@@ -2,9 +2,9 @@
<!-- Responsibility : MDTODO project page consuming public ProjectTask DTOs and Workbench launch DTOs without Workbench or Markdown internals . -- >
< script setup lang = "ts" >
import { computed , onMounted , ref , watch } from "vue" ;
import { computed , onMounted , reactive , ref , watch } from "vue" ;
import { useRouter } from "vue-router" ;
import { projectManagementAPI , workbenchAPI , type MdtodoFileRecord , type MdtodoTaskRecord , type ProjectNavigationResponse , type ProjectSource , type ProjectWorkbenchLinkRecord } from "@/api" ;
import { projectManagementAPI , workbenchAPI , type MdtodoFileRecord , type MdtodoTaskRecord , type ProjectNavigationResponse , type ProjectSource , type ProjectSourceInput , type ProjectWorkbenchLinkRecord } from "@/api" ;
import EmptyState from "@/components/common/EmptyState.vue" ;
import LoadingState from "@/components/common/LoadingState.vue" ;
import PageHeader from "@/components/common/PageHeader.vue" ;
@@ -15,7 +15,14 @@ const loading = ref(false);
const taskLoading = ref ( false ) ;
const linkLoading = ref ( false ) ;
const launchLoading = ref ( false ) ;
const sourceSaving = ref ( false ) ;
const sourceProbeLoading = ref ( false ) ;
const sourceReindexLoading = ref ( false ) ;
const showInfo = ref ( false ) ;
const showSourceConfig = ref ( false ) ;
const launchError = ref < string | null > ( null ) ;
const sourceMessage = ref < string | null > ( null ) ;
const sourceError = ref < string | null > ( null ) ;
const error = ref < string | null > ( null ) ;
const apiError = ref < ApiError | null > ( null ) ;
const diagnostic = ref < ErrorDiagnostic | null > ( null ) ;
@@ -27,18 +34,65 @@ const links = ref<ProjectWorkbenchLinkRecord[]>([]);
const selectedSourceId = ref < string | null > ( null ) ;
const selectedFileRef = ref < string | null > ( null ) ;
const selectedTaskRef = ref < string | null > ( null ) ;
const taskSearch = ref ( "" ) ;
const taskStatusFilter = ref ( "all" ) ;
const collapsedTaskRefs = ref < Set < string > > ( new Set ( ) ) ;
const sourceForm = reactive < ProjectSourceInput > ( {
sourceId : "d601-f103-v2-mdtodo" ,
sourceKind : "hwpod-workspace" ,
displayName : "D601 F103 MDTODO" ,
projectId : "project_hwlab_v03" ,
hwpodId : "d601-f103-v2" ,
nodeId : "node-d601-f103-v2" ,
workspaceRootRef : "" ,
mdtodoRootRef : "docs/MDTODO/" ,
maxFiles : 120
} ) ;
const selectedSource = computed ( ( ) => sources . value . find ( ( source ) => source . sourceId === selectedSourceId . value ) ? ? null ) ;
const selectedFile = computed ( ( ) => files . value . find ( ( file ) => file . fileRef === selectedFileRef . value ) ? ? null ) ;
const selectedTask = computed ( ( ) => tasks . value . find ( ( task ) => task . taskRef === selectedTaskRef . value ) ? ? null ) ;
const filteredTasks = computed ( ( ) => selectedFileRef . value ? tasks . value . filter ( ( task ) => task . fileRef === selectedFileRef . value ) : tasks . value ) ;
const taskStatusCounts = computed ( ( ) => filteredTasks . value . reduce ( ( acc , task ) => {
const fileScopedTasks = computed ( ( ) => selectedFileRef . value ? tasks . value . filter ( ( task ) => task . fileRef === selectedFileRef . value ) : tasks . value ) ;
const filteredTasks = computed ( ( ) => {
const query = taskSearch . value . trim ( ) . toLowerCase ( ) ;
return fileScopedTasks . value . filter ( ( task ) => {
const statusOk = taskStatusFilter . value === "all" || ( task . status || "unknown" ) === taskStatusFilter . value ;
if ( ! statusOk ) return false ;
if ( ! query ) return true ;
return [ task . taskId , task . title , task . taskRef ] . some ( ( value ) => String ( value ? ? "" ) . toLowerCase ( ) . includes ( query ) ) ;
} ) ;
} ) ;
const taskStatusCounts = computed ( ( ) => fileScopedTasks . value . reduce ( ( acc , task ) => {
const key = task . status || "unknown" ;
acc [ key ] = ( acc [ key ] ? ? 0 ) + 1 ;
return acc ;
} , { } as Record < string , number > ) ) ;
const taskByRef = computed ( ( ) => new Map ( filteredTasks . value . map ( ( task ) => [ task . taskRef , task ] ) ) ) ;
const childrenByParent = computed ( ( ) => {
const map = new Map < string , MdtodoTaskRecord [ ] > ( ) ;
for ( const task of filteredTasks . value ) {
const parent = task . parentTaskRef && taskByRef . value . has ( task . parentTaskRef ) ? task . parentTaskRef : "root" ;
const list = map . get ( parent ) ? ? [ ] ;
list . push ( task ) ;
map . set ( parent , list ) ;
}
for ( const list of map . values ( ) ) list . sort ( ( a , b ) => ( a . ordinal ? ? 0 ) - ( b . ordinal ? ? 0 ) || String ( a . taskId ? ? "" ) . localeCompare ( String ( b . taskId ? ? "" ) ) ) ;
return map ;
} ) ;
const visibleTaskRows = computed ( ( ) => {
const rows : MdtodoTaskRecord [ ] = [ ] ;
const visit = ( task : MdtodoTaskRecord ) => {
rows . push ( task ) ;
if ( collapsedTaskRefs . value . has ( task . taskRef ) ) return ;
for ( const child of childrenByParent . value . get ( task . taskRef ) ? ? [ ] ) visit ( child ) ;
} ;
for ( const task of childrenByParent . value . get ( "root" ) ? ? [ ] ) visit ( task ) ;
return rows ;
} ) ;
const workbenchLaunchEnabled = computed ( ( ) => navigation . value ? . navigation ? . capabilities ? . workbenchLaunch === true ) ;
const launchButtonDisabled = computed ( ( ) => ! selectedTask . value || ! workbenchLaunchEnabled . value || launchLoading . value ) ;
const selectedSourceCanReindex = computed ( ( ) => Boolean ( selectedSourceId . value && ! taskLoading . value && ! sourceReindexLoading . value ) ) ;
onMounted ( ( ) => void loadPage ( ) ) ;
@@ -62,7 +116,7 @@ async function loadPage(): Promise<void> {
if ( ! sourceResponse . ok ) throw sourceResponse ;
navigation . value = navigationResponse . data ;
sources . value = sourceResponse . data ? . sources ? ? [ ] ;
selectedSourceId . value = sources . value [ 0 ] ? . sourceId ? ? null ;
selectedSourceId . value = selectedSourceId . value && sources . value . some ( ( source ) => source . sourceId === selectedSourceId . value ) ? selectedSourceId . value : sources . value [ 0 ] ? . sourceId ? ? null ;
if ( selectedSourceId . value ) await loadFilesAndTasks ( selectedSourceId . value ) ;
else await loadLinks ( null ) ;
} catch ( err ) {
@@ -83,9 +137,10 @@ async function loadFilesAndTasks(sourceId: string): Promise<void> {
if ( ! taskResponse . ok ) throw taskResponse ;
files . value = fileResponse . data ? . files ? ? [ ] ;
tasks . value = taskResponse . data ? . tasks ? ? [ ] ;
selectedFileRef . value = files . value [ 0 ] ? . fileRef ? ? null ;
selectedFileRef . value = selectedFileRef . value && files . value . some ( ( file ) => file . fileRef === selectedFileRef . value ) ? selectedFileRef . value : files . value [ 0 ] ? . fileRef ? ? null ;
const firstVisibleTask = selectedFileRef . value ? tasks . value . find ( ( task ) => task . fileRef === selectedFileRef . value ) : tasks . value [ 0 ] ;
selectedTaskRef . value = firstVisibleTask ? . taskRef ? ? null ;
collapsedTaskRefs . value = new Set ( ) ;
if ( ! selectedTaskRef . value ) await loadLinks ( null ) ;
} catch ( err ) {
setError ( err ) ;
@@ -107,8 +162,16 @@ async function loadLinks(taskRef: string | null): Promise<void> {
}
}
function onSourceSelect ( event : Event ) : void {
selectedSourceId . value = ( event . target as HTMLSelectElement ) . value || null ;
}
function onFileSelect ( event : Event ) : void {
selectFile ( ( event . target as HTMLSelectElement ) . value ) ;
}
function selectFile ( fileRef : string ) : void {
selectedFileRef . value = fileRef ;
selectedFileRef . value = fileRef || null ;
const nextTask = tasks . value . find ( ( task ) => task . fileRef === fileRef ) ? ? null ;
selectedTaskRef . value = nextTask ? . taskRef ? ? null ;
}
@@ -117,6 +180,89 @@ function selectTask(taskRef: string): void {
selectedTaskRef . value = taskRef ;
}
function toggleTask ( task : MdtodoTaskRecord ) : void {
const next = new Set ( collapsedTaskRefs . value ) ;
if ( next . has ( task . taskRef ) ) next . delete ( task . taskRef ) ;
else next . add ( task . taskRef ) ;
collapsedTaskRefs . value = next ;
}
function hasChildren ( task : MdtodoTaskRecord ) : boolean {
return ( childrenByParent . value . get ( task . taskRef ) ? ? [ ] ) . length > 0 ;
}
function openSourceConfig ( ) : void {
const source = selectedSource . value ;
sourceMessage . value = null ;
sourceError . value = null ;
Object . assign ( sourceForm , {
sourceId : source ? . sourceKind === "hwpod-workspace" ? source . sourceId : "d601-f103-v2-mdtodo" ,
sourceKind : "hwpod-workspace" ,
displayName : source ? . sourceKind === "hwpod-workspace" ? source . displayName : "D601 F103 MDTODO" ,
projectId : source ? . projectId || "project_hwlab_v03" ,
hwpodId : source ? . hwpodId || "d601-f103-v2" ,
nodeId : source ? . nodeId || "node-d601-f103-v2" ,
workspaceRootRef : source ? . workspaceRootRef || "" ,
mdtodoRootRef : source ? . mdtodoRootRef || "docs/MDTODO/" ,
maxFiles : source ? . maxFiles || 120
} ) ;
showSourceConfig . value = true ;
}
async function saveSourceConfig ( ) : Promise < void > {
sourceSaving . value = true ;
sourceMessage . value = null ;
sourceError . value = null ;
try {
const sourceId = String ( sourceForm . sourceId || "" ) . trim ( ) ;
const exists = Boolean ( sourceId && sources . value . some ( ( source ) => source . sourceId === sourceId ) ) ;
const response = exists ? await projectManagementAPI . updateSource ( sourceId , sourceForm ) : await projectManagementAPI . createSource ( sourceForm ) ;
if ( ! response . ok ) throw response ;
selectedSourceId . value = response . data ? . source ? . sourceId ? ? sourceId ;
sourceMessage . value = "Source 已保存" ;
await loadPage ( ) ;
} catch ( err ) {
sourceError . value = projectApiError ( err ) . error ;
} finally {
sourceSaving . value = false ;
}
}
async function probeSelectedSource ( ) : Promise < void > {
const sourceId = selectedSourceId . value || sourceForm . sourceId ;
if ( ! sourceId ) return ;
sourceProbeLoading . value = true ;
sourceMessage . value = null ;
sourceError . value = null ;
try {
const response = await projectManagementAPI . probeSource ( sourceId ) ;
if ( ! response . ok ) throw response ;
sourceMessage . value = ` Probe ${ response . data ? . probe ? . status || "ok" } ` ;
} catch ( err ) {
sourceError . value = projectApiError ( err ) . error ;
} finally {
sourceProbeLoading . value = false ;
}
}
async function reindexSelectedSource ( ) : Promise < void > {
const sourceId = selectedSourceId . value ;
if ( ! sourceId ) return ;
sourceReindexLoading . value = true ;
sourceMessage . value = null ;
sourceError . value = null ;
try {
const response = await projectManagementAPI . reindexSource ( sourceId ) ;
if ( ! response . ok ) throw response ;
sourceMessage . value = ` Reindex ${ response . data ? . projection ? . documentCount ? ? 0 } files / ${ response . data ? . projection ? . taskCount ? ? 0 } tasks ` ;
await loadFilesAndTasks ( sourceId ) ;
} catch ( err ) {
sourceError . value = projectApiError ( err ) . error ;
} finally {
sourceReindexLoading . value = false ;
}
}
async function launchSelectedTask ( ) : Promise < void > {
const task = selectedTask . value ;
if ( ! task || ! task . taskRef || ! task . projectId || launchButtonDisabled . value ) return ;
@@ -166,6 +312,7 @@ function projectApiError(err: unknown): { error: string; apiError: ApiError | nu
function statusLabel ( value ? : string | null ) : string {
if ( value === "done" ) return "完成" ;
if ( value === "blocked" ) return "阻塞" ;
if ( value === "in_progress" ) return "进行中" ;
if ( value === "open" ) return "待办" ;
return value || "未知" ;
}
@@ -177,7 +324,7 @@ function sourceStatusTone(value?: string | null): string {
}
function taskIndent ( task : MdtodoTaskRecord ) : Record < string , string > {
return { paddingInlineStart : ` ${ 10 + Math . max ( 0 , task . depth ? ? 0 ) * 16 } px ` } ;
return { paddingInlineStart : ` ${ 12 + Math . max ( 0 , task . depth ? ? 0 ) * 22 } px ` } ;
}
function shortRef ( value ? : string | null ) : string {
@@ -195,76 +342,83 @@ function displayDate(value?: string | null): string {
< template >
< section class = "route-stack mdtodo-page" data -testid = " project -management -mdtodo " >
< PageHeader eyebrow = "Project Management" title = "MDTODO" description = "按 source、文件和 任务查看当前项目上下文、任务状态和关联工作台摘要 。" / >
< PageHeader eyebrow = "Project Management" title = "MDTODO" description = "MDTODO source、文件、 任务树和 Workbench link 的项目工作区 。" / >
< LoadingState v-if = "loading && !sources.length" label="加载 MDTODO source" / >
< section v-else-if = "error" class="data-panel mdtodo-error" data-testid="project-management-error" >
< EmptyState title = "MDTODO 加载失败" :description = "error" :api-error = "apiError" :diagnostic = "diagnostic" action -label = " 重试 " @action ="loadPage" / >
< / section >
< template v-else >
< section class = "mdtodo-summary-grid " data -testid = " mdtodo -summary " aria -label = " MDTODO 摘要 " >
< article class = "metric-card" > < span > Source < / span > < strong > { { sources . length } } < / strong > < small > { { selectedSource ? . displayName || '未选择' } } < / small > < / article >
< article class = "metric-card" > < span > Files < / span > < strong > { { files . length } } < / strong > < small > { { selectedFile ? . relativePath || 'no file' } } < / small > < / article >
< article class = "metric-card" > < span > Tasks < / span > < strong > { { filteredTasks . length } } < / strong > < small > open { { taskStatusCounts . open || 0 } } / done { { taskStatusCounts . done || 0 } } < / small > < / article >
< article class = "metric-card" > < span > Links < / span > < strong > { { links . length } } < / strong > < small > { { workbenchLaunchEnabled ? 'launch enabled' : 'launch unavailable' } } < / small > < / article >
< section class = "mdtodo-toolbar " data -testid = " mdtodo -toolbar " aria -label = " MDTODO controls " >
< label class = "toolbar-field" data -testid = " mdtodo -source -list " >
< span > Source < / span >
< select data -testid = " mdtodo -source -select " : value = "selectedSourceId || ''" @change ="onSourceSelect" >
< option v-for = "source in sources" :key="source.sourceId" :value="source.sourceId" > {{ source.displayName | | source.sourceId }} < / option >
< / select >
< / label >
< label class = "toolbar-field" data -testid = " mdtodo -file -control " >
< span > File < / span >
< select data -testid = " mdtodo -file -select " : value = "selectedFileRef || ''" : disabled = "taskLoading || !files.length" @change ="onFileSelect" >
< option v-for = "file in files" :key="file.fileRef" :value="file.fileRef" > {{ file.title | | file.relativePath | | file.fileRef }} < / option >
< / select >
< / label >
< div class = "toolbar-actions" >
< button class = "icon-button" type = "button" data -testid = " mdtodo -source -config -open " aria -label = " 配置 Source " @click ="openSourceConfig" > ⚙ < / button >
< button class = "icon-button" type = "button" data -testid = " mdtodo -source -reindex " aria -label = " 重建索引 " :disabled = "!selectedSourceCanReindex" @click ="reindexSelectedSource" > ↻ < / button >
< button class = "icon-button" type = "button" data -testid = " mdtodo -info -open " aria -label = " 查看摘要 " @click ="showInfo = true" > ? < / button >
< / div >
< / section >
< p v-if = "sourceMessage" class="source-message" data-testid="mdtodo-source-message" > {{ sourceMessage }} < / p >
< p v-if = "sourceError" class="source-error" data-testid="mdtodo-source-error" > {{ sourceError }} < / p >
< section class = "mdtodo-workspace" >
< aside class = "data-panel mdtodo-source-panel" data -testid = " mdtodo -source -list " >
< header class = "mdtodo-panel-header" > < h2 > Source < / h2 > < button class = "btn btn-secondary btn-sm" type = "button" :disabled = "loading" @click ="loadPage" > 刷新 < / button > < / header >
< EmptyState v-if = "!sources.length" title="暂无 source" description="ProjectSource registry 没有返回可见 MDTODO source。" / >
< button v-for = "source in sources" v-else :key="source.sourceId" class="source-option" type="button" :data-selected="source.sourceId === selectedSourceId" :data-source-id="source.sourceId" @click="selectedSourceId = source.sourceId" >
< span class = "pm-status" :data-tone = "sourceStatusTone(source.status)" > { { source . status || 'unknown' } } < / span >
< strong > { { source . displayName || source . sourceId } } < / strong >
< small > { { source . kind || 'mdtodo' } } / { { source . projectId || '-' } } < / small >
< / button >
< / aside >
< aside class = "data-panel mdtodo-file-panel" data -testid = " mdtodo -file -list " >
< header class = "mdtodo-panel-header" > < h2 > 文件 < / h2 > < span > { { taskLoading ? '同步中' : ` ${ files . length } 个 ` } } < / span > < / header >
< LoadingState v-if = "taskLoading" compact label="同步文件和任务" / >
< EmptyState v-else-if = "!files.length" title="暂无 TODO 文件" description="服务已连接,但当前 source 尚未发现可投影的 TODO。" / >
< button v-for = "file in files" v-else :key="file.fileRef" class="file-option" type="button" :data-selected="file.fileRef === selectedFileRef" :data-file-ref="file.fileRef" @click="selectFile(file.fileRef)" >
< strong > { { file . title || file . relativePath || file . fileRef } } < / strong >
< small > { { file . relativePath || file . fileRef } } < / small >
< span : data -status = " file.parseError ? ' error ' : ' ok ' " > { { file . parseError ? 'parse error' : ` ${ file . taskCount ? ? 0 } tasks ` } } < / span >
< / button >
< / aside >
< section class = "data-panel mdtodo-task-panel" data -testid = " mdtodo -task -tree " >
< header class = "mdtodo-panel-header" > < h2 > 任务树 < / h2 > < span > { { filteredTasks . length } } tasks < / span > < / header >
< EmptyState v-if = "!filteredTasks.length" title="暂无任务" description="当前文件没有任务投影;可以刷新 source 状态后再查看。" / >
< header class = "mdtodo-panel-header" >
< div > < h2 > 任务树 < / h2 > < p > { { filteredTasks . length } } / { { fileScopedTasks . length } } tasks < / p > < / div >
< div class = "task-tools" >
< input v-model = "taskSearch" data-testid="mdtodo-task-search" type="search" placeholder="R1 / title" aria-label="搜索任务" / >
< select v-model = "taskStatusFilter" data-testid="mdtodo-task-status-filter" aria-label="筛选状态" >
< option value = "all" > 全部 < / option >
< option value = "open" > 待办 < / option >
< option value = "in_progress" > 进行中 < / option >
< option value = "done" > 完成 < / option >
< option value = "blocked" > 阻塞 < / option >
< / select >
< / div >
< / header >
< LoadingState v-if = "taskLoading" compact label="同步文件和任务" / >
< EmptyState v-else-if = "!visibleTaskRows.length" title="暂无任务" description="当前文件没有任务投影。" / >
< div v-else class = "task-tree" role = "tree" aria -label = " MDTODO task tree " >
< button v-for = "task in filtered Tasks" :key="task.taskRef" class="task-row" role="treeitem" type="button" :style="taskIndent(task)" :data-selected="task.taskRef === selectedTaskRef" :data-task-ref="task.taskRef" :data-task-status="task.status || 'unknown'" @click="selectTask(task.taskRef )" >
< span class = "task-status " : data -status = " task.status | | ' unknown ' " > { { statusLabel ( task . status ) } } < / span >
< strong > { { task . title || task . taskId || 'Untitled task' } } < / strong >
< small > line { { task . lineNumber ? ? '- ' } } / links { { task . linkCount ? ? 0 } } < / small >
< / button >
< div v-for = "task in visible TaskRow s" :key="task.taskRef" class="task-row-shell" :style="taskIndent(task )" >
< button class = "task-toggle" type = "button " : aria -label = " collapsedTaskRefs.has ( task.taskRef ) ? ' 展开任务 ' : ' 折叠任务 ' " :disabled = "!hasChildren(task)" @click.stop ="toggleTask(task)" > {{ hasChildren ( task ) ? ( collapsedTaskRefs.has ( task.taskRef ) ? ' + ' : ' - ' ) : ' ' }} < / button >
< button class = "task-row" role = "treeitem" type = "button" : data -selected = " task.taskRef = = = selectedTaskRef " :data-task-ref = "task.taskRef" :data-task-id = "task.taskId" : data -task -status = " task.status | | ' unknown ' " @click ="selectTask(task.taskRef)" >
< span class = "task-id" > { { task . taskId || 'R? ' } } < / span >
< strong > { { task . title || task . taskId || 'Untitled task' } } < / strong >
< span class = "task-status" : data -status = " task.status | | ' unknown ' " > { { statusLabel ( task . status ) } } < / span >
< / button >
< / div >
< / div >
< / section >
< aside class = "data-panel mdtodo-detail-panel" data -testid = " mdtodo -task -detail " >
< header class = "mdtodo-panel-header" > < h2 > 任务详情 < / h2 > < span > { { selectedTask ? statusLabel ( selectedTask . status ) : '未选择' } } < / span > < / header >
< header class = "mdtodo-panel-header" > < div > < h2 > 任务详情 < / h2 > < p > { { selectedTask ? statusLabel ( selectedTask . status ) : '未选择' } } < / p > < / div > < / header >
< EmptyState v-if = "!selectedTask" title="未选择任务" description="选择任务后显示 taskRef、状态、文件和 Workbench link 摘要。" / >
< template v-else >
< dl class = "task-detail-list" >
< div > < dt > Title < / dt > < dd > { { selectedTask . title || selectedTask . taskId } } < / dd > < / div >
< div > < dt > TaskRef < / dt > < dd > < code > { { selectedTask . taskRef } } < / code > < / dd > < / div >
< div > < dt > Project < / dt > < dd > < code > { { selectedTask . projectId || '-' } } < / code > < / dd > < / div >
< div > < dt > FileRef < / dt > < dd > < code > { { selectedTask . fileRef || '-' } } < / code > < / dd > < / div >
< div > < dt > File < / dt > < dd > < code > { { selectedFile ? . relativePath || selectedTask . fileRef || '-' } } < / code > < / dd > < / div >
< div > < dt > Updated < / dt > < dd > { { displayDate ( selectedTask . updatedAt ) } } < / dd > < / div >
< / dl >
< button class = "btn btn-primary" type = "button" data -testid = " mdtodo -workbench -launch " :disabled = "launchButtonDisabled" :aria-disabled = "launchButtonDisabled" @click ="launchSelectedTask" >
{ { launchLoading ? '启动中' : '在 Workbench 执行' } }
< / button >
< p class = "launch-blocker" data -testid = " mdtodo -workbench -launch -blocker " > { { workbenchLaunchEnabled ? '公共 Workbench Launch API 已可用。 ' : 'Workbench Launch capability 暂不可用。 ' } } < / p >
< p class = "launch-blocker" data -testid = " mdtodo -workbench -launch -blocker " > { { workbenchLaunchEnabled ? 'Workbench Launch API ready ' : 'Workbench Launch capability unavailable ' } } < / p >
< p v-if = "launchError" class="launch-blocker" data-testid="mdtodo-workbench-launch-error" > {{ launchError }} < / p >
< section class = "task-links" data -testid = " mdtodo -workbench -link -summary " >
< header > < strong > Workbench Links < / strong > < span v-if = "linkLoading" > 同步中 < / span > < / header >
< ul v-if = "links.length" >
< li v-for = "link in links" :key="link.linkId" >
< strong > { { link . sessionId || link . linkId } } < / strong >
< small > { { shortRef ( link . traceId || link . taskRef || link . projectId ) } } < / small >
< / li >
< li v-for = "link in links" :key="link.linkId" > < strong > { { link . sessionId || link . linkId } } < / strong > < small > { { shortRef ( link . traceId || link . taskRef || link . projectId ) } } < / small > < / li >
< / ul >
< p v-else > 暂无关联 session 。 < / p >
< / section >
@@ -272,64 +426,113 @@ function displayDate(value?: string | null): string {
< / aside >
< / section >
< / template >
< div v-if = "showInfo" class="modal-backdrop" data-testid="mdtodo-info-popover" role="dialog" aria-modal="true" aria-label="MDTODO 摘要" @click.self="showInfo = false" >
< section class = "mdtodo-dialog mdtodo-info-dialog" >
< header > < h2 > MDTODO 摘要 < / h2 > < button class = "icon-button" type = "button" aria -label = " 关闭摘要 " @click ="showInfo = false" > × < / button > < / header >
< dl class = "summary-list" >
< div > < dt > Source < / dt > < dd > { { sources . length } } / { { selectedSource ? . displayName || '-' } } < / dd > < / div >
< div > < dt > Files < / dt > < dd > { { files . length } } / { { selectedFile ? . relativePath || '-' } } < / dd > < / div >
< div > < dt > Tasks < / dt > < dd > open { { taskStatusCounts . open || 0 } } / progress { { taskStatusCounts . in _progress || 0 } } / done { { taskStatusCounts . done || 0 } } < / dd > < / div >
< div > < dt > Links < / dt > < dd > { { links . length } } / { { workbenchLaunchEnabled ? 'launch enabled' : 'launch unavailable' } } < / dd > < / div >
< / dl >
< / section >
< / div >
< div v-if = "showSourceConfig" class="modal-backdrop" data-testid="mdtodo-source-config-dialog" role="dialog" aria-modal="true" aria-label="配置 MDTODO Source" @click.self="showSourceConfig = false" >
< section class = "mdtodo-dialog source-config-dialog" >
< header > < h2 > Source 配置 < / h2 > < button class = "icon-button" type = "button" aria -label = " 关闭配置 " @click ="showSourceConfig = false" > × < / button > < / header >
< form class = "source-form" @submit.prevent ="saveSourceConfig" >
< label > < span > Source ID < / span > < input v-model = "sourceForm.sourceId" data-testid="mdtodo-source-form-id" required / > < / label >
< label > < span > Display Name < / span > < input v-model = "sourceForm.displayName" data-testid="mdtodo-source-form-name" required / > < / label >
< label > < span > HWPOD ID < / span > < input v-model = "sourceForm.hwpodId" data-testid="mdtodo-source-form-hwpod" required / > < / label >
< label > < span > Node ID < / span > < input v-model = "sourceForm.nodeId" data-testid="mdtodo-source-form-node" required / > < / label >
< label > < span > Workspace Root < / span > < input v-model = "sourceForm.workspaceRootRef" data-testid="mdtodo-source-form-workspace" / > < / label >
< label > < span > MDTODO Root < / span > < input v-model = "sourceForm.mdtodoRootRef" data-testid="mdtodo-source-form-root" required / > < / label >
< label > < span > Max Files < / span > < input v -model .number = " sourceForm.maxFiles " data -testid = " mdtodo -source -form -max -files " type = "number" min = "1" max = "500" / > < / label >
< footer >
< button class = "btn btn-primary" data -testid = " mdtodo -source -save " type = "submit" :disabled = "sourceSaving" > { { sourceSaving ? '保存中' : '保存' } } < / button >
< button class = "btn btn-secondary" data -testid = " mdtodo -source -probe " type = "button" :disabled = "sourceProbeLoading" @click ="probeSelectedSource" > {{ sourceProbeLoading ? ' Probe... ' : ' Probe ' }} < / button >
< button class = "btn btn-secondary" data -testid = " mdtodo -source -reindex -dialog " type = "button" :disabled = "sourceReindexLoading" @click ="reindexSelectedSource" > {{ sourceReindexLoading ? ' Reindex... ' : ' Reindex ' }} < / button >
< / footer >
< p v-if = "sourceMessage" class="source-message" > {{ sourceMessage }} < / p >
< p v-if = "sourceError" class="source-error" > {{ sourceError }} < / p >
< / form >
< / section >
< / div >
< / section >
< / template >
< style scoped >
. mdtodo - page { min - width : 0 ; }
. mdtodo - summary - grid { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ; gap : 12 px ; }
. mdtodo - summary - grid . metric - card strong { color : # 0 f172a ; font - size : 28 px ; line - height : 1.15 ; }
. mdtodo - summary - grid . metric - card small { min - width : 0 ; overflow : hidden ; color : # 64748 b ; text - overflow : ellipsis ; white - space : nowrap ; }
. mdtodo - workspace { display : grid ; min - height : min ( 720 px , calc ( 100 dvh - 190 px ) ) ; grid - template - columns : minmax ( 210 px , 0.72 fr ) minmax ( 230 px , 0.82 fr ) minmax ( 360 px , 1.28 fr ) minmax ( 320 px , 0.96 fr ) ; gap : 12 px ; }
. mdtodo - source - panel ,
. mdtodo - file - panel ,
. mdtodo - toolbar { display : grid ; grid - template - columns : minmax ( 220 px , 0.9 fr ) minmax ( 260 px , 1.1 fr ) auto ; gap : 10 px ; align - items : end ; border : 1 px solid # d8e2df ; border - radius : 8 px ; background : # fbfdfb ; padding : 10 px ; }
. toolbar - field { display : grid ; min - width : 0 ; gap : 5 px ; }
. toolbar - field span { color : # 52615 c ; font - size : 11 px ; font - weight : 800 ; letter - spacing : 0 ; text - transform : uppercase ; }
. toolbar - field select ,
. task - tools input ,
. task - tools select ,
. source - form input { width : 100 % ; min - width : 0 ; height : 36 px ; border : 1 px solid # cbd8d2 ; border - radius : 6 px ; background : # fff ; color : # 14211 d ; padding : 0 10 px ; font : inherit ; }
. toolbar - actions { display : flex ; gap : 8 px ; justify - content : flex - end ; }
. icon - button { display : inline - grid ; width : 36 px ; height : 36 px ; place - items : center ; border : 1 px solid # cbd8d2 ; border - radius : 6 px ; background : # fff ; color : # 14211 d ; font - weight : 850 ; }
. icon - button : disabled { opacity : 0.5 ; }
. source - message ,
. source - error { margin : 0 ; border - radius : 6 px ; padding : 8 px 10 px ; font - size : 13 px ; }
. source - message { border : 1 px solid # bbf7d0 ; background : # f0fdf4 ; color : # 166534 ; }
. source - error { border : 1 px solid # fecaca ; background : # fef2f2 ; color : # 991 b1b ; }
. mdtodo - workspace { display : grid ; min - height : min ( 720 px , calc ( 100 dvh - 220 px ) ) ; grid - template - columns : minmax ( 420 px , 1.15 fr ) minmax ( 340 px , 0.85 fr ) ; gap : 12 px ; }
. mdtodo - task - panel ,
. mdtodo - detail - panel ,
. mdtodo - error { display : grid ; min - width : 0 ; min - height : 0 ; align - content : start ; gap : 10 px ; padding : 14 px ; overflow : auto ; }
. mdtodo - panel - header { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : 10 px ; }
. mdtodo - panel - header h2 { margin : 0 ; color : # 0 f172a ; font - size : 16 px ; line - height : 1.2 ; }
. mdtodo - panel - header span { color : # 64748 b ; font - size : 12 px ; white - space : nowrap ; }
. source - option ,
. file - option ,
. task - row { display : grid ; width : 100 % ; min - width : 0 ; gap : 5 px ; border : 1 px solid # e2e8f0 ; border - radius : 8 px ; background : # ffffff ; color : # 0 f172a ; padding : 10 px ; text - align : left ; }
. source - option [ data - selected = "true" ] ,
. file - option [ data - selected = "true" ] ,
. task - row [ data - selected = "true" ] { border - color : # 0891 b2 ; background : # ecfeff ; box - shadow : inset 3 px 0 0 # 0891 b2 ; }
. source - option strong ,
. file - option strong ,
. mdtodo - error { display : grid ; min - width : 0 ; min - height : 0 ; align - content : start ; gap : 12 px ; padding : 14 px ; overflow : auto ; }
. mdtodo - panel - header { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : 12 px ; }
. mdtodo - panel - header h2 { margin : 0 ; color : # 14211 d ; font - size : 16 px ; line - height : 1.2 ; }
. mdtodo - panel - header p { margin : 3 px 0 0 ; color : # 64706 b ; font - size : 12 px ; }
. task - tools { display : grid ; width : min ( 360 px , 48 % ) ; grid - template - columns : minmax ( 130 px , 1 fr ) minmax ( 110 px , 0.7 fr ) ; gap : 8 px ; }
. task - tree { display : grid ; gap : 5 px ; }
. task - row - shell { display : grid ; grid - template - columns : 24 px minmax ( 0 , 1 fr ) ; gap : 4 px ; align - items : center ; }
. task - toggle { width : 24 px ; height : 24 px ; border : 1 px solid # d8e2df ; border - radius : 4 px ; background : # fff ; color : # 31423 c ; font - weight : 850 ; line - height : 1 ; }
. task - toggle : disabled { border - color : transparent ; background : transparent ; }
. task - row { display : grid ; width : 100 % ; min - width : 0 ; grid - template - columns : minmax ( 52 px , 0.22 fr ) minmax ( 0 , 1 fr ) auto ; gap : 8 px ; align - items : center ; border : 1 px solid # d8e2df ; border - radius : 6 px ; background : # fff ; color : # 14211 d ; padding : 8 px 10 px ; text - align : left ; }
. task - row [ data - selected = "true" ] { border - color : # 0 f766e ; background : # ecfdf5 ; box - shadow : inset 3 px 0 0 # 0 f766e ; }
. task - id { color : # 0 f766e ; font - size : 12 px ; font - weight : 850 ; }
. task - row strong { min - width : 0 ; overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
. source - option small ,
. file - option small ,
. task - row small { min - width : 0 ; overflow : hidden ; color : # 64748 b ; font - size : 12 px ; text - overflow : ellipsis ; white - space : nowrap ; }
. file - option span { width : fit - content ; border - radius : 999 px ; background : # f1f5f9 ; color : # 475569 ; padding : 3 px 7 px ; font - size : 11 px ; font - weight : 750 ; }
. file - option span [ data - status = "error" ] { background : # fef2f2 ; color : # 991 b1b ; }
. task - tree { display : grid ; gap : 6 px ; }
. pm - status ,
. task - status { display : inline - flex ; width : fit - content ; max - width : 100 % ; align - items : center ; border - radius : 999 px ; border : 1 px solid # cbd5e1 ; padding : 4 px 8 px ; font - size : 11 px ; font - weight : 750 ; line - height : 1 ; }
. pm - status [ data - tone = "ok" ] { border - color : # 86 efac ; background : # dcfce7 ; color : # 166534 ; }
. pm - status [ data - tone = "error" ] { border - color : # fecaca ; background : # fef2f2 ; color : # 991 b1b ; }
. pm - status [ data - tone = "pending" ] { border - color : # fde68a ; background : # fffbeb ; color : # 92400 e ; }
. task - status { display : inline - flex ; width : fit - content ; max - width : 100 % ; align - items : center ; border - radius : 999 px ; border : 1 px solid # bae6fd ; background : # f0f9ff ; color : # 075985 ; padding : 4 px 8 px ; font - size : 11 px ; font - weight : 750 ; line - height : 1 ; }
. task - status [ data - status = "done" ] { border - color : # 86 efac ; background : # dcfce7 ; color : # 166534 ; }
. task - status [ data - status = "in_progress" ] { border - color : # fde68a ; background : # fffbeb ; color : # 92400 e ; }
. task - status [ data - status = "blocked" ] { border - color : # fed7aa ; background : # fff7ed ; color : # 9 a3412 ; }
. task - status { border - color : # bae6fd ; background : # f0f9ff ; color : # 075985 ; }
. task - detail - list { display : grid ; gap : 8 px ; margin : 0 ; }
. task - detail - list div { display : grid ; gap : 4 px ; border - bottom : 1 px solid # e2e8f0 ; padding - bottom : 8 px ; }
. task - detail - list dt { color : # 64748 b ; font - size : 11 px ; font - weight : 800 ; text - transform : uppercase ; }
. task - detail - list dd { min - width : 0 ; margin : 0 ; color : # 0 f172a ; font - size : 13 px ; line - height : 1.45 ; overflow - wrap : anywhere ; }
. task - detail - list code { font - size : 12 px ; }
. launch - blocker { margin : 0 ; border - radius : 8 px ; background : # f8fafc ; color : # 475569 ; padding : 10 px ; font - size : 12 px ; line - height : 1.45 ; }
. task - links { display : grid ; gap : 8 px ; }
. task - links header { display : flex ; align - items : center ; justify - content : space - between ; gap : 8 px ; color : # 0 f172a ; }
. task - links header span ,
. task - links p { margin : 0 ; color : # 64748 b ; font - size : 12 px ; }
. task - detail - list ,
. summary - list { display : grid ; gap : 8 px ; margin : 0 ; }
. task - detail - list div ,
. summary - list div { display : grid ; gap : 4 px ; border - bottom : 1 px solid # e2e8f0 ; padding - bottom : 8 px ; }
. task - detail - list dt ,
. summary - list dt { color : # 64706 b ; font - size : 11 px ; font - weight : 800 ; text - transform : uppercase ; }
. task - detail - list dd ,
. summary - list dd { min - width : 0 ; margin : 0 ; overflow - wrap : anywhere ; color : # 14211 d ; font - size : 13 px ; }
. task - detail - list code { color : # 0 f766e ; font - size : 12 px ; }
. launch - blocker { margin : 0 ; color : # 64706 b ; font - size : 12 px ; }
. task - links { display : grid ; gap : 8 px ; border - top : 1 px solid # e2e8f0 ; padding - top : 10 px ; }
. task - links header { display : flex ; justify - content : space - between ; gap : 10 px ; color : # 14211 d ; font - size : 13 px ; }
. task - links ul { display : grid ; gap : 6 px ; margin : 0 ; padding : 0 ; list - style : none ; }
. task - links li { display : grid ; min - width : 0 ; gap : 4 px ; border : 1 px solid # e2e8f0 ; border - radius : 8 px ; padding : 8 px ; }
. task - links li strong ,
. task - links li small { min - width : 0 ; overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
. task - links li small { color : # 64748 b ; }
@ media ( max - width : 1320 px ) { . mdtodo - workspace { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; } }
@ media ( max - width : 820 px ) { . mdtodo - summary - grid ,
. task - links li { display : grid ; gap : 3 px ; border : 1 px solid # d8e2df ; border - radius : 6 px ; padding : 8 px ; }
. task - links small { color : # 64706 b ; overflow - wrap : anywhere ; }
. modal - backdrop { position : fixed ; z - index : 60 ; inset : 0 ; display : grid ; place - items : start center ; overflow : auto ; background : rgba ( 15 , 23 , 42 , 0.38 ) ; padding : 8 dvh 16 px 32 px ; }
. mdtodo - dialog { display : grid ; width : min ( 720 px , calc ( 100 vw - 32 px ) ) ; gap : 14 px ; border : 1 px solid # cbd8d2 ; border - radius : 8 px ; background : # fff ; color : # 14211 d ; padding : 16 px ; box - shadow : 0 24 px 60 px rgba ( 15 , 23 , 42 , 0.22 ) ; }
. mdtodo - dialog header { display : flex ; align - items : center ; justify - content : space - between ; gap : 12 px ; }
. mdtodo - dialog h2 { margin : 0 ; font - size : 18 px ; }
. source - form { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 12 px ; }
. source - form label { display : grid ; min - width : 0 ; gap : 5 px ; color : # 52615 c ; font - size : 12 px ; font - weight : 800 ; }
. source - form footer { display : flex ; grid - column : 1 / - 1 ; flex - wrap : wrap ; gap : 8 px ; }
. source - form . source - message ,
. source - form . source - error { grid - column : 1 / - 1 ; }
@ media ( max - width : 980 px ) {
. mdtodo - toolbar ,
. mdtodo - workspace { grid - template - columns : 1 fr ; }
. mdtodo - workspace { min - height : auto ; }
. toolbar - actions { justify - content : flex - start ; }
. task - tools { width : 100 % ; grid - template - columns : 1 fr ; }
. mdtodo - workspace { min - height : 0 ; }
}
@ media ( max - width : 640 px ) {
. source - form { grid - template - columns : 1 fr ; }
. task - row { grid - template - columns : minmax ( 48 px , auto ) minmax ( 0 , 1 fr ) ; }
. task - status { grid - column : 1 / - 1 ; }
}
< / style >