@@ -1,30 +1,54 @@
< script setup lang = "ts" >
import { computed , onMounted , ref , watch } from "vue" ;
import { computed , nextTick , onMounted , ref , watch } from "vue" ;
import { useRoute , useRouter } from "vue-router" ;
import { ChevronDown , ChevronRight , ZoomIn , ZoomOut } from "lucide-vue-next" ;
import { tasktreeAPI , type TaskTreeGroup , type TaskTreeReport , type TaskTreeTask , type TaskTreeTimeline } from "@/api" ;
import AsyncBoundary from "@/components/common/AsyncBoundary.vue" ;
import BaseDialog from "@/components/common/BaseDialog.vue" ;
import ContentViewer from "@/components/common/ContentViewer.vue" ;
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue" ;
import PageCommandBar from "@/components/layout/PageCommandBar.vue" ;
import StatusStrip from "@/components/layout/StatusStrip.vue" ;
const route = useRoute ( ) ;
const router = useRouter ( ) ;
const groups = ref < TaskTreeGroup [ ] > ( [ ] ) ;
const timeline = ref < TaskTreeTimeline | null > ( null ) ;
const selectedTask = ref < TaskTreeTask | null > ( null ) ;
const collapsedTaskIds = ref ( new Set < string > ( ) ) ;
const timelineHeaderElement = ref < HTMLElement | null > ( null ) ;
const taskLabelsElement = ref < HTMLElement | null > ( null ) ;
const timelineBodyElement = ref < HTMLElement | null > ( null ) ;
const timelineScaleIndex = ref ( 1 ) ;
const loading = ref ( false ) ;
const error = ref < string | null > ( null ) ;
const groupId = computed ( ( ) => String ( route . params . groupId || groups . value [ 0 ] ? . id || "" ) ) ;
const datedTasks = computed ( ( ) => timeline . value ? . tasks . filter ( ( task ) => task . startAt || task . dueAt ) ? ? [ ] ) ;
const childrenByParent = computed ( ( ) => {
const children = new Map < string , TaskTreeTask [ ] > ( ) ;
for ( const task of timeline . value ? . tasks ? ? [ ] ) {
if ( ! task . parentId ) continue ;
children . set ( task . parentId , [ ... ( children . get ( task . parentId ) ? ? [ ] ) , task ] ) ;
}
return children ;
} ) ;
const visibleTasks = computed ( ( ) => ( timeline . value ? . tasks ? ? [ ] ) . filter (
( task ) => ! task . parentId || ! collapsedTaskIds . value . has ( task . parentId )
) ) ;
const timelineScales = [
{ label : "周" , stepMs : 2 * 86400000 , tickWidth : 96 , precision : "date" } ,
{ label : "日" , stepMs : 86400000 , tickWidth : 144 , precision : "date" } ,
{ label : "时" , stepMs : 6 * 3600000 , tickWidth : 72 , precision : "hour" } ,
{ label : "分" , stepMs : 3600000 , tickWidth : 64 , precision : "minute" } ,
{ label : "秒" , stepMs : 15 * 60000 , tickWidth : 88 , precision : "second" }
] as const ;
const timelineScale = computed ( ( ) => timelineScales [ timelineScaleIndex . value ] ) ;
const range = computed ( ( ) => timelineRange ( datedTasks . value ) ) ;
const days = computed ( ( ) => Array . from ( { length : range . value . dayCount } , ( _ , index ) => new Date ( range . value . start . getTime ( ) + index * 86400000 ) ) ) ;
const timelineTicks = computed ( ( ) => Array . from (
{ length : Math . ceil ( range . value . dayCount * 86400000 / timelineScale . value . stepMs ) } ,
( _ , index ) => new Date ( range . value . start . getTime ( ) + index * timelineScale . value . stepMs )
) ) ;
const timelineWidth = computed ( ( ) => timelineTicks . value . length * timelineScale . value . tickWidth ) ;
const selectedReports = computed < TaskTreeReport [ ] > ( ( ) => selectedTask . value ? timeline . value ? . reports . filter ( ( report ) => report . taskId === selectedTask . value ? . id ) ? ? [ ] : [ ] ) ;
const statusItems = computed ( ( ) => [
{ id : "groups" , label : "Taskgroups" , value : groups . value . length , detail : "PostgreSQL authority" } ,
{ id : "tasks" , label : "Tasks" , value : timeline . value ? . tasks . filter ( ( task ) => task . kind === "task" ) . length ? ? 0 , detail : "甘特主行" } ,
{ id : "subtasks" , label : "Subtasks" , value : timeline . value ? . tasks . filter ( ( task ) => task . kind === "subtask" ) . length ? ? 0 , detail : "二级执行项" } ,
{ id : "reports" , label : "Reports" , value : timeline . value ? . reports . length ? ? 0 , detail : ` ${ timeline . value ? . milestones . length ? ? 0 } milestones ` }
] ) ;
const asyncState = computed ( ( ) => loading . value ? timeline . value ? "refreshing" : "initial-loading" : error . value ? timeline . value ? "partial" : "error" : timeline . value ? . tasks . length ? "ready" : "empty" ) ;
onMounted ( loadGroups ) ;
@@ -47,6 +71,38 @@ async function loadTimeline(id = groupId.value) {
timeline . value = response . data . data ;
}
function selectGroup ( event : Event ) { void router . push ( ` /projects/tasktree/ ${ encodeURIComponent ( ( event . target as HTMLSelectElement ) . value ) } ` ) ; }
function toggleTask ( taskId : string ) {
const next = new Set ( collapsedTaskIds . value ) ;
if ( next . has ( taskId ) ) next . delete ( taskId ) ;
else next . add ( taskId ) ;
collapsedTaskIds . value = next ;
}
async function changeTimelineScale ( direction : - 1 | 1 ) {
const body = timelineBodyElement . value ;
const centerRatio = body && body . scrollWidth > 0 ? ( body . scrollLeft + body . clientWidth / 2 ) / body . scrollWidth : 0 ;
timelineScaleIndex . value = Math . min ( timelineScales . length - 1 , Math . max ( 0 , timelineScaleIndex . value + direction ) ) ;
await nextTick ( ) ;
if ( body ) {
body . scrollLeft = Math . max ( 0 , centerRatio * body . scrollWidth - body . clientWidth / 2 ) ;
if ( timelineHeaderElement . value ) timelineHeaderElement . value . scrollLeft = body . scrollLeft ;
}
}
function syncTimelineBody ( ) {
const body = timelineBodyElement . value ;
if ( ! body ) return ;
if ( timelineHeaderElement . value && timelineHeaderElement . value . scrollLeft !== body . scrollLeft ) timelineHeaderElement . value . scrollLeft = body . scrollLeft ;
if ( taskLabelsElement . value && taskLabelsElement . value . scrollTop !== body . scrollTop ) taskLabelsElement . value . scrollTop = body . scrollTop ;
}
function syncTimelineHeader ( ) {
const header = timelineHeaderElement . value ;
const body = timelineBodyElement . value ;
if ( header && body && body . scrollLeft !== header . scrollLeft ) body . scrollLeft = header . scrollLeft ;
}
function syncTaskLabels ( ) {
const labels = taskLabelsElement . value ;
const body = timelineBodyElement . value ;
if ( labels && body && body . scrollTop !== labels . scrollTop ) body . scrollTop = labels . scrollTop ;
}
function timelineRange ( tasks : TaskTreeTask [ ] ) {
const values = tasks . flatMap ( ( task ) => [ task . startAt , task . dueAt ] ) . filter ( Boolean ) . map ( ( value ) => new Date ( value as string ) . getTime ( ) ) ;
const today = startDay ( new Date ( ) ) ; const min = values . length ? Math . min ( ... values ) : today . getTime ( ) ; const max = values . length ? Math . max ( ... values ) : min + 6 * 86400000 ;
@@ -59,10 +115,38 @@ function barStyle(task: TaskTreeTask) {
const left = Math . max ( 0 , ( start - range . value . start . getTime ( ) ) / 86400000 ) * unit ; const width = Math . max ( unit * 0.65 , ( ( end - start ) / 86400000 + 1 ) * unit ) ;
return { left : ` ${ left } % ` , width : ` ${ Math . min ( 100 - left , width ) } % ` } ;
}
function milestoneStyle ( occursAt : string ) { return barStyle ( { startAt : occursAt , dueAt : occursAt } as TaskTreeTask ) ; }
function dayLabel ( date : Date ) { return ` ${ date . getUTCMonth ( ) + 1 } / ${ date . getUTCDate ( ) } ` ; }
function milestoneStyle ( occursAt : string ) {
const time = new Date ( occursAt ) . getTime ( ) ;
const left = Math . max ( 0 , ( time - range . value . start . getTime ( ) ) / 86400000 ) * ( 100 / range . value . dayCount ) ;
return { left : ` ${ Math . min ( 100 , left ) } % ` } ;
}
function tickLabel ( date : Date ) {
const monthDay = ` ${ date . getUTCMonth ( ) + 1 } / ${ date . getUTCDate ( ) } ` ;
if ( timelineScale . value . precision === "date" ) return monthDay ;
const hour = String ( date . getUTCHours ( ) ) . padStart ( 2 , "0" ) ;
if ( timelineScale . value . precision === "hour" ) return ` ${ monthDay } ${ hour } 时 ` ;
const minute = String ( date . getUTCMinutes ( ) ) . padStart ( 2 , "0" ) ;
if ( timelineScale . value . precision === "minute" ) return ` ${ monthDay } ${ hour } : ${ minute } ` ;
const second = String ( date . getUTCSeconds ( ) ) . padStart ( 2 , "0" ) ;
return ` ${ monthDay } ${ hour } : ${ minute } : ${ second } ` ;
}
function dateLabel ( value : string | null ) { return value ? new Date ( value ) . toLocaleString ( ) : "未设置" ; }
function statusLabel ( status : string ) { return ( { pending : "待处理" , in _progress : "进行中" , completed : "已完成" , blocked : "阻塞" } as Record < string , string > ) [ status ] || status ; }
function displayTaskTitle ( task : TaskTreeTask | null ) {
const title = String ( task ? . title || "任务详情" ) . trim ( ) ;
const separator = title . search ( /[: :]/u ) ;
return separator >= 10 && separator <= 120 ? title . slice ( 0 , separator ) : title ;
}
function reportMarkdown ( report : TaskTreeReport ) {
const lines = report . body . replace ( /\r\n?/gu , "\n" ) . split ( "\n" ) ;
const firstContent = lines . findIndex ( ( line ) => line . trim ( ) . length > 0 ) ;
if ( firstContent < 0 ) return report . body ;
const heading = lines [ firstContent ] . match ( /^#{1,6}\s+(.+?)\s*#*\s*$/u ) ;
if ( ! heading || normalizeHeading ( heading [ 1 ] ) !== normalizeHeading ( report . title ) ) return report . body ;
lines . splice ( firstContent , 1 ) ;
return lines . join ( "\n" ) . trimStart ( ) ;
}
function normalizeHeading ( value : string ) { return value . replace ( /\s+/gu , "" ) . toLocaleLowerCase ( ) ; }
< / script >
< template >
@@ -70,25 +154,166 @@ function statusLabel(status: string) { return ({ pending: "待处理", in_progre
< PageCommandBar title = "TaskTree" eyebrow = "项目时间线" description = "任务、时间节点与执行报告" >
< template # actions > < label class = "tasktree-group-select" > < span class = "sr-only" > Taskgroup < / span > < select :value = "groupId" @change ="selectGroup" > < option v-for = "group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label><button class="icon-button" type="button" title="刷新" aria-label="刷新" :disabled="loading" @click="loadTimeline()"><span aria-hidden="true" > ↻ < / span > < / button > < / template >
< / PageCommandBar >
< StatusStrip :items = "statusItems" / >
< AsyncBoundary :state = "asyncState" title = "TaskTree 暂无任务" : message = "error || '通过 CLI 创建 taskgroup 和任务后会显示在时间轴中。'" @retry ="loadGroups" >
< section v-if = "timeline" class="tasktree-workspace" aria-label="TaskTree 甘特图" >
< header class = "tasktree-context" > < div > < strong > { { timeline . group . name } } < / strong > < p > { { timeline . group . description || '未填写 taskgroup 说明' } } < / p > < / div > < span > { { days . length } } 天 < / span > < / header >
< div class = "tasktree-gantt" : style = "{ '--day-count': days.length }" >
< div class = "tasktree-corner" > 任务 < / div > < div class = "tasktree-days" > < span v-for = "day in days" :key="day.toISOString()" > {{ dayLabel ( day ) }} < / span > < / div >
< template v-for = "task in timeline.tasks" :key="task.id" >
< button class = "tasktree-label" : class = "{ subtask: task.kind === 'subtask' }" type = "button" @click ="selectedTask = task" > < span class = "tasktree-status" :data-status = "task.status" / > < span > { { task . title } } < / span > < / button >
< div class = "tasktree-track" : style = "{ backgroundSize: `${100 / days.length}% 100%` }" > < button class = "tasktree-bar" : class = "[`status-${task.status}`, { subtask: task.kind === 'subtask' }]" :style = "barStyle(task)" type = "button" @click ="selectedTask = task" > < span > { { statusLabel ( task . status ) } } < / span > < / button > < span v-for = "milestone in timeline.milestones.filter((item) => item.taskId === task.id)" :key="milestone.id" class="tasktree-milestone" :title="milestone.title" :style="milestoneStyle(milestone.occursAt)" / > < / div >
< / template >
< header class = "tasktree-context" >
< div > < strong > { { timeline . group . name } } < / strong > < p > { { timeline . group . description || '未填写 taskgroup 说明' } } < / p > < / div >
< div class = "tasktree-timeline-tools" >
< span > { { range . dayCount } } 天 < / span >
< div class = "tasktree-zoom" role = "group" aria -label = " 时间轴缩放 " >
< button type = "button" title = "缩小时间尺度" aria -label = " 缩小时间尺度 " : disabled = "timelineScaleIndex === 0" @click ="changeTimelineScale(-1)" > < ZoomOut :size = "15" aria -hidden = " true " / > < / button >
< output :title = "`当前时间尺度:${timelineScale.label}`" aria -live = " polite " > { { timelineScale . label } } < / output >
< button type = "button" title = "放大时间尺度" aria -label = " 放大时间尺度 " : disabled = "timelineScaleIndex === timelineScales.length - 1" @click ="changeTimelineScale(1)" > < ZoomIn :size = "15" aria -hidden = " true " / > < / button >
< / div >
< / div >
< / header >
< div class = "tasktree-gantt" : style = "{ '--tick-count': timelineTicks.length, '--tick-width': `${timelineScale.tickWidth}px`, '--timeline-width': `${timelineWidth}px` }" >
< div class = "tasktree-corner" > 任务 < / div >
< div ref = "timelineHeaderElement" class = "tasktree-timeline-header" @scroll ="syncTimelineHeader" >
< div class = "tasktree-days" >
< span v-for = "tick in timelineTicks" :key="tick.toISOString()" > {{ tickLabel ( tick ) }} < / span >
< / div >
< / div >
< div ref = "taskLabelsElement" class = "tasktree-labels" @scroll ="syncTaskLabels" >
< div class = "tasktree-labels-canvas" >
< div v-for = "task in visibleTasks" :key="task.id" class="tasktree-label" :class="{ subtask: task.kind === 'subtask' }" :data-dated="Boolean(task.startAt || task.dueAt)" >
< button
v - if = "childrenByParent.has(task.id)"
class = "tasktree-disclosure"
type = "button"
: title = "collapsedTaskIds.has(task.id) ? '展开子任务' : '收起子任务'"
: aria - label = "collapsedTaskIds.has(task.id) ? `展开 ${task.title} 的子任务` : `收起 ${task.title} 的子任务`"
: aria - expanded = "!collapsedTaskIds.has(task.id)"
@ click = "toggleTask(task.id)"
>
< ChevronRight v-if = "collapsedTaskIds.has(task.id)" :size="15" aria-hidden="true" / >
< ChevronDown v-else :size = "15" aria -hidden = " true " / >
< / button >
< span v-else class = "tasktree-disclosure-spacer" aria -hidden = " true " / >
< button class = "tasktree-title" type = "button" @click ="selectedTask = task" >
< span class = "tasktree-status" :data-status = "task.status" / >
< span :title = "task.title" > { { displayTaskTitle ( task ) } } < / span >
< / button >
< / div >
< / div >
< / div >
< div ref = "timelineBodyElement" class = "tasktree-timeline-body" @scroll ="syncTimelineBody" >
< div class = "tasktree-timeline-canvas" >
< div v-for = "task in visibleTasks" :key="task.id" class="tasktree-track" :style="{ backgroundSize: `${100 / timelineTicks.length}% 100%` }" >
< button
v - if = "task.startAt || task.dueAt"
class = "tasktree-bar"
: class = "[`status-${task.status}`, { subtask: task.kind === 'subtask' }]"
: style = "barStyle(task)"
type = "button"
: aria - label = "`${task.title}, ${statusLabel(task.status)}`"
@ click = "selectedTask = task"
>
< span > { { statusLabel ( task . status ) } } < / span >
< / button >
< span
v - for = "milestone in timeline.milestones.filter((item) => item.taskId === task.id)"
: key = "milestone.id"
class = "tasktree-milestone"
role = "img"
: aria - label = "`里程碑:${milestone.title}`"
: title = "milestone.title"
: style = "milestoneStyle(milestone.occursAt)"
/ >
< / div >
< / div >
< / div >
< / div >
< footer class = "tasktree-statusbar" aria -label = " TaskTree 状态 " >
< span > < strong > { { groups . length } } < / strong > Taskgroups < / span >
< span > < strong > { { timeline . tasks . filter ( ( task ) => task . kind === 'task' ) . length } } < / strong > Tasks < / span >
< span > < strong > { { timeline . tasks . filter ( ( task ) => task . kind === 'subtask' ) . length } } < / strong > Subtasks < / span >
< span > < strong > { { timeline . reports . length } } < / strong > Reports < / span >
< span > < strong > { { timeline . milestones . length } } < / strong > Milestones < / span >
< span class = "tasktree-statusbar-authority" > PostgreSQL < / span >
< / footer >
< / section >
< / AsyncBoundary >
< BaseDialog :open = "Boolean(selectedTask)" : title = "selectedTask?.title || '任务详情' " : description = "selectedTask ? `${selectedTask.kind === 'subtask' ? 'Subtask' : 'Task'} · ${statusLabel(selectedTask.status)}` : ''" wide @close ="selectedTask = null" >
< div v-if = "selectedTask" class="tasktree-detail"><dl><div><dt>开始</dt><dd>{{ dateLabel(selectedTask.startAt) }}</dd></div><div><dt>截止</dt><dd>{{ dateLabel(selectedTask.dueAt) }}</dd></div><div><dt>状态</dt><dd>{{ statusLabel(selectedTask.status) }}</dd></div></dl><section><h3>任务说明</h3><p>{{ selectedTask.description || ' 未填写任务说明。' }} </p></section><section><h3>执行报告</h3><div v-if="selectedReports.length" class="tasktree-reports"><article v-for="report in selectedReports" :key="report.id"><header><strong>{{ report.title }}</strong>< time>{{ dateLabel(report.createdAt) }}</time></header><p>{{ report.body }}</p ></article></div><p v-else class="tasktree-muted" > 尚无执行报告 。 < / p > < / section > < / div >
< BaseDialog :open = "Boolean(selectedTask)" :title = "displayTaskTitle(selectedTask) " : description = "selectedTask ? `${selectedTask.kind === 'subtask' ? 'Subtask' : 'Task'} · ${statusLabel(selectedTask.status)}` : ''" surface -class = " tasktree -detail -dialog " wide @close ="selectedTask = null" >
< div v-if = "selectedTask" class="tasktree-detail"><dl><div><dt>开始</dt><dd>{{ dateLabel(selectedTask.startAt) }}</dd></div><div><dt>截止</dt><dd>{{ dateLabel(selectedTask.dueAt) }}</dd></div><div><dt>状态</dt><dd>{{ statusLabel(selectedTask.status) }}</dd></div></dl><section><h3>任务说明</h3><MessageMarkdown v-if="selectedTask.description" class="tasktree-description" :source="selectedTask.description" /><p v-else class="tasktree-muted"> 未填写任务说明。</p></section><section><h3>执行报告</h3><div v-if="selectedReports.length" class="tasktree-reports"><article v-for="report in selectedReports" :key="report.id"><time>{{ dateLabel(report.createdAt) }}</time><ContentViewer :content="reportMarkdown(report)" mode="markdown" :title="report.title" :filename="`${report.title}.md`" :line-numbers="false" / ></article></div><p v-else class="tasktree-muted" > 尚无执行报告 。 < / p > < / section > < / div >
< / BaseDialog >
< / main >
< / template >
< style scoped >
. tasktree - page { display : grid ; gap : 12 px ; min - width : 0 } . tasktree - workspace { border : 1 px solid var ( -- border - color ) ; background : var ( -- surface - primary ) ; min - width : 0 ; overflow : hidden } . tasktree - context { min - height : 58 px ; padding : 10 px 14 px ; border - bottom : 1 px solid var ( -- border - color ) ; display : flex ; align - items : center ; justify - content : space - between ; gap : 16 px } . tasktree - context p { margin : 3 px 0 0 ; color : var ( -- text - secondary ) ; font - size : 12 px } . tasktree - context > span { color : var ( -- text - secondary ) ; font - size : 12 px } . tasktree - gantt { display : grid ; grid - template - columns : minmax ( 210 px , 280 px ) minmax ( 720 px , 1 fr ) ; max - height : calc ( 100 vh - 285 px ) ; overflow : auto } . tasktree - corner { position : sticky ; left : 0 ; top : 0 ; z - index : 4 ; padding : 9 px 14 px ; background : var ( -- surface - secondary ) ; border - right : 1 px solid var ( -- border - color ) ; border - bottom : 1 px solid var ( -- border - color ) ; font - size : 11 px ; font - weight : 700 } . tasktree - days { position : sticky ; top : 0 ; z - index : 3 ; display : grid ; grid - template - columns : repeat ( var ( -- day - count ) , minmax ( 28 px , 1 fr ) ) ; background : var ( -- surface - secondary ) ; border - bottom : 1 px solid var ( -- border - color ) } . tasktree - days span { padding : 9 px 2 px ; border - right : 1 px solid var ( -- border - subtle ) ; font - size : 10 px ; text - align : center ; color : var ( -- text - secondary ) } . tasktree - label { position : sticky ; left : 0 ; z - index : 2 ; height : 46 px ; border : 0 ; border - right : 1 px solid var ( -- border - color ) ; border - bottom : 1 px solid var ( -- border - subtle ) ; background : var ( -- surface - primary ) ; padding : 0 12 px ; display : flex ; align - items : center ; gap : 8 px ; text - align : left ; color : var ( -- text - primary ) ; min - width : 0 } . tasktree - label span : last - child { overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap } . tasktree - label . subtask { padding - left : 34 px ; color : var ( -- text - secondary ) } . tasktree - status { width : 7 px ; height : 7 px ; border - radius : 50 % ; background : # 83909 c ; flex : 0 0 auto } . tasktree - status [ data - status = completed ] { background : # 1 f9d67 } . tasktree - status [ data - status = in _progress ] { background : # 2376 c9 } . tasktree - status [ data - status = blocked ] { background : # c73e48 } . tasktree - track { position : relative ; height : 46 px ; border - bottom : 1 px solid var ( -- border - subtle ) ; background - image : linear - gradient ( to right , var ( -- border - subtle ) 1 px , transparent 1 px ) } . tasktree - bar { position : absolute ; top : 10 px ; height : 26 px ; border : 0 ; border - radius : 4 px ; background : # 2376 c9 ; color : white ; min - width : 18 px ; padding : 0 8 px ; text - align : left ; overflow : hidden } . tasktree - bar span { font - size : 10 px ; white - space : nowrap } . tasktree - bar . subtask { top : 14 px ; height : 18 px ; background : # 5 f7d99 } . tasktree - bar . status - completed { background : # 23845 c } . tasktree - bar . status - blocked { background : # b84149 } . tasktree - bar . status - pending { background : # 687581 } . tasktree - milestone { position : absolute ; top : 7 px ; width : 12 px ; height : 12 px ; background : # b36b00 ; border : 2 px solid var ( -- surface - primary ) ; transform : translateX ( - 6 px ) rotate ( 45 deg ) ; z - index : 2 } . tasktree - group - select { display : flex ; align - items : center } . tasktree - group - select select { min - width : 180 px ; padding : 7 px 10 px ; border : 1 px solid var ( -- border - color ) ; background : var ( -- surface - primary ) ; color : var ( -- text - primary ) } . tasktree - detail { display : grid ; gap : 18 px } . tasktree - detail dl { display : grid ; grid - template - columns : repeat ( 3 , 1 fr ) ; gap : 8 px ; margin : 0 } . tasktree - detail dl div { padding : 10 px ; border - left : 2 px solid # 2376 c9 ; background : var ( -- surface - secondary ) } . tasktree - detail dt { font - size : 11 px ; color : var ( -- text - secondary ) } . tasktree - detail dd { margin : 4 px 0 0 } . tasktree - detail h3 { font - size : 13 px ; margin : 0 0 8 px } . tasktree - detail p { margin : 0 ; white - space : pre - wrap } . tasktree - reports { display : grid ; gap : 8 px } . tasktree - reports article { border : 1 px solid var ( -- border - color ) ; padding : 10 px } . tasktree - reports header { display : flex ; justify - content : space - between ; gap : 10 px } . tasktree - reports time , . tasktree - muted { font - size : 11 px ; color : var ( -- text - secondary ) } @ media ( max - width : 720 px ) { . tasktree - gantt { grid - template - columns : 170 px minmax ( 680 px , 1 fr ) ; max - height : calc ( 100 vh - 250 px ) } . tasktree - detail dl { grid - template - columns : 1 fr } . tasktree - group - select select { min - width : 130 px ; max - width : 42 vw } }
. tasktree - page { display : grid ; gap : 12 px ; min - width : 0 ; }
. tasktree - workspace { display : grid ; grid - template - rows : auto minmax ( 0 , 1 fr ) auto ; height : calc ( 100 dvh - 190 px ) ; min - height : 360 px ; border : 1 px solid var ( -- border - color ) ; background : var ( -- surface - primary ) ; min - width : 0 ; overflow : hidden ; }
. tasktree - context { min - height : 58 px ; padding : 10 px 14 px ; border - bottom : 1 px solid var ( -- border - color ) ; display : flex ; align - items : center ; justify - content : space - between ; gap : 16 px ; }
. tasktree - context p { margin : 3 px 0 0 ; color : var ( -- text - secondary ) ; font - size : 12 px ; }
. tasktree - timeline - tools { display : flex ; align - items : center ; gap : 10 px ; color : var ( -- text - secondary ) ; font - size : 12 px ; }
. tasktree - zoom { display : grid ; grid - template - columns : 28 px 42 px 28 px ; height : 28 px ; align - items : stretch ; border : 1 px solid var ( -- border - color ) ; background : var ( -- surface - primary ) ; }
. tasktree - zoom button { display : grid ; place - items : center ; padding : 0 ; border : 0 ; background : transparent ; color : var ( -- text - secondary ) ; cursor : pointer ; }
. tasktree - zoom button : hover : not ( : disabled ) { background : var ( -- surface - secondary ) ; color : var ( -- text - primary ) ; }
. tasktree - zoom button : disabled { opacity : 0.35 ; cursor : default ; }
. tasktree - zoom output { display : grid ; place - items : center ; border - inline : 1 px solid var ( -- border - color ) ; color : var ( -- text - primary ) ; font - family : var ( -- console - font - mono ) ; font - size : 10 px ; }
. tasktree - gantt { display : grid ; grid - template - columns : minmax ( 210 px , 280 px ) minmax ( 0 , 1 fr ) ; grid - template - rows : 34 px minmax ( 0 , 1 fr ) ; min - height : 0 ; overflow : hidden ; }
. tasktree - corner { display : flex ; align - items : center ; padding : 0 14 px ; border - right : 1 px solid var ( -- border - color ) ; border - bottom : 1 px solid var ( -- border - color ) ; background : transparent ; font - size : 11 px ; font - weight : 700 ; }
. tasktree - timeline - header { min - width : 0 ; overflow - x : auto ; overflow - y : hidden ; border - bottom : 1 px solid var ( -- border - color ) ; background : transparent ; scrollbar - width : none ; overscroll - behavior - inline : contain ; }
. tasktree - timeline - header : : - webkit - scrollbar ,
. tasktree - labels : : - webkit - scrollbar { display : none ; }
. tasktree - days { width : var ( -- timeline - width ) ; height : 100 % ; display : grid ; grid - template - columns : repeat ( var ( -- tick - count ) , var ( -- tick - width ) ) ; }
. tasktree - days span { display : flex ; align - items : center ; justify - content : center ; min - width : 0 ; padding : 0 4 px ; overflow : hidden ; border - right : 1 px solid var ( -- border - subtle ) ; color : var ( -- text - secondary ) ; font - size : 10 px ; white - space : nowrap ; }
. tasktree - labels { min - width : 0 ; min - height : 0 ; overflow - y : auto ; overflow - x : hidden ; border - right : 1 px solid var ( -- border - color ) ; scrollbar - width : none ; overscroll - behavior - block : contain ; }
. tasktree - labels - canvas { min - height : 100 % ; display : grid ; grid - auto - rows : 46 px ; align - content : start ; }
. tasktree - label { height : 46 px ; border - bottom : 1 px solid var ( -- border - subtle ) ; background : transparent ; padding : 0 8 px ; display : flex ; align - items : center ; color : var ( -- text - primary ) ; min - width : 0 ; }
. tasktree - label : hover { background : rgb ( 255 255 255 / 32 % ) ; }
. tasktree - label . subtask { padding - left : 30 px ; color : var ( -- text - secondary ) ; }
. tasktree - disclosure ,
. tasktree - disclosure - spacer { width : 24 px ; height : 28 px ; flex : 0 0 24 px ; }
. tasktree - disclosure { display : grid ; place - items : center ; padding : 0 ; border : 0 ; background : transparent ; color : var ( -- text - secondary ) ; cursor : pointer ; }
. tasktree - disclosure : hover { color : var ( -- text - primary ) ; background : var ( -- surface - secondary ) ; }
. tasktree - title { display : flex ; align - items : center ; gap : 8 px ; min - width : 0 ; height : 100 % ; padding : 0 4 px ; border : 0 ; background : transparent ; color : inherit ; text - align : left ; cursor : pointer ; }
. tasktree - title span : last - child { overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
. tasktree - status { width : 7 px ; height : 7 px ; border - radius : 50 % ; background : # 83909 c ; flex : 0 0 auto ; }
. tasktree - status [ data - status = "completed" ] { background : # 1 f9d67 ; }
. tasktree - status [ data - status = "in_progress" ] { background : # 2376 c9 ; }
. tasktree - status [ data - status = "blocked" ] { background : # c73e48 ; }
. tasktree - timeline - body { min - width : 0 ; min - height : 0 ; overflow : auto ; scrollbar - gutter : stable ; overscroll - behavior : contain ; touch - action : pan - x pan - y ; }
. tasktree - timeline - canvas { width : var ( -- timeline - width ) ; min - height : 100 % ; display : grid ; grid - auto - rows : 46 px ; align - content : start ; }
. tasktree - track { position : relative ; height : 46 px ; border - bottom : 1 px solid var ( -- border - subtle ) ; background - image : linear - gradient ( to right , var ( -- border - subtle ) 1 px , transparent 1 px ) ; }
. tasktree - bar { position : absolute ; top : 10 px ; height : 26 px ; min - width : 18 px ; padding : 0 ; border : 0 ; border - radius : 4 px ; background : # 2376 c9 ; color : white ; overflow : visible ; }
. tasktree - bar span { position : absolute ; left : 7 px ; top : 50 % ; transform : translateY ( - 50 % ) ; font - size : 10 px ; line - height : 1 ; white - space : nowrap ; text - shadow : 0 1 px 2 px rgb ( 0 0 0 / 45 % ) ; pointer - events : none ; }
. tasktree - bar . subtask { top : 14 px ; height : 18 px ; background : # 5 f7d99 ; }
. tasktree - bar . status - completed { background : # 23845 c ; }
. tasktree - bar . status - blocked { background : # b84149 ; }
. tasktree - bar . status - pending { background : # 687581 ; }
. tasktree - milestone { position : absolute ; top : 15 px ; z - index : 2 ; width : 16 px ; height : 16 px ; transform : translateX ( - 50 % ) ; background : # d39114 ; clip - path : polygon ( 50 % 0 , 100 % 50 % , 50 % 100 % , 0 50 % ) ; filter : drop - shadow ( 0 0 0.5 px # 664000 ) drop - shadow ( 0 1 px 1 px rgb ( 0 0 0 / 35 % ) ) ; }
. tasktree - statusbar { min - width : 0 ; min - height : 28 px ; padding : 0 10 px ; display : flex ; align - items : center ; gap : 0 ; overflow - x : auto ; border - top : 1 px solid var ( -- border - color ) ; background : var ( -- surface - secondary ) ; color : var ( -- text - secondary ) ; font - family : var ( -- console - font - mono ) ; font - size : 10 px ; white - space : nowrap ; scrollbar - width : thin ; }
. tasktree - statusbar span { display : inline - flex ; align - items : center ; gap : 4 px ; }
. tasktree - statusbar span + span : : before { margin : 0 9 px ; color : var ( -- border - color ) ; content : "|" ; }
. tasktree - statusbar strong { color : var ( -- text - primary ) ; font - weight : 700 ; }
. tasktree - statusbar - authority { margin - left : auto ; }
. tasktree - statusbar - authority : : after { width : 6 px ; height : 6 px ; margin - left : 6 px ; border - radius : 50 % ; background : # 1 f9d67 ; content : "" ; }
. tasktree - group - select { display : flex ; align - items : center ; }
. tasktree - group - select select { min - width : 180 px ; padding : 7 px 10 px ; border : 1 px solid var ( -- border - color ) ; background : var ( -- surface - primary ) ; color : var ( -- text - primary ) ; }
. tasktree - detail { display : grid ; gap : 18 px ; }
. tasktree - detail dl { display : grid ; grid - template - columns : repeat ( 3 , 1 fr ) ; gap : 8 px ; margin : 0 ; }
. tasktree - detail dl div { padding : 10 px ; border - left : 2 px solid # 2376 c9 ; background : var ( -- surface - secondary ) ; }
. tasktree - detail dt { font - size : 11 px ; color : var ( -- text - secondary ) ; }
. tasktree - detail dd { margin : 4 px 0 0 ; }
. tasktree - detail h3 { font - size : 13 px ; margin : 0 0 8 px ; }
. tasktree - detail p { margin : 0 ; white - space : pre - wrap ; }
. tasktree - description { font - size : 14 px ; line - height : 1.65 ; }
. tasktree - reports { display : grid ; gap : 8 px ; }
. tasktree - reports article { border : 1 px solid var ( -- border - color ) ; padding : 10 px ; }
. tasktree - reports time ,
. tasktree - muted { font - size : 11 px ; color : var ( -- text - secondary ) ; }
. tasktree - reports time { display : block ; margin : 0 0 6 px ; text - align : right ; }
. tasktree - reports : deep ( . content - viewer ) { max - height : min ( 52 vh , 520 px ) ; }
. tasktree - reports : deep ( . content - viewer - body ) { overflow : auto ; }
@ media ( max - width : 720 px ) {
. tasktree - workspace { height : calc ( 100 dvh - 225 px ) ; min - height : 400 px ; }
. tasktree - context { align - items : flex - start ; }
. tasktree - timeline - tools { flex - direction : column ; align - items : flex - end ; gap : 4 px ; }
. tasktree - gantt { grid - template - columns : 170 px minmax ( 0 , 1 fr ) ; }
. tasktree - statusbar - authority { margin - left : 0 ; }
. tasktree - detail dl { grid - template - columns : 1 fr ; }
. tasktree - group - select select { min - width : 130 px ; max - width : 42 vw ; }
}
< / style >