@@ -2,27 +2,39 @@
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 { tasktreeAPI , type TaskTreeGroup , type TaskTreeGroupOverview , type TaskTreeTask , type TaskTreeTimeline } from "@/api/tasktree " ;
import AsyncBoundary from "@/components/common/AsyncBoundary.vue" ;
import BaseDialog from "@/components/common/BaseDialog.vue" ;
import ContentViewer from "@/components/common/ContentViewer .vue" ;
import TaskTreeDetailPanel from "@/components/tasktree/TaskTreeDetailPanel .vue" ;
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue" ;
import PageCommandBar from "@/components/layout/PageCommandBar.vue" ;
const route = useRoute ( ) ;
const router = useRouter ( ) ;
const groups = ref < TaskTreeGroup [ ] > ( [ ] ) ;
const overview = ref < TaskTreeGroupOverview [ ] > ( [ ] ) ;
const timeline = ref < TaskTreeTimeline | null > ( null ) ;
const selectedTask = ref < TaskTreeTask | null > ( null ) ;
const detailMode = ref < "dialog" | "docked" > ( route . query . detail === "docked" ? "docked" : "dialog" ) ;
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 labelChildScrollers = new Map < string , HTMLElement > ( ) ;
const timelineChildScrollers = new Map < string , HTMLElement > ( ) ;
const initializedChildScrollers = new Set < string > ( ) ;
const childScrollEdges = ref ( new Map < string , { atTop : boolean ; atBottom : boolean } > ( ) ) ;
const timelineScaleIndex = ref ( 1 ) ;
const taskColumnWidth = ref ( typeof window !== "undefined" ? Math . round ( window . innerWidth * ( window . innerWidth <= 720 ? 0.55 : 0.21 ) ) : 400 ) ;
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 groupId = computed ( ( ) => String ( route . params . groupId || "" ) ) ;
const requestedTaskId = computed ( ( ) => String ( route . query . task || "" ) ) ;
const requestedDetailMode = computed < "dialog" | "docked" > ( ( ) => route . query . detail === "docked" ? "docked" : "dialog" ) ;
const isGlobalView = computed ( ( ) => ! groupId . value ) ;
const datedItems = computed ( ( ) => isGlobalView . value
? overview . value . filter ( ( item ) => item . startAt || item . dueAt )
: 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 ? ? [ ] ) {
@@ -31,9 +43,7 @@ const childrenByParent = computed(() => {
}
return children ;
} ) ;
const visibleTasks = computed ( ( ) => ( timeline . value ? . tasks ? ? [ ] ) . filter (
( task ) => ! task . parentId || ! collapsedTaskIds . value . has ( task . parentId )
) ) ;
const rootTasks = computed ( ( ) => ( timeline . value ? . tasks ? ? [ ] ) . filter ( ( task ) => ! task . parentId ) ) ;
const timelineScales = [
{ label : "周" , stepMs : 2 * 86400000 , tickWidth : 96 , precision : "date" } ,
{ label : "日" , stepMs : 86400000 , tickWidth : 144 , precision : "date" } ,
@@ -42,40 +52,170 @@ const timelineScales = [
{ label : "秒" , stepMs : 15 * 60000 , tickWidth : 88 , precision : "second" }
] as const ;
const timelineScale = computed ( ( ) => timelineScales [ timelineScaleIndex . value ] ) ;
const range = computed ( ( ) => timelineRange ( datedTasks . value ) ) ;
const range = computed ( ( ) => timelineRange ( datedItems . value ) ) ;
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 asyncState = computed ( ( ) => loading . value ? timeline . value ? "refreshing" : "initial-loading" : error . value ? timeline . value ? "partial" : "error" : timeline . value ? . tasks . length ? "ready" : "empty" ) ;
const hasActiveData = computed ( ( ) => isGlobalView . value ? overview . value . length > 0 : Boolean ( timeline . value ) ) ;
const asyncState = computed ( ( ) => loading . value ? hasActiveData . value ? "refreshing" : "initial-loading" : error . value ? hasActiveData . value ? "partial" : "error" : hasActiveData . value ? "ready" : "empty" ) ;
const overviewTotals = computed ( ( ) => overview . value . reduce ( ( totals , item ) => ( {
tasks : totals . tasks + item . taskCount ,
subtasks : totals . subtasks + item . subtaskCount ,
subsubtasks : totals . subsubtasks + item . subsubtaskCount ,
reports : totals . reports + item . reportCount
} ) , { tasks : 0 , subtasks : 0 , subsubtasks : 0 , reports : 0 } ) ) ;
onMounted ( loadGroups ) ;
watch ( groupId , ( value ) => { if ( value ) void loadTimeline ( value ) ; } ) ;
onMounted ( ( ) => void loadActiveView ( ) ) ;
watch ( groupId , ( ) => void loadActiveView ( ) ) ;
watch ( requestedTaskId , syncSelectedTaskFromRoute ) ;
watch ( requestedDetailMode , ( mode ) => { detailMode . value = mode ; } ) ;
async function loadGroups ( ) {
async function loadActiveView ( ) {
loading . value = true ; error . value = null ;
const response = await tasktreeAPI . groups ( ) ; loading . value = false ;
if ( ! response . ok || response . data ? . ok === false ) { error . value = response . data ? . error ? . message || response . error || "TaskTree taskgroups 加载失败" ; return ; }
groups . value = response . data ? . data ? . groups ? ? [ ] ;
const requested = String ( route . params . groupId || "" ) ;
const selected = groups . value . some ( ( group ) => group . id === requested ) ? requested : groups . value [ 0 ] ? . id ;
if ( selected && selected !== requested ) await router . replace ( ` /projects/tasktree/ ${ encodeURIComponent ( selected ) } ` ) ;
else if ( selected ) await loadTimeline ( selected ) ;
const overviewResponse = await tasktreeAPI . overview ( ) ;
if ( ! overviewResponse . ok || overviewResponse . data ? . ok === false ) {
loading . value = false ;
error . value = overviewResponse . data ? . error ? . message || overviewResponse . error || "TaskTree 全局视图加载失败" ;
return ;
}
overview . value = overviewResponse . data ? . data ? . groups ? ? [ ] ;
groups . value = overview . value . map ( ( item ) => item . group ) ;
if ( ! groupId . value ) {
timeline . value = null ;
loading . value = false ;
return ;
}
const timelineResponse = await tasktreeAPI . timeline ( groupId . value ) ;
loading . value = false ;
if ( ! timelineResponse . ok || timelineResponse . data ? . ok === false || ! timelineResponse . data ? . data ) {
error . value = timelineResponse . data ? . error ? . message || timelineResponse . error || "TaskTree timeline 加载失败" ;
return ;
}
timeline . value = timelineResponse . data . data ;
initializedChildScrollers . clear ( ) ;
syncSelectedTaskFromRoute ( ) ;
}
async function loadTimeline ( id = groupId . value ) {
if ( ! id ) return ; loading . value = true ; error . value = null ;
const response = await tasktreeAPI . timeline ( id ) ; loading . value = false ;
if ( ! response . ok || response . data ? . ok === false || ! response . data ? . data ) { error . value = response . data ? . error ? . message || response . error || "TaskTree timeline 加载失败" ; return ; }
timeline . value = response . data . data ;
function selectGroup ( event : Event ) {
const value = ( event . target as HTMLSelectElement ) . value ;
void router . push ( value ? ` /projects/tasktree/ ${ encodeURIComponent ( value ) } ` : "/projects/tasktree" ) ;
}
function selectGroup ( event : Event ) { void router . push ( ` /projects/tasktree/ ${ encodeURIComponent ( ( event . target as HTMLSelectElement ) . value ) } ` ) ; }
function openGroup ( id : string ) { void router . push ( ` /projects/tasktree/ ${ encodeURIComponent ( id ) } ` ) ; }
function toggleTask ( taskId : string ) {
const next = new Set ( collapsedTaskIds . value ) ;
if ( next . has ( taskId ) ) next . delete ( taskId ) ;
else next . add ( taskId ) ;
collapsedTaskIds . value = next ;
if ( ! next . has ( taskId ) ) {
initializedChildScrollers . delete ( ` labels: ${ taskId } ` ) ;
initializedChildScrollers . delete ( ` timeline: ${ taskId } ` ) ;
}
}
function visibleDescendants ( rootId : string ) {
const result : TaskTreeTask [ ] = [ ] ;
const append = ( parentId : string ) => {
if ( collapsedTaskIds . value . has ( parentId ) ) return ;
for ( const child of childrenByParent . value . get ( parentId ) ? ? [ ] ) {
result . push ( child ) ;
append ( child . id ) ;
}
} ;
append ( rootId ) ;
return result ;
}
function setChildScroller ( side : "labels" | "timeline" , groupId : string , element : Element | null ) {
const map = side === "labels" ? labelChildScrollers : timelineChildScrollers ;
if ( ! element ) {
map . delete ( groupId ) ;
const edges = new Map ( childScrollEdges . value ) ;
edges . delete ( ` ${ side } : ${ groupId } ` ) ;
childScrollEdges . value = edges ;
return ;
}
const scroller = element as HTMLElement ;
map . set ( groupId , scroller ) ;
const key = ` ${ side } : ${ groupId } ` ;
if ( initializedChildScrollers . has ( key ) ) {
updateChildScrollEdges ( side , groupId , scroller ) ;
return ;
}
initializedChildScrollers . add ( key ) ;
void nextTick ( ( ) => {
scroller . scrollTop = scroller . scrollHeight ;
updateChildScrollEdges ( side , groupId , scroller ) ;
} ) ;
}
function syncChildScroller ( groupId : string , source : "labels" | "timeline" ) {
const from = ( source === "labels" ? labelChildScrollers : timelineChildScrollers ) . get ( groupId ) ;
const to = ( source === "labels" ? timelineChildScrollers : labelChildScrollers ) . get ( groupId ) ;
if ( from && to && to . scrollTop !== from . scrollTop ) to . scrollTop = from . scrollTop ;
if ( from ) updateChildScrollEdges ( source , groupId , from ) ;
if ( to ) updateChildScrollEdges ( source === "labels" ? "timeline" : "labels" , groupId , to ) ;
}
function childScrollerClass ( side : "labels" | "timeline" , groupId : string , count : number ) {
const edge = childScrollEdges . value . get ( ` ${ side } : ${ groupId } ` ) ;
return {
scrollable : count > 8 ,
"has-hidden-above" : count > 8 && edge !== undefined && ! edge . atTop ,
"has-hidden-below" : count > 8 && edge !== undefined && ! edge . atBottom
} ;
}
function updateChildScrollEdges ( side : "labels" | "timeline" , groupId : string , scroller : HTMLElement ) {
const next = {
atTop : scroller . scrollTop <= 1 ,
atBottom : scroller . scrollTop >= scroller . scrollHeight - scroller . clientHeight - 1
} ;
const key = ` ${ side } : ${ groupId } ` ;
const current = childScrollEdges . value . get ( key ) ;
if ( current ? . atTop === next . atTop && current . atBottom === next . atBottom ) return ;
const edges = new Map ( childScrollEdges . value ) ;
edges . set ( key , next ) ;
childScrollEdges . value = edges ;
}
function handleChildWheel ( event : WheelEvent , groupId : string , side : "labels" | "timeline" ) {
if ( Math . abs ( event . deltaY ) <= Math . abs ( event . deltaX ) ) return ;
const scroller = ( side === "labels" ? labelChildScrollers : timelineChildScrollers ) . get ( groupId ) ;
if ( ! scroller ) return ;
const delta = event . deltaY * ( event . deltaMode === WheelEvent . DOM _DELTA _LINE ? 40 : event . deltaMode === WheelEvent . DOM _DELTA _PAGE ? scroller . clientHeight : 1 ) ;
const maxScrollTop = Math . max ( 0 , scroller . scrollHeight - scroller . clientHeight ) ;
const available = Math . max ( 0 , delta < 0 ? scroller . scrollTop : maxScrollTop - scroller . scrollTop ) ;
if ( Math . abs ( delta ) <= available + 1 ) return ;
event . preventDefault ( ) ;
scroller . scrollTop = delta < 0 ? 0 : maxScrollTop ;
syncChildScroller ( groupId , side ) ;
scrollOuterBy ( side , delta < 0 ? delta + available : delta - available ) ;
}
function scrollOuterBy ( side : "labels" | "timeline" , delta : number ) {
const source = side === "labels" ? taskLabelsElement . value : timelineBodyElement . value ;
const target = side === "labels" ? timelineBodyElement . value : taskLabelsElement . value ;
if ( ! source ) return ;
const nextTop = Math . max ( 0 , Math . min ( source . scrollHeight - source . clientHeight , source . scrollTop + delta ) ) ;
source . scrollTop = nextTop ;
if ( target ) target . scrollTop = Math . max ( 0 , Math . min ( target . scrollHeight - target . clientHeight , nextTop ) ) ;
}
function taskColumnLimit ( ) { return Math . max ( 170 , Math . min ( 760 , window . innerWidth - 280 ) ) ; }
function resizeTaskColumn ( width : number ) { taskColumnWidth . value = Math . max ( 170 , Math . min ( taskColumnLimit ( ) , Math . round ( width ) ) ) ; }
function startTaskColumnResize ( event : PointerEvent ) {
const handle = event . currentTarget as HTMLElement ;
const startX = event . clientX ;
const startWidth = taskColumnWidth . value ;
handle . setPointerCapture ( event . pointerId ) ;
const move = ( moveEvent : PointerEvent ) => resizeTaskColumn ( startWidth + moveEvent . clientX - startX ) ;
const stop = ( stopEvent : PointerEvent ) => {
handle . releasePointerCapture ( stopEvent . pointerId ) ;
handle . removeEventListener ( "pointermove" , move ) ;
handle . removeEventListener ( "pointerup" , stop ) ;
handle . removeEventListener ( "pointercancel" , stop ) ;
} ;
handle . addEventListener ( "pointermove" , move ) ;
handle . addEventListener ( "pointerup" , stop ) ;
handle . addEventListener ( "pointercancel" , stop ) ;
}
function resizeTaskColumnByKeyboard ( event : KeyboardEvent ) {
if ( event . key !== "ArrowLeft" && event . key !== "ArrowRight" ) return ;
event . preventDefault ( ) ;
resizeTaskColumn ( taskColumnWidth . value + ( event . key === "ArrowLeft" ? - 16 : 16 ) ) ;
}
async function changeTimelineScale ( direction : - 1 | 1 ) {
const body = timelineBodyElement . value ;
@@ -103,15 +243,15 @@ function syncTaskLabels() {
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 ( ) ) ;
function timelineRange ( items : Array < { startAt : string | null ; dueAt : string | null } > ) {
const values = items . flatMap ( ( item ) => [ item . startAt , item . 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 ;
const start = new Date ( startDay ( new Date ( min ) ) . getTime ( ) - 86400000 ) ;
return { start , dayCount : Math . min ( 120 , Math . max ( 7 , Math . ceil ( ( max - start . getTime ( ) ) / 86400000 ) + 2 ) ) } ;
}
function startDay ( date : Date ) { return new Date ( Date . UTC ( date . getUTCFullYear ( ) , date . getUTCMonth ( ) , date . getUTCDate ( ) ) ) ; }
function barStyle ( task : TaskTreeTask ) {
const start = new Date ( task . startAt || task . dueAt || range . value . start ) . getTime ( ) ; const end = new Date ( task . dueAt || task . startAt || range . value . start ) . getTime ( ) ; const unit = 100 / range . value . dayCount ;
function barStyle ( item : { startAt : string | null ; dueAt : string | null } ) {
const start = new Date ( item . startAt || item . dueAt || range . value . start ) . getTime ( ) ; const end = new Date ( item . dueAt || item . startAt || range . value . start ) . getTime ( ) ; const unit = 100 / range . value . dayCount ;
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 ) } % ` } ;
}
@@ -130,34 +270,52 @@ function tickLabel(date: Date) {
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 taskKindLabel ( kind : TaskTreeTask [ "kind" ] ) { return ( { task : "Task" , subtask : "Subtask" , subsubtask : "Subsubtask" } ) [ kind ] ; }
function plainTaskTitle ( task : TaskTreeTask | null ) {
return String ( task ? . title || "任务详情" ) . replace ( /\[([^\]]+)\]\([^)]+\)/gu , "$1" ) . replace ( /`([^`]+)`/gu , "$1" ) . trim ( ) ;
}
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 selectTaskFromTitle ( event : MouseEvent , task : TaskTreeTask ) {
if ( ( event . target as Element ) . closest ( "a " ) ) return ;
selectTask ( task ) ;
}
function selectTask ( task : TaskTreeTask | null ) {
if ( ! task ) return ;
selectedTask . value = task ;
if ( requestedTaskId . value !== task . id ) void router . replace ( { query : { ... route . query , task : task . id } } ) ;
}
function closeTask ( ) {
selectedTask . value = null ;
if ( ! requestedTaskId . value ) return ;
const query = { ... route . query } ;
delete query . task ;
delete query . detail ;
void router . replace ( { query } ) ;
}
function setDetailMode ( mode : "dialog" | "docked" ) {
detailMode . value = mode ;
const query = { ... route . query } ;
if ( mode === "docked" ) query . detail = "docked" ;
else delete query . detail ;
void router . replace ( { query } ) ;
}
function syncSelectedTaskFromRoute ( ) {
if ( ! requestedTaskId . value ) { selectedTask . value = null ; return ; }
selectedTask . value = timeline . value ? . tasks . find ( ( task ) => task . id === requestedTaskId . value ) ? ? null ;
}
function normalizeHeading ( value : string ) { return value . replace ( /\s+/gu , "" ) . toLocaleLowerCase ( ) ; }
< / script >
< template >
< main class = "tasktree-page" data -testid = " tasktree -page " >
< 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 >
< template # actions > < label class = "tasktree-group-select" > < span class = "sr-only" > Taskgroup < / span > < select :value = "groupId" @change ="selectGroup" > < option value = "" > 全部 TaskGroups < / option > < 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="loadActiveView "><span aria-hidden="true" > ↻ < / span > < / button > < / template >
< / PageCommandBar >
< AsyncBoundary :state = "asyncState" title = "TaskTree 暂无任务" : message = "error || '通过 CLI 创建 taskgroup 和任务后会显示在时间轴中。'" @retry ="loadGroups " >
< section v-if = "timeline" class="tasktree-workspace" aria-label="TaskTree 甘特图 " >
< AsyncBoundary :state = "asyncState" title = "TaskTree 暂无任务" : message = "error || '通过 CLI 创建 taskgroup 和任务后会显示在时间轴中。'" @retry ="loadActiveView " >
< div v-if = "isGlobalView || timeline" class="tasktree-view-layout" :class="{ docked: detailMode === 'docked' && selectedTask } " >
< section class = "tasktree-workspace" aria -label = " TaskTree 甘特图 " >
< header class = "tasktree-context" >
< div > < strong > { { timeline . group . name } } < / strong > < p > { { timeline . group . description || '未填写 taskgroup 说明' } } < / p > < / div >
< div v-if = "isGlobalView" > < strong > 全局 TaskGroups < / strong > < p > 跨项目时间范围与工作量概览 < / p > < / div >
< div v-else-if = "timeline" > < 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 = " 时间轴缩放 " >
@@ -167,122 +325,232 @@ function normalizeHeading(value: string) { return value.replace(/\s+/gu, "").toL
< / 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 class = "tasktree-gantt" : class = "{ 'global-view': isGlobalView }" : style = "{ '--task-column-width': `${taskColumnWidth}px`, '--tick-count': timelineTicks.length, '--tick-width': `${timelineScale.tickWidth}px`, '--timeline-width': `${timelineWidth}px` }">
< div class = "tasktree-corner" > { { isGlobalView ? 'TaskGroup' : '任务' } } < / div >
< div
class = "tasktree-column-resizer"
role = "separator"
aria -label = " 调整任务列表宽度 "
aria -orientation = " vertical "
aria -valuemin = " 170 "
:aria-valuemax = "taskColumnLimit()"
:aria-valuenow = "taskColumnWidth"
tabindex = "0"
@pointerdown ="startTaskColumnResize"
@keydown ="resizeTaskColumnByKeyboard"
/ >
< 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-da ted ="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 v-if = "isGlobalView" class=" tasktree-labels-canvas tasktree-overview-labels " >
< div v-for = "item in overview" :key="item.group.id" class="tasktree-label tasktree-group-label" role="link" tabindex="0" @click="openGroup(item.group.id)" @keydown.en ter ="openGroup(item.group.id )" >
< div class = "tasktree-group-title" >
< MessageMarkdown class = "tasktree-title-markdown" :source = "item.group.name" / >
< ChevronRight :size = "17" aria -hidden = " true " / >
< / div >
< span > { { item . taskCount } } tasks · { { item . subtaskCount + item . subsubtaskCount } } children · { { item . reportCount } } reports < / span >
< / div >
< / div >
< div v-else class = "tasktree-labels-canvas tasktree-detail-labels" >
< section v-for = "root in rootTasks" :key="root.id" class="tasktree-task-group" >
< div class = "tasktree-label" : data -dated = " Boolean ( root.startAt | | root.dueAt ) " >
< button
v-if = "childrenByParent.has(root.id)"
class = "tasktree-disclosure"
type = "button"
: title = "collapsedTaskIds.has(root.id) ? '展开子任务' : '收起子任务'"
: aria -label = " collapsedTaskIds.has ( root.id ) ? ` 展开 $ { root.title } 的子任务 ` : ` 收起 $ { root.title } 的子任务 ` "
:aria-expanded = "!collapsedTaskIds.has(root.id)"
@click ="toggleTask(root.id)"
>
< ChevronRight v-if = "collapsedTaskIds.has(root.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 " / >
< div class = "tasktree-title" role = "button" tabindex = "0" @click ="selectTaskFromTitle($event, root)" @keydown.enter ="selectTask(root)" >
< span class = "tasktree-status" :data-status = "root.status" / >
< MessageMarkdown class = "tasktree-title-markdown" :source = "root.title" / >
< / div >
< / div >
< div
v-if = "visibleDescendants(root.id).length"
: ref = "(element) => setChildScroller('labels', root.id, element as Element | null)"
class = "tasktree-child-viewport"
: class = "childScrollerClass('labels', root.id, visibleDescendants(root.id).length)"
@scroll ="syncChildScroller(root.id, 'labels')"
@wheel ="handleChildWheel($event, root.id, 'labels')"
>
< div v-for = "task in visibleDescendants(root.id)" :key="task.id" class="tasktree-label" :class="task.kind" :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 " / >
< div class = "tasktree-title" role = "button" tabindex = "0" @click ="selectTaskFromTitle($event, task)" @keydown.enter ="selectTask(task)" >
< span class = "tasktree-status" :data-status = "task.status" / >
< MessageMarkdown class = "tasktree-title-markdown" :source = "task.title" / >
< / div >
< / div >
< / div >
< / section >
< / 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%` }" >
< div v-if = "isGlobalView" class=" tasktree-timeline-canvas tasktree-overview-timeline " >
< div v-for = "item in overview" :key="item.group.id" class="tasktree-track tasktree-group -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)"
v-if = "item .startAt || item .dueAt"
class = "tasktree-group- bar"
:style = "barStyle(item) "
type = "button"
:aria-label = "`${task.title}, ${statusLabel(task.status)}`"
@click ="selectedTask = task "
: aria -label = " ` 打开 $ { item.group.name } ` "
@click ="openGroup(item.group.id) "
>
< span > { { statusLabel ( task . status ) } } < / span >
< span > { { item . taskCount + item . subtaskCount + item . subsubtaskCount } } 项 < / 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 v-else-if = "timeline" class="tasktree-timeline-canvas tasktree-detail-timeline" >
< section v-for = "root in rootTasks" :key="root.id" class="tasktree-task-group" >
< div class = "tasktree-track" : style = "{ backgroundSize: `${100 / timelineTicks.length}% 100%` }" >
< button v-if = "root.startAt || root.dueAt" class="tasktree-bar" :class="`status-${root.status}`" :style="barStyle(root)" type="button" :aria-label="`${root.title}, ${statusLabel(root.status)}`" @click="selectTask(root)" > < span > { { statusLabel ( root . status ) } } < / span > < / button >
< span v-for = "milestone in timeline.milestones.filter((item) => item.taskId === root.id)" :key="milestone.id" class="tasktree-milestone" role="img" :aria-label="`里程碑:${milestone.title}`" :title="milestone.title" :style="milestoneStyle(milestone.occursAt)" / >
< / div >
< div
v-if = "visibleDescendants(root.id).length"
: ref = "(element) => setChildScroller('timeline', root.id, element as Element | null)"
class = "tasktree-child-viewport"
: class = "childScrollerClass('timeline', root.id, visibleDescendants(root.id).length)"
@scroll ="syncChildScroller(root.id, 'timeline')"
@wheel ="handleChildWheel($event, root.id, 'timeline')"
>
< div v-for = "task in visibleDescendants(root.id)" :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}`, task.kind]" :style="barStyle(task)" type="button" :aria-label="`${task.title}, ${statusLabel(task.status)}`" @click="selectTask(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 >
< / section >
< / 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 > < strong > { { overview . length } } < / strong > Taskgroups < / span >
< span > < strong > { { isGlobalView ? overviewTotals . tasks : timeline ? . tasks . filter ( ( task ) => task . kind === 'task' ) . length } } < / strong > Tasks < / span >
< span > < strong > { { isGlobalView ? overviewTotals . subtasks : timeline ? . tasks . filter ( ( task ) => task . kind === 'subtask' ) . length } } < / strong > Subtasks < / span >
< span > < strong > { { isGlobalView ? overviewTotals . subsubtasks : timeline ? . tasks . filter ( ( task ) => task . kind === 'subsubtask' ) . length } } < / strong > Subsubtasks < / span >
< span > < strong > { { isGlobalView ? overviewTotals . reports : timeline ? . reports . length } } < / strong > Reports < / span >
< span v-if = "!isGlobalView" > < strong > { { timeline ? . milestones . length } } < / strong > Milestones < / span >
< span class = "tasktree-statusbar-authority" > PostgreSQL < / span >
< / footer >
< / section >
< / section >
< aside v-if = "detailMode === 'docked' && selectedTask && timeline" class="tasktree-docked-detail" aria-label="任务详情" >
< TaskTreeDetailPanel :task = "selectedTask" :tasks = "timeline.tasks" :reports = "timeline.reports" mode = "docked" @select ="selectTask" @close ="closeTask" @mode ="setDetailMode" / >
< / aside >
< / div >
< / AsyncBoundary >
< BaseDialog :open = "Boolean(selectedTask)" :title = "dis play TaskTitle(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 : open = "Boolean(selectedTask) && detailMode === 'dialog' " :title = "plain TaskTitle(selectedTask)" : description = "selectedTask ? `${taskKindLabel( selectedTask.kind) } · ${statusLabel(selectedTask.status)}` : ''" surface -class = " tasktree -detail -dialog " wide @close ="closeTask " >
< TaskTreeDetailPanel v-if = "selectedTask && timeline" :task="selectedTask" :tasks="timeline.tasks" :reports="timeline.reports" mode="dialog" @select="selectTask" @close="closeTask" @mode="setDetailMode" / >
< / BaseDialog >
< / main >
< / template >
< style scoped >
. tasktree - page { display : grid ; gap : 12 px ; min - width : 0 ; }
. tasktree - view - layout { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) ; min - width : 0 ; gap : 10 px ; }
. tasktree - view - layout . docked { grid - template - columns : minmax ( 0 , 1 fr ) clamp ( 520 px , 34 vw , 680 px ) ; }
. 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 - docked - detail { height : calc ( 100 dvh - 190 px ) ; min - width : 0 ; min - height : 360 px ; overflow : hidden ; border : 1 px solid var ( -- border - color ) ; background : var ( -- surface - primary ) ; }
. 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 - gantt { display : grid ; grid - template - areas : "corner resize header" "labels resize timeline" ; grid - template - columns : var ( -- task - column - width ) 7 px minmax ( 0 , 1 fr ) ; grid - template - rows : 34 px minmax ( 0 , 1 fr ) ; min - height : 0 ; height : 100 % ; overflow : hidden ; }
. tasktree - corner { grid - area : corner ; }
. tasktree - timeline - header { grid - area : header ; }
. tasktree - labels { grid - area : labels ; }
. tasktree - timeline - body { grid - area : timeline ; }
. tasktree - column - resizer { position : relative ; z - index : 3 ; grid - area : resize ; width : 7 px ; min - height : 0 ; border : 0 ; border - inline : 1 px solid var ( -- border - color ) ; background : var ( -- surface - secondary ) ; cursor : col - resize ; touch - action : none ; }
. tasktree - column - resizer : : after { position : absolute ; inset : 0 2 px ; background : transparent ; content : "" ; transition : background - color 120 ms ease ; }
. tasktree - column - resizer : hover : : after ,
. tasktree - column - resizer : focus - visible : : after ,
. tasktree - column - resizer : active : : after { background : # 2376 c9 ; }
. 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 - timeline - header : : - webkit - scrollbar { display : none ; }
. tasktree - days { width : max ( 100 % , var ( -- timeline - width ) ) ; height : 100 % ; display : grid ; grid - template - columns : repeat ( var ( -- tick - count ) , minmax ( var ( -- tick - width ) , 1 fr ) ) ; }
. 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 - labels { min - width : 0 ; min - height : 0 ; max - height : 100 % ; overflow - y : auto ; overflow - x : hidden ; border - right : 1 px solid var ( -- border - color ) ; scrollbar - width : thin ; overscroll - behavior - block : contain ; }
. tasktree - labels - canvas { min - height : 100 % ; align - content : start ; }
. tasktree - overview - labels { display : grid ; grid - auto - rows : 68 px ; }
. tasktree - task - group { min - width : 0 ; }
. 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 - label . subsubtask { padding - left : 54 px ; color : var ( -- text - secondary ) ; }
. tasktree - group - label { height : 68 px ; padding : 9 px 14 px ; display : grid ; align - content : center ; gap : 5 px ; cursor : pointer ; }
. tasktree - group - label > span { color : var ( -- text - secondary ) ; font - family : var ( -- console - font - mono ) ; font - size : 10 px ; }
. tasktree - group - title { display : flex ; min - width : 0 ; align - items : center ; gap : 8 px ; color : var ( -- text - primary ) ; font - size : 15 px ; font - weight : 700 ; }
. tasktree - group - title svg { flex : 0 0 auto ; 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 - title { display : flex ; flex : 1 1 auto ; align - items : center ; gap : 8 px ; min - width : 0 ; height : 100 % ; padding : 0 4 px ; color : inherit ; text - align : left ; cursor : pointer ; }
. tasktree - title : focus - visible { outline : 2 px solid # 2376 c9 ; outline - offset : - 2 px ; }
. tasktree - title - markdown { min - width : 0 ; overflow : hidden ; }
. tasktree - title - markdown : deep ( p ) { min - width : 0 ; margin : 0 ; overflow : hidden ; font : inherit ; text - overflow : ellipsis ; white - space : nowrap ; }
. tasktree - title - markdown : deep ( a ) { color : # 1766 ad ; text - decoration : underline ; text - underline - offset : 2 px ; }
. tasktree - title - markdown : deep ( code ) { padding : 1 px 3 px ; border : 1 px solid var ( -- border - subtle ) ; background : var ( -- surface - secondary ) ; color : inherit ; font - size : 0.9 em ; }
. 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 - timeline - body { min - width : 0 ; min - height : 0 ; max - height : 100 % ; overflow : auto ; scrollbar - gutter : stable ; overscroll - behavior : contain ; touch - action : pan - x pan - y ; }
. tasktree - timeline - canvas { width : max ( 100 % , var ( -- timeline - width ) ) ; min - height : 100 % ; align - content : start ; }
. tasktree - overview - timeline { display : grid ; grid - auto - rows : 68 px ; }
. 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 - group - track { height : 68 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 . subsubtask { top : 16 px ; height : 14 px ; background : # 8294 a5 ; }
. tasktree - group - bar { position : absolute ; top : 16 px ; height : 36 px ; min - width : 28 px ; overflow : hidden ; border : 1 px solid # 135 b94 ; border - radius : 4 px ; background : # 176 ba8 ; color : white ; box - shadow : inset 0 1 px 0 rgb ( 255 255 255 / 22 % ) , 0 2 px 5 px rgb ( 17 67 101 / 20 % ) ; cursor : pointer ; }
. tasktree - group - bar : hover { background : # 125 d95 ; }
. tasktree - group - bar span { display : block ; padding : 0 10 px ; overflow : hidden ; font - size : 11 px ; font - weight : 700 ; line - height : 34 px ; text - overflow : ellipsis ; white - space : nowrap ; }
. 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 - task - group : has ( > . tasktree - child - viewport . scrollable ) > . tasktree - label ,
. tasktree - task - group : has ( > . tasktree - child - viewport . scrollable ) > . tasktree - track { position : relative ; z - index : 3 ; border - bottom - color : # 91 a4b3 ; }
. tasktree - task - group : has ( > . tasktree - child - viewport . has - hidden - above ) > . tasktree - label ,
. tasktree - task - group : has ( > . tasktree - child - viewport . has - hidden - above ) > . tasktree - track { box - shadow : 0 9 px 15 px - 8 px rgb ( 24 45 61 / 62 % ) ; }
. tasktree - child - viewport { width : 100 % ; max - height : calc ( 46 px * 8 ) ; overflow : hidden ; background : color - mix ( in srgb , var ( -- surface - secondary ) 55 % , var ( -- surface - primary ) ) ; overscroll - behavior - y : none ; scrollbar - color : # 7891 a5 transparent ; scrollbar - width : thin ; }
. tasktree - child - viewport . scrollable { overflow - y : auto ; box - shadow : inset 0 1 px 0 rgb ( 18 53 76 / 12 % ) , inset 0 - 1 px 0 rgb ( 18 53 76 / 10 % ) ; }
. tasktree - child - viewport . scrollable : : before ,
. tasktree - child - viewport . scrollable : : after { position : sticky ; z - index : 5 ; display : block ; height : 16 px ; margin - bottom : - 16 px ; opacity : 0 ; pointer - events : none ; content : "" ; transition : opacity 100 ms ease ; }
. tasktree - child - viewport . scrollable : : before { top : 0 ; background : linear - gradient ( to bottom , rgb ( 29 50 65 / 25 % ) , rgb ( 29 50 65 / 8 % ) 45 % , transparent ) ; box - shadow : inset 0 7 px 9 px - 8 px rgb ( 12 31 44 / 75 % ) ; }
. tasktree - child - viewport . scrollable : : after { bottom : 0 ; margin - top : - 16 px ; margin - bottom : 0 ; background : linear - gradient ( to top , rgb ( 29 50 65 / 20 % ) , transparent ) ; box - shadow : inset 0 - 7 px 9 px - 8 px rgb ( 12 31 44 / 65 % ) ; }
. tasktree - child - viewport . has - hidden - above : : before ,
. tasktree - child - viewport . has - hidden - below : : after { opacity : 1 ; }
. tasktree - child - viewport : : - webkit - scrollbar { width : 9 px ; }
. tasktree - child - viewport : : - webkit - scrollbar - track { background : transparent ; }
. tasktree - child - viewport : : - webkit - scrollbar - thumb { border : 2 px solid transparent ; border - radius : 999 px ; background : # 7891 a5 ; background - clip : padding - box ; }
. tasktree - child - viewport : : - webkit - scrollbar - thumb : hover { background : # 526 f86 ; background - clip : padding - box ; }
. 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 : "|" ; }
@@ -291,29 +559,20 @@ function normalizeHeading(value: string) { return value.replace(/\s+/gu, "").toL
. 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 ; }
: global ( . console - overlay - surface . tasktree - detail - dialog ) { height : 90 dvh ; min - height : 90 dvh ; max - height : 90 dvh ; }
: global ( . console - overlay - surface . tasktree - detail - dialog . console - overlay - body ) { min - height : 0 ; overflow : hidden ; padding : 0 ; }
@ media ( max - width : 1100 px ) {
. tasktree - view - layout . docked { grid - template - columns : minmax ( 0 , 1 fr ) minmax ( 320 px , 42 vw ) ; }
}
@ media ( max - width : 720 px ) {
. tasktree - view - layout . docked { grid - template - columns : minmax ( 0 , 1 fr ) ; }
. tasktree - workspace { height : calc ( 100 dvh - 225 px ) ; min - height : 400 px ; }
. tasktree - docked - detail { height : 80 dvh ; min - height : 480 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 >