refactor(web): 重写 MDTODO 三栏工作台

This commit is contained in:
root
2026-07-13 13:51:28 +02:00
parent 951559de54
commit 8675832a31
17 changed files with 1285 additions and 1415 deletions
@@ -1,48 +1,32 @@
<!-- SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite. -->
<!-- Responsibility: new root/subtask/continue task dialog never a resident create box on the main layout. -->
<script setup lang="ts">
import BaseDialog from "@/components/common/BaseDialog.vue";
defineProps<{
show: boolean;
newTitle: string;
newBody: string;
loading: boolean;
disabled: boolean;
error: string | null;
hasSelectedTask: boolean;
}>();
const emit = defineEmits<{
close: [];
"update:newTitle": [value: string];
"update:newBody": [value: string];
create: [kind: "root" | "subtask" | "continue"];
}>();
defineProps<{ show: boolean; newTitle: string; newBody: string; loading: boolean; disabled: boolean; disabledReason: string | null; error: string | null; hasSelectedTask: boolean }>();
const emit = defineEmits<{ close: []; "update:newTitle": [value: string]; "update:newBody": [value: string]; create: [kind: "root" | "subtask" | "continue"] }>();
</script>
<template>
<BaseDialog :open="show" title="新建任务" description="选择根任务、子任务或同级延续,并写入当前 MDTODO source。" :busy="loading" @close="emit('close')">
<div class="task-create-form" data-testid="mdtodo-task-create-dialog">
<label><span>New task</span><input :value="newTitle" data-testid="mdtodo-new-title" :disabled="loading || disabled" placeholder="新任务标题" @input="emit('update:newTitle', ($event.target as HTMLInputElement).value)" /></label>
<textarea :value="newBody" data-testid="mdtodo-new-body" :disabled="loading || disabled" rows="5" placeholder="新任务正文,可留空。" @input="emit('update:newBody', ($event.target as HTMLTextAreaElement).value)" />
<p v-if="disabled" class="source-error" data-testid="mdtodo-task-create-disabled">当前任务仅有最近一次投影Node 恢复并读到源 Markdown 后才能新建任务</p>
<p v-if="error" class="source-error" data-testid="mdtodo-task-mutation-error">{{ error }}</p>
</div>
<BaseDialog :open="show" title="创建 MDTODO 任务" description="写入当前 Source/File;层级关系由 Project Management API 生成。" :busy="loading" @close="emit('close')">
<form class="task-form" data-testid="mdtodo-task-create-dialog" @submit.prevent="emit('create', hasSelectedTask ? 'continue' : 'root')">
<label>
任务标题
<input data-testid="mdtodo-new-title" autofocus :value="newTitle" :disabled="loading || disabled" @input="emit('update:newTitle', ($event.target as HTMLInputElement).value)" />
</label>
<label>Markdown 正文<textarea data-testid="mdtodo-new-body" rows="7" :value="newBody" :disabled="loading || disabled" @input="emit('update:newBody', ($event.target as HTMLTextAreaElement).value)" /></label>
<p v-if="disabled" class="dialog-alert" data-testid="mdtodo-task-create-disabled">{{ disabledReason || "当前上下文不可写" }}</p>
<p v-if="error" class="dialog-alert" data-testid="mdtodo-task-mutation-error">{{ error }}</p>
</form>
<template #footer>
<button class="btn btn-secondary" type="button" :disabled="loading" @click="emit('close')">取消</button>
<button class="btn btn-secondary" type="button" data-testid="mdtodo-add-root" :disabled="loading || disabled" @click="emit('create', 'root')">新增根任务</button>
<button class="btn btn-secondary" type="button" data-testid="mdtodo-add-subtask" :disabled="loading || disabled || !hasSelectedTask" @click="emit('create', 'subtask')">新增子任务</button>
<button class="btn btn-primary" type="button" data-testid="mdtodo-continue-task" :disabled="loading || disabled || !hasSelectedTask" @click="emit('create', 'continue')">延续同级</button>
<button type="button" :disabled="loading" @click="emit('close')">取消</button>
<button type="button" data-testid="mdtodo-add-root" :disabled="loading || disabled" @click="emit('create', 'root')">根任务</button>
<button type="button" data-testid="mdtodo-add-subtask" :disabled="loading || disabled || !hasSelectedTask" @click="emit('create', 'subtask')">子任务</button>
<button type="button" data-testid="mdtodo-continue-task" :disabled="loading || disabled || !hasSelectedTask" @click="emit('create', 'continue')">延续同级</button>
</template>
</BaseDialog>
</template>
<style scoped>
.task-create-form { display: grid; gap: 8px; }
.task-create-form label { display: grid; min-width: 0; gap: 5px; color: #52615c; font-size: 12px; font-weight: 800; }
.task-create-form input, .task-create-form textarea { width: 100%; min-width: 0; border: 1px solid #cbd8d4; border-radius: 6px; background: #fff; color: #14211d; font: inherit; line-height: 1.4; padding: 8px 9px; }
.task-create-form textarea { resize: vertical; }
.source-error { margin: 0; border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; border-radius: 6px; padding: 8px 10px; font-size: 13px; }
.task-form { display: grid; gap: 12px; }
.task-form label { display: grid; gap: 5px; color: #38515c; font-size: 11px; font-weight: 780; text-transform: uppercase; letter-spacing: .04em; }
.task-form input, .task-form textarea { box-sizing: border-box; width: 100%; border: 1px solid #9fb2bb; background: #f9fbfc; padding: 8px; }
.task-form input, .task-form textarea { color: #172d36; font: 13px/1.45 system-ui; text-transform: none; letter-spacing: normal; }
.task-form textarea { resize: vertical; font-family: ui-monospace, monospace; }
.dialog-alert { margin: 0; border: 1px solid #e2b2aa; background: #fff1ef; padding: 8px; color: #963f32; font-size: 12px; }
</style>
@@ -1,10 +1,7 @@
<!-- SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite. -->
<!-- Responsibility: collapse Source/TaskRef/File/Updated/counts/diagnostics/link metadata into an info dialog. -->
<script setup lang="ts">
import type { MdtodoFileRecord, MdtodoTaskRecord, MdtodoTaskPage, ProjectSource, ProjectWorkbenchLinkRecord, ProjectNavigationResponse } from "@/api";
import type { MdtodoFileRecord, MdtodoTaskPage, MdtodoTaskRecord, ProjectNavigationResponse, ProjectSource, ProjectWorkbenchLinkRecord } from "@/api";
import BaseDialog from "@/components/common/BaseDialog.vue";
import { displayDate, shortRef } from "@/composables/mdtodo/useMdtodoDisplay";
import { displayDate } from "@/composables/mdtodo/useMdtodoDisplay";
defineProps<{
show: boolean;
@@ -21,53 +18,62 @@ defineProps<{
taskStatusCounts: Record<string, number>;
navigation: ProjectNavigationResponse | null;
}>();
const emit = defineEmits<{ close: [] }>();
function workbenchLaunchEnabledLabel(navigation: ProjectNavigationResponse | null): string {
return navigation?.navigation?.capabilities?.workbenchLaunch === true ? "launch enabled" : "launch unavailable";
}
</script>
<template>
<BaseDialog :open="show" title="MDTODO 摘要" description="当前 source、文件、任务与 Workbench 链接的只读诊断。" wide @close="emit('close')">
<dl class="summary-list" data-testid="mdtodo-info-popover">
<div><dt>Source</dt><dd>{{ sources.length }} / {{ selectedSource?.displayName || selectedSourceId || '-' }}</dd></div>
<div><dt>Source ID</dt><dd><code>{{ selectedSourceId || '-' }}</code></dd></div>
<div><dt>Source Status</dt><dd>{{ selectedSource?.status || '-' }}</dd></div>
<div><dt>File</dt><dd>{{ selectedFile?.relativePath || selectedFileName }}</dd></div>
<div><dt>File Ref</dt><dd><code>{{ selectedFileRef || '-' }}</code></dd></div>
<div><dt>File Fingerprint</dt><dd><code>{{ shortRef(selectedFile?.fingerprint) }}</code></dd></div>
<div><dt>Task</dt><dd>{{ selectedTask?.taskId || '-' }} {{ selectedTask?.title || '' }}</dd></div>
<div><dt>TaskRef</dt><dd><code>{{ shortRef(selectedTask?.taskRef) }}</code></dd></div>
<div><dt>Status</dt><dd>{{ selectedTask?.status || '-' }}</dd></div>
<div><dt>Updated</dt><dd>{{ displayDate(selectedTask?.updatedAt) }}</dd></div>
<div><dt>Tasks</dt><dd>待办 {{ taskStatusCounts.open || 0 }} / 进行中 {{ taskStatusCounts.in_progress || 0 }} / 完成 {{ taskStatusCounts.done || 0 }} / 阻塞 {{ taskStatusCounts.blocked || 0 }}<span v-if="taskPage?.hasMore"> · window {{ taskPage.limit }}</span></dd></div>
<div><dt>File Tasks</dt><dd>{{ fileScopedTaskCount }}<span v-if="taskPage?.total != null"> / {{ taskPage.total }} total</span></dd></div>
<div><dt>Workbench Links</dt><dd>{{ links.length }} / {{ workbenchLaunchEnabledLabel(navigation) }}</dd></div>
<div v-if="links.length" class="info-links">
<dt>Link Sessions</dt>
<dd>
<ul class="info-link-list">
<li v-for="link in links.slice(0, 8)" :key="link.linkId">
<strong>{{ link.sessionId || link.linkId }}</strong>
<small>{{ shortRef(link.traceId || link.taskRef || link.projectId) }}</small>
</li>
</ul>
</dd>
</div>
</dl>
<template #footer><button class="btn btn-primary" type="button" @click="emit('close')">关闭</button></template>
<BaseDialog :open="show" title="MDTODO 信息" description="稳定 ID、Source authority、并发 fingerprint 与 Workbench 公共链接。" wide @close="emit('close')">
<div class="info-grid" data-testid="mdtodo-info-popover">
<section>
<h3>Source / File</h3>
<dl>
<div><dt>Source</dt><dd>{{ selectedSource?.displayName || selectedSourceId || "-" }}</dd></div>
<div><dt>Source ID</dt><dd><code>{{ selectedSourceId || "-" }}</code></dd></div>
<div><dt>Status</dt><dd>{{ selectedSource?.status || "-" }}</dd></div>
<div><dt>File</dt><dd>{{ selectedFile?.relativePath || selectedFileName }}</dd></div>
<div><dt>File Ref</dt><dd><code>{{ selectedFileRef || "-" }}</code></dd></div>
<div><dt>Fingerprint</dt><dd><code>{{ selectedFile?.fingerprint || "-" }}</code></dd></div>
</dl>
</section>
<section>
<h3>Task / Window</h3>
<dl>
<div><dt>Task</dt><dd>{{ selectedTask?.taskId || selectedTask?.rxxId || "-" }} {{ selectedTask?.title }}</dd></div>
<div><dt>Task Ref</dt><dd><code>{{ selectedTask?.taskRef || "-" }}</code></dd></div>
<div><dt>Updated</dt><dd>{{ displayDate(selectedTask?.updatedAt) }}</dd></div>
<div><dt>Window</dt><dd>{{ fileScopedTaskCount }}<span v-if="taskPage?.total != null"> / {{ taskPage.total }}</span></dd></div>
<div>
<dt>State</dt>
<dd>open {{ taskStatusCounts.open || 0 }} · progress {{ taskStatusCounts.in_progress || 0 }} · done {{ taskStatusCounts.done || 0 }} · blocked {{ taskStatusCounts.blocked || 0 }}</dd>
</div>
</dl>
</section>
<section class="wide">
<h3>Workbench Public Links</h3>
<p>{{ navigation?.navigation?.capabilities?.workbenchLaunch === true ? "Launch enabled" : "Launch unavailable" }} · {{ links.length }} links · {{ sources.length }} sources</p>
<ul>
<li v-for="link in links.slice(0, 8)" :key="link.linkId">
<strong>{{ link.sessionId || link.linkId }}</strong>
<code>{{ link.traceId || link.taskRef || link.projectId || "-" }}</code>
</li>
</ul>
</section>
</div>
<template #footer><button type="button" @click="emit('close')">关闭</button></template>
</BaseDialog>
</template>
<style scoped>
.summary-list { display: grid; gap: 8px; margin: 0; }
.summary-list > div { display: grid; gap: 4px; border-bottom: 1px solid #e2e8f0; padding-bottom: 8px; }
.summary-list dt { color: #64706b; font-size: 11px; font-weight: 800; text-transform: uppercase; }
.summary-list dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #14211d; font-size: 13px; }
.summary-list code { color: #0f766e; font-size: 12px; }
.info-link-list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
.info-link-list li { display: grid; gap: 3px; border: 1px solid #d8e2df; border-radius: 6px; padding: 8px; }
.info-link-list small { color: #64706b; overflow-wrap: anywhere; }
.info-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.info-grid section { border: 1px solid #bac9d0; background: #f7fafb; padding: 10px; }
.info-grid .wide { grid-column: 1 / -1; }
.info-grid h3 { margin: 0 0 8px; color: #294b59; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
.info-grid dl { display: grid; gap: 6px; margin: 0; }
.info-grid dl div { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 8px; border-bottom: 1px solid #d5dfe3; padding-bottom: 5px; }
.info-grid dt { color: #687d86; font-size: 10px; font-weight: 750; text-transform: uppercase; }
.info-grid dd, .info-grid p { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #1c333d; font-size: 12px; }
.info-grid code { color: #17627d; font: 11px ui-monospace, monospace; }
.info-grid ul { display: grid; gap: 5px; margin: 8px 0 0; padding: 0; list-style: none; }
.info-grid li { display: flex; justify-content: space-between; gap: 8px; }
@media (max-width: 600px) { .info-grid { grid-template-columns: 1fr; } .info-grid .wide { grid-column: auto; } }
</style>
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,3 @@
<!-- SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite. -->
<!-- Responsibility: right-pane report preview open/close/fold/fullscreen, light code blocks, internal scroll. -->
<script setup lang="ts">
import type { MdtodoReportPreviewRecord, MdtodoTaskLinkRecord } from "@/api";
import BaseDialog from "@/components/common/BaseDialog.vue";
@@ -14,12 +11,10 @@ defineProps<{
error: string | null;
collapsed: boolean;
showFullscreen: boolean;
links: MdtodoTaskLinkRecord[];
}>();
const emit = defineEmits<{
"update:collapsed": [value: boolean];
openPreview: [link: MdtodoTaskLinkRecord];
close: [];
openFullscreen: [];
closeFullscreen: [];
@@ -27,54 +22,39 @@ const emit = defineEmits<{
</script>
<template>
<aside class="report-sidebar" data-testid="mdtodo-report-sidebar" :data-collapsed="collapsed">
<header class="report-sidebar-header">
<button class="icon-button" type="button" data-testid="mdtodo-report-sidebar-toggle" :aria-label="collapsed ? '展开报告侧栏' : '收起报告侧栏'" @click="emit('update:collapsed', !collapsed)">{{ collapsed ? '' : '' }}</button>
<strong v-if="!collapsed" class="report-title">{{ activeLink?.label || report?.relativePath || 'Report' }}</strong>
<div v-if="!collapsed" class="report-sidebar-actions">
<button class="btn btn-secondary" type="button" data-testid="mdtodo-report-fullscreen" :disabled="!report" @click="emit('openFullscreen')">全屏</button>
<button class="icon-button" type="button" aria-label="关闭报告预览" data-testid="mdtodo-report-close" @click="emit('close')">×</button>
</div>
<aside class="report-viewer" data-testid="mdtodo-report-sidebar" :data-collapsed="collapsed">
<header>
<button type="button" data-testid="mdtodo-report-sidebar-toggle" :aria-label="collapsed ? '展开报告' : '收起报告'" @click="emit('update:collapsed', !collapsed)">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3 5 5-5 5" /></svg>
</button>
<strong v-if="!collapsed">{{ activeLink?.label || report?.relativePath || "Report" }}</strong>
<span />
<button v-if="!collapsed" type="button" data-testid="mdtodo-report-fullscreen" :disabled="!report" @click="emit('openFullscreen')">全屏</button>
<button v-if="!collapsed" type="button" data-testid="mdtodo-report-close" aria-label="关闭报告" @click="emit('close')">×</button>
</header>
<article v-if="!collapsed" class="report-preview" data-testid="mdtodo-report-preview">
<div v-if="loading" class="report-loading">加载报告</div>
<div v-else class="markdown-body" v-html="reportHtml" />
<p v-if="error" class="source-error" data-testid="mdtodo-report-error">{{ error }}</p>
<article v-if="!collapsed" data-testid="mdtodo-report-preview">
<div v-if="loading">读取报告</div>
<div v-else class="report-markdown" v-html="reportHtml" />
<p v-if="error">{{ error }}</p>
</article>
</aside>
<BaseDialog
:open="showFullscreen && Boolean(report)"
:title="activeLink?.label || report?.relativePath || 'Report'"
description="Markdown 报告只读预览。"
wide
@close="emit('closeFullscreen')"
>
<article class="markdown-body report-fullscreen-body" data-testid="mdtodo-report-fullscreen-dialog" v-html="fullscreenHtml" />
<template #footer><button class="btn btn-primary" type="button" @click="emit('closeFullscreen')">关闭</button></template>
<BaseDialog :open="showFullscreen && Boolean(report)" :title="activeLink?.label || report?.relativePath || 'Report'" description="Markdown 报告只读预览" wide @close="emit('closeFullscreen')">
<article class="report-markdown fullscreen" data-testid="mdtodo-report-fullscreen-dialog" v-html="fullscreenHtml" />
<template #footer><button type="button" @click="emit('closeFullscreen')">关闭</button></template>
</BaseDialog>
</template>
<style scoped>
.report-sidebar { display: grid; min-width: 0; min-height: 0; height: 100%; max-height: none; align-content: start; grid-template-rows: auto minmax(0, 1fr); background: #f8fafb; overflow: hidden; }
.report-sidebar[data-collapsed="true"] { justify-items: center; padding: 8px 4px; grid-template-rows: auto; }
.report-sidebar-header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px solid #d8e1e7; color: #111827; font-size: 13px; padding: 9px 10px; }
.report-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.report-sidebar-actions { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 6px; }
.report-preview { display: grid; min-height: 0; gap: 8px; overflow: hidden; padding: 10px; }
.report-preview > .markdown-body { min-height: 0; height: 100%; max-height: none; overflow: auto; }
.report-loading { color: #64706b; font-size: 13px; padding: 12px; }
.markdown-body { min-width: 0; color: #111827; font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
.markdown-body :deep(p), .markdown-body :deep(ul), .markdown-body :deep(ol), .markdown-body :deep(pre), .markdown-body :deep(blockquote) { margin: 0 0 8px; }
.markdown-body :deep(pre) { max-width: 100%; overflow: auto; border: 1px solid #d8e2df; border-radius: 6px; background: #f8faf9; color: #111827; padding: 10px; white-space: pre; }
.markdown-body :deep(code) { border-radius: 4px; background: #eef6f2; padding: 1px 4px; color: #0f766e; }
.markdown-body :deep(pre code) { background: transparent; color: inherit; padding: 0; white-space: inherit; }
.markdown-body :deep(a) { color: #0f766e; font-weight: 750; }
.source-error { margin: 0; border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; border-radius: 6px; padding: 8px 10px; font-size: 13px; }
.icon-button { display: inline-grid; width: 32px; height: 32px; place-items: center; border: 1px solid #cbd6dd; border-radius: 6px; background: #fff; color: #111827; font-weight: 850; }
.report-fullscreen-body { min-height: 0; max-height: calc(100dvh - 250px); overflow: auto; border: 1px solid #e2e8f0; border-radius: 6px; padding: 14px; }
@media (max-width: 720px) {
.report-sidebar { height: 100%; align-content: stretch; grid-template-rows: auto minmax(0, 1fr); }
.report-preview > .markdown-body { max-height: none; }
}
.report-viewer { display: grid; grid-template-rows: auto minmax(0, 1fr); min-width: 0; min-height: 0; height: 100%; background: #f3f7f8; }
.report-viewer[data-collapsed="true"] { width: 38px; }
.report-viewer header { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto auto; align-items: center; gap: 5px; border-bottom: 1px solid #bdcbd2; padding: 7px; }
.report-viewer header button { height: 28px; border: 1px solid #9fb1ba; background: #fff; color: #294653; font-size: 11px; }
.report-viewer svg { width: 13px; fill: none; stroke: currentColor; stroke-width: 1.7; }
.report-viewer[data-collapsed="false"] header > button:first-child svg { transform: rotate(180deg); }
.report-viewer strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.report-viewer > article { min-height: 0; overflow: auto; padding: 12px; }
.report-markdown { overflow-wrap: anywhere; color: #1b3039; font: 12.5px/1.6 system-ui; }
.report-markdown :deep(pre) { max-width: 100%; overflow: auto; border: 1px solid #c8d5da; background: #fff; padding: 9px; }
.report-viewer p { border: 1px solid #e4b6ae; background: #fff1ef; padding: 7px; color: #963f32; }
.fullscreen { max-height: calc(100dvh - 230px); overflow: auto; }
</style>
@@ -1,56 +1,37 @@
<!-- SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite. -->
<!-- Responsibility: HWPOD-bound MDTODO source configuration dialog (create/update/probe/reindex). -->
<script setup lang="ts">
import type { ProjectSourceInput } from "@/api";
import BaseDialog from "@/components/common/BaseDialog.vue";
defineProps<{
show: boolean;
form: ProjectSourceInput;
saving: boolean;
probeLoading: boolean;
reindexLoading: boolean;
message: string | null;
error: string | null;
}>();
const emit = defineEmits<{
close: [];
save: [];
probe: [];
reindex: [];
}>();
defineProps<{ show: boolean; form: ProjectSourceInput; saving: boolean; probeLoading: boolean; reindexLoading: boolean; reindexDisabled: boolean; message: string | null; error: string | null }>();
const emit = defineEmits<{ close: []; save: []; probe: []; reindex: [] }>();
</script>
<template>
<BaseDialog :open="show" title="Source 配置" description="配置 MDTODO 索引来源并执行受控探测或重建。" wide :busy="saving" @close="emit('close')">
<form id="mdtodo-source-form" class="source-form" data-testid="mdtodo-source-config-dialog" @submit.prevent="emit('save')">
<label><span>Source ID</span><input v-model="form.sourceId" data-testid="mdtodo-source-form-id" required /></label>
<label><span>Display Name</span><input v-model="form.displayName" data-testid="mdtodo-source-form-name" required /></label>
<label><span>HWPOD ID</span><input v-model="form.hwpodId" data-testid="mdtodo-source-form-hwpod" required /></label>
<label><span>Node ID</span><input v-model="form.nodeId" data-testid="mdtodo-source-form-node" required /></label>
<label><span>Workspace Root</span><input v-model="form.workspaceRootRef" data-testid="mdtodo-source-form-workspace" /></label>
<label><span>MDTODO Root</span><input v-model="form.mdtodoRootRef" data-testid="mdtodo-source-form-root" required /></label>
<label><span>Max Files</span><input v-model.number="form.maxFiles" data-testid="mdtodo-source-form-max-files" type="number" min="1" max="500" /></label>
<p v-if="message" class="source-message">{{ message }}</p>
<p v-if="error" class="source-error">{{ error }}</p>
<BaseDialog :open="show" title="MDTODO Source" description="HWPOD workspace 是 Source authorityProbe 与 Reindex 不读取或显示 Secret 值。" wide :busy="saving" @close="emit('close')">
<form id="mdtodo-source-form" class="source-grid" data-testid="mdtodo-source-config-dialog" @submit.prevent="emit('save')">
<label>Source ID<input v-model="form.sourceId" data-testid="mdtodo-source-form-id" required /></label>
<label>显示名称<input v-model="form.displayName" data-testid="mdtodo-source-form-name" required /></label>
<label>HWPOD ID<input v-model="form.hwpodId" data-testid="mdtodo-source-form-hwpod" required /></label>
<label>Node ID<input v-model="form.nodeId" data-testid="mdtodo-source-form-node" required /></label>
<label class="wide">Workspace Root<input v-model="form.workspaceRootRef" data-testid="mdtodo-source-form-workspace" /></label>
<label class="wide">MDTODO Root<input v-model="form.mdtodoRootRef" data-testid="mdtodo-source-form-root" required /></label>
<label>Max Files<input v-model.number="form.maxFiles" data-testid="mdtodo-source-form-max-files" type="number" min="1" max="500" /></label>
<output v-if="message || error" class="source-output" data-testid="mdtodo-source-message" :data-error="Boolean(error)">{{ error || message }}</output>
<p v-if="reindexDisabled" class="source-hint">先保存 Source再执行 Reindex</p>
</form>
<template #footer>
<button class="btn btn-secondary" data-testid="mdtodo-source-config-close" type="button" :disabled="saving" @click="emit('close')">取消</button>
<button class="btn btn-secondary" data-testid="mdtodo-source-probe" type="button" :disabled="probeLoading || saving" @click="emit('probe')">{{ probeLoading ? 'Probe...' : 'Probe' }}</button>
<button class="btn btn-secondary" data-testid="mdtodo-source-reindex-dialog" type="button" :disabled="reindexLoading || saving" @click="emit('reindex')">{{ reindexLoading ? 'Reindex...' : 'Reindex' }}</button>
<button class="btn btn-primary" data-testid="mdtodo-source-save" type="submit" form="mdtodo-source-form" :disabled="saving">{{ saving ? '保存中' : '保存' }}</button>
<button type="button" data-testid="mdtodo-source-config-close" :disabled="saving" @click="emit('close')">取消</button>
<button type="button" data-testid="mdtodo-source-probe" :disabled="probeLoading || saving" @click="emit('probe')">{{ probeLoading ? "Probe" : "Probe" }}</button>
<button type="button" data-testid="mdtodo-source-reindex-dialog" :disabled="reindexLoading || saving || reindexDisabled" @click="emit('reindex')">{{ reindexLoading ? "Reindex" : "Reindex" }}</button>
<button type="submit" form="mdtodo-source-form" data-testid="mdtodo-source-save" :disabled="saving">{{ saving ? "保存中…" : "保存 Source" }}</button>
</template>
</BaseDialog>
</template>
<style scoped>
.source-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.source-form label { display: grid; min-width: 0; gap: 5px; color: #52615c; font-size: 12px; font-weight: 800; }
.source-form input { width: 100%; min-width: 0; height: 36px; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; padding: 0 10px; font: inherit; }
.source-form .source-message, .source-form .source-error { grid-column: 1 / -1; margin: 0; border-radius: 6px; padding: 8px 10px; font-size: 13px; }
.source-message { border: 1px solid #bbf7d0; background: #f0fdf4; color: #166534; }
.source-error { border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; }
@media (max-width: 640px) { .source-form { grid-template-columns: 1fr; } }
.source-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; }
.source-grid label { display: grid; gap: 5px; color: #3a535e; font-size: 11px; font-weight: 780; text-transform: uppercase; letter-spacing: .04em; }
.source-grid label.wide, .source-output { grid-column: 1 / -1; }
.source-grid input { box-sizing: border-box; width: 100%; height: 34px; border: 1px solid #9fb3bc; background: #f9fbfc; padding: 0 8px; color: #172d36; text-transform: none; letter-spacing: normal; }
.source-output { border: 1px solid #b6d5c0; background: #eef8f1; padding: 8px; color: #286344; font-size: 12px; }
.source-output[data-error="true"] { border-color: #e1b1a9; background: #fff1ef; color: #943e31; }
.source-hint { grid-column: 1 / -1; margin: 0; color: #7b5a2e; font-size: 11px; }
@media (max-width: 560px) { .source-grid { grid-template-columns: 1fr; } .source-grid label.wide, .source-output { grid-column: auto; } }
</style>
@@ -1,29 +1,15 @@
<!-- SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite. -->
<!-- Responsibility: main workspace task title, status, body (the primary content), inline edit, report links, Workbench launch. -->
<script setup lang="ts">
import { computed } from "vue";
import type { MdtodoTaskDetailRecord, MdtodoTaskLinkRecord, MdtodoTaskRecord, ProjectNavigationResponse } from "@/api";
import type { ProviderProfile } from "@/types";
import type { ApiError, ErrorDiagnostic } from "@/types";
import EmptyState from "@/components/common/EmptyState.vue";
import type { MdtodoTaskDetailRecord, MdtodoTaskLinkRecord, MdtodoTaskRecord } from "@/api";
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
import RefreshBoundary from "@/components/common/RefreshBoundary.vue";
import type { ApiError, ErrorDiagnostic, ProviderProfile } from "@/types";
interface ProviderProfileOption {
value: ProviderProfile;
label: string;
configured?: boolean;
}
const props = defineProps<{
defineProps<{
task: MdtodoTaskRecord | null;
detail: MdtodoTaskDetailRecord | null;
detailLoading: boolean;
detailError: string | null;
detailPartial: boolean;
mutationDisabled: boolean;
bodyText: string;
bodyHtml: string;
links: MdtodoTaskLinkRecord[];
editingTitle: boolean;
@@ -42,8 +28,7 @@ const props = defineProps<{
launchEnabled: boolean;
launchBlocker: string | null;
providerProfile: ProviderProfile;
providerOptions: ProviderProfileOption[];
navigation: ProjectNavigationResponse | null;
providerOptions: Array<{ value: ProviderProfile; label: string; configured?: boolean }>;
}>();
const emit = defineEmits<{
@@ -63,152 +48,153 @@ const emit = defineEmits<{
launchWorkbench: [];
}>();
const bodyDiffersFromTitle = computed(() => {
const body = normalizeTaskText(props.bodyText);
if (!body) return false;
const title = normalizeTaskText(props.task?.title);
const titleWithId = normalizeTaskText([props.task?.taskId, props.task?.title].filter(Boolean).join(" "));
return body !== title && body !== titleWithId;
});
function normalizeTaskText(value?: string | null): string {
return String(value ?? "").replace(/\s+/g, " ").trim();
function titleKeydown(event: KeyboardEvent): void {
if (event.key === "Enter" || event.key === "F2") {
event.preventDefault();
emit("beginTitleEdit");
}
}
</script>
<template>
<section class="mdtodo-detail-panel" data-testid="mdtodo-task-detail">
<header class="mdtodo-detail-header">
<div class="task-title-block">
<span class="task-id-large">{{ task?.taskId || 'R?' }}</span>
<div v-if="task && editingTitle" class="inline-editor">
<input :value="editTitle" data-testid="mdtodo-edit-title" :disabled="mutationLoading || mutationDisabled" @input="emit('update:editTitle', ($event.target as HTMLInputElement).value)" @keyup.enter="emit('saveTaskBasics')" @keyup.esc="emit('cancelTitleEdit')" />
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-save" :disabled="mutationLoading || mutationDisabled" @click="emit('saveTaskBasics')">保存</button>
<button class="btn btn-secondary" type="button" :disabled="mutationLoading || mutationDisabled" @click="emit('cancelTitleEdit')">取消</button>
<main class="document-workspace" data-testid="mdtodo-task-detail">
<header class="document-header">
<div class="title-region">
<code>{{ task?.taskId || task?.rxxId || "R?" }}</code>
<div v-if="editingTitle" class="title-editor">
<input
data-testid="mdtodo-edit-title"
:value="editTitle"
:disabled="mutationLoading || mutationDisabled"
@input="emit('update:editTitle', ($event.target as HTMLInputElement).value)"
@keydown.enter="emit('saveTaskBasics')"
@keydown.esc="emit('cancelTitleEdit')"
/>
<button type="button" data-testid="mdtodo-edit-save" @click="emit('saveTaskBasics')">保存</button>
<button type="button" @click="emit('cancelTitleEdit')">取消</button>
</div>
<button v-else-if="task" class="task-title-read" type="button" data-testid="mdtodo-title-read" :disabled="mutationDisabled" :title="mutationDisabled ? '当前为只读投影' : '双击编辑标题'" @dblclick="!mutationDisabled && emit('beginTitleEdit')">{{ task.title || task.taskId }}</button>
<strong v-else>未选择任务</strong>
<button
v-else
class="title-button"
type="button"
data-testid="mdtodo-title-read"
:disabled="!task || mutationDisabled"
@dblclick="emit('beginTitleEdit')"
@keydown="titleKeydown"
>{{ task?.title || "选择任务查看正文" }}</button>
</div>
<div v-if="task" class="detail-toolbar">
<select :value="editStatus" data-testid="mdtodo-edit-status" :disabled="mutationLoading || mutationDisabled" aria-label="任务状态" @change="emit('update:editStatus', ($event.target as HTMLSelectElement).value)">
<option value="open">待办</option>
<option value="in_progress">进行中</option>
<option value="done">完成</option>
<option value="blocked">阻塞</option>
<div class="document-meta">
<select
data-testid="mdtodo-edit-status"
:value="editStatus"
:disabled="!task || mutationLoading || mutationDisabled"
aria-label="任务状态"
@change="emit('update:editStatus', ($event.target as HTMLSelectElement).value); emit('saveTaskBasics')"
>
<option value="open">open</option>
<option value="in_progress">in_progress</option>
<option value="blocked">blocked</option>
<option value="done">done</option>
</select>
<button class="btn btn-secondary" type="button" data-testid="mdtodo-status-save" :disabled="mutationLoading || mutationDisabled" @click="emit('saveTaskBasics')">保存状态</button>
<label class="provider-field" for="mdtodo-provider-profile">
<span>模型通道</span>
<select id="mdtodo-provider-profile" :value="providerProfile" data-testid="mdtodo-provider-profile" :disabled="launchLoading" aria-label="模型通道" @change="emit('update:providerProfile', ($event.target as HTMLSelectElement).value)">
<option v-for="option in providerOptions" :key="option.value" :value="option.value" :disabled="option.configured === false">
{{ option.label }}{{ option.configured === false ? ' · 未配置' : '' }}
</option>
</select>
</label>
<button class="btn btn-primary" type="button" data-testid="mdtodo-workbench-launch" :disabled="!task || !launchEnabled || launchLoading" :aria-disabled="!task || !launchEnabled || launchLoading" @click="emit('launchWorkbench')">{{ launchLoading ? '启动中' : ' Workbench 执行' }}</button>
<span v-if="detailPartial" class="authority-warning">offline projection</span>
<span v-else>{{ detail?.relativePath || "source detail" }}</span>
</div>
</header>
<EmptyState v-if="!task" title="未选择任务" description="从左侧任务树选择一个任务,正文将在此处显示。" />
<div v-if="mutationMessage || mutationError || detailError" class="document-notice" data-testid="mdtodo-task-notice" :data-error="Boolean(mutationError || detailError)">
{{ mutationError || detailError || mutationMessage }}
</div>
<ApiErrorDiagnostic
v-if="launchError || launchApiError || launchDiagnostic"
title="Workbench 启动失败"
:error="launchError"
:api-error="launchApiError"
:diagnostic="launchDiagnostic"
compact
/>
<template v-else>
<div class="task-status-stack">
<p v-if="mutationMessage" class="source-message" data-testid="mdtodo-task-mutation-message">{{ mutationMessage }}</p>
<p v-if="mutationError" class="source-error" data-testid="mdtodo-task-mutation-error">{{ mutationError }}</p>
<ApiErrorDiagnostic v-if="launchError" class="launch-blocker" data-testid="mdtodo-workbench-launch-error" title="Workbench launch" :error="launchError" :api-error="launchApiError" :diagnostic="launchDiagnostic" compact />
<p v-else-if="launchBlocker" class="launch-blocker" data-testid="mdtodo-workbench-launch-blocker">{{ launchBlocker }}</p>
<section class="document-body">
<div v-if="detailLoading" class="document-loading">读取 Markdown 正文</div>
<template v-else-if="task">
<textarea
v-if="editingBody"
data-testid="mdtodo-body-editor"
:value="editBody"
:disabled="mutationLoading || mutationDisabled"
aria-label="Markdown 正文编辑器"
@input="emit('update:editBody', ($event.target as HTMLTextAreaElement).value)"
@keydown.esc="emit('cancelBodyEdit')"
/>
<article
v-else
class="markdown-document"
data-testid="mdtodo-body-read"
tabindex="0"
title="双击编辑 Markdown 正文"
@dblclick="!mutationDisabled && emit('beginBodyEdit')"
v-html="bodyHtml"
/>
</template>
<div v-else class="document-empty">从左侧任务索引选择一个 Rxx</div>
</section>
<footer class="document-footer">
<div class="edit-actions">
<template v-if="editingBody">
<button type="button" data-testid="mdtodo-body-save" :disabled="mutationLoading || mutationDisabled" @click="emit('saveTaskBody')">保存正文</button>
<button type="button" @click="emit('cancelBodyEdit')">取消</button>
</template>
<button v-else type="button" data-testid="mdtodo-body-edit" :disabled="!task || mutationDisabled" @click="emit('beginBodyEdit')">编辑正文</button>
<button type="button" data-testid="mdtodo-delete-task" :disabled="!task || mutationLoading || mutationDisabled" @click="emit('deleteTask')">{{ deleteConfirm ? "确认删除" : "删除" }}</button>
<button v-if="deleteConfirm" type="button" @click="emit('cancelDelete')">取消删除</button>
</div>
<RefreshBoundary class="task-detail-content" data-testid="mdtodo-task-editor" :loading="detailLoading" mode="blocking" :has-content="true" compact label="同步任务详情">
<p v-if="detailError" :class="detailPartial ? 'source-message task-detail-notice' : 'source-error task-detail-error'" :data-testid="detailPartial ? 'mdtodo-task-detail-notice' : 'mdtodo-task-detail-error'">{{ detailError }}</p>
<section class="task-body-section" data-testid="mdtodo-task-body">
<div v-if="editingBody" class="inline-body-editor">
<textarea :value="editBody" data-testid="mdtodo-edit-body" :disabled="mutationLoading || mutationDisabled" rows="14" @input="emit('update:editBody', ($event.target as HTMLTextAreaElement).value)" />
<div class="editor-actions">
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-body-save" :disabled="mutationLoading || mutationDisabled" @click="emit('saveTaskBody')">保存正文</button>
<button class="btn btn-secondary" type="button" :disabled="mutationLoading || mutationDisabled" @click="emit('cancelBodyEdit')">取消</button>
</div>
</div>
<article v-else class="markdown-body task-body-rendered" data-testid="mdtodo-body-rendered" :aria-disabled="mutationDisabled ? 'true' : 'false'" :title="mutationDisabled ? '当前为只读投影' : '双击编辑正文'" @dblclick="!mutationDisabled && emit('beginBodyEdit')">
<div v-if="bodyHtml && bodyDiffersFromTitle" v-html="bodyHtml" />
<p v-else-if="!task" class="empty-inline">暂无补充正文</p>
<p v-else class="empty-inline">{{ task.title || task.taskId || '暂无正文' }}</p>
</article>
</section>
</RefreshBoundary>
<footer class="task-document-footer">
<section class="report-link-section" data-testid="mdtodo-report-section">
<strong>报告</strong>
<div v-if="links.length" class="report-link-list">
<button v-for="link in links" :key="link.linkId" class="report-link-button" type="button" data-testid="mdtodo-report-link" :disabled="link.kind !== 'markdown-report'" @click="emit('openReport', link)">
<span>{{ link.label || link.href }}</span>
<small>{{ link.relativePath || link.kind }}</small>
</button>
</div>
<span v-else class="empty-inline">暂无报告链接</span>
</section>
<div class="editor-actions danger-actions">
<button class="btn btn-secondary" type="button" data-testid="mdtodo-delete-task" :disabled="mutationLoading || mutationDisabled" @click="emit('deleteTask')">{{ deleteConfirm ? '确认删除' : '删除任务' }}</button>
<button v-if="deleteConfirm" class="btn btn-secondary" type="button" data-testid="mdtodo-delete-cancel" :disabled="mutationLoading || mutationDisabled" @click="emit('cancelDelete')">取消</button>
<div class="report-links">
<div v-for="link in links" :key="link.linkId" class="report-link-item">
<button type="button" data-testid="mdtodo-report-link" :disabled="link.availability === 'projected-link-unavailable'" @click="emit('openReport', link)">报告 · {{ link.label || link.relativePath }}</button>
<small v-if="link.availability === 'projected-link-unavailable'">{{ link.message || "报告索引待刷新,Node 恢复后可打开" }}</small>
</div>
</footer>
</template>
</section>
</div>
<div class="launch-controls">
<select
id="mdtodo-provider-profile"
data-testid="mdtodo-provider-profile"
:value="providerProfile"
aria-label="模型通道"
@change="emit('update:providerProfile', ($event.target as HTMLSelectElement).value as ProviderProfile)"
>
<option v-for="option in providerOptions" :key="option.value" :value="option.value" :disabled="option.configured === false">{{ option.label }}</option>
</select>
<button type="button" data-testid="mdtodo-launch-workbench" :disabled="!launchEnabled || launchLoading" :title="launchBlocker || undefined" @click="emit('launchWorkbench')">
{{ launchLoading ? "启动中…" : "Workbench" }}
</button>
<small v-if="launchBlocker" class="launch-blocker">{{ launchBlocker }}</small>
</div>
</footer>
</main>
</template>
<style scoped>
.mdtodo-detail-panel { display: grid; min-width: 0; min-height: 0; height: 100%; max-height: none; align-content: stretch; grid-template-rows: auto auto minmax(0, 1fr) auto; gap: 0; overflow: hidden; background: #fff; }
.mdtodo-detail-header { display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto; gap: 8px; border-bottom: 1px solid #e2e8ed; padding: 10px 12px; }
.task-title-block { display: grid; min-width: 0; grid-template-columns: auto minmax(0, 1fr); gap: 8px; align-items: center; }
.task-id-large { display: inline-flex; min-width: 46px; justify-content: center; border: 1px solid #9be3dc; border-radius: 6px; background: #e8faf6; color: #0f766e; padding: 5px 8px; font-size: 12px; font-weight: 850; }
.task-title-read { min-width: 0; overflow: hidden; border: 0; border-radius: 5px; background: transparent; color: #111827; padding: 5px 6px; font: inherit; font-size: 18px; font-weight: 850; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.task-title-read:hover { background: #f3f8f8; }
.inline-editor { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; align-items: center; }
.inline-editor input { width: 100%; min-width: 0; border: 1px solid #cbd6dd; border-radius: 6px; color: #111827; font: inherit; padding: 8px 9px; }
.detail-toolbar { display: flex; flex-wrap: wrap; justify-content: flex-start; gap: 8px; grid-row: 2; }
.detail-toolbar select { min-width: 108px; height: 32px; border: 1px solid #cbd6dd; border-radius: 6px; background: #fff; color: #111827; padding: 0 8px; font: inherit; font-size: 13px; }
.provider-field { display: flex; min-width: 218px; align-items: center; gap: 6px; color: #5d6a73; font-size: 12px; font-weight: 850; }
.provider-field span { white-space: nowrap; }
.provider-field select { flex: 1 1 150px; min-width: 150px; }
.task-status-stack { display: grid; min-width: 0; gap: 6px; }
.task-detail-content { display: grid; min-width: 0; min-height: 0; grid-template-rows: auto minmax(0, 1fr); gap: 0; overflow: hidden; }
.task-detail-status { display: grid; min-width: 0; gap: 6px; }
.task-body-section { display: grid; min-width: 0; min-height: 0; }
.task-body-rendered { min-height: 0; height: 100%; max-height: none; overflow: auto; border: 0; background: #fff; padding: 16px 18px 20px; }
.markdown-body { min-width: 0; color: #111827; font-size: 14px; line-height: 1.65; overflow-wrap: anywhere; }
.markdown-body :deep(p), .markdown-body :deep(ul), .markdown-body :deep(ol), .markdown-body :deep(pre), .markdown-body :deep(blockquote) { margin: 0 0 10px; }
.markdown-body :deep(pre) { max-width: 100%; overflow: auto; border: 1px solid #d8e2df; border-radius: 6px; background: #f8faf9; color: #111827; padding: 10px; white-space: pre; }
.markdown-body :deep(code) { border-radius: 4px; background: #eef6f2; padding: 1px 4px; color: #0f766e; }
.markdown-body :deep(pre code) { background: transparent; color: inherit; padding: 0; white-space: inherit; }
.markdown-body :deep(a) { color: #0f766e; font-weight: 750; }
.empty-inline { margin: 0; color: #64706b; }
.inline-body-editor { display: grid; min-height: 0; height: 100%; grid-template-rows: minmax(0, 1fr) auto; gap: 8px; padding: 12px; }
.inline-body-editor textarea { width: 100%; min-width: 0; min-height: 0; border: 1px solid #cbd6dd; border-radius: 6px; background: #fff; color: #111827; font: inherit; line-height: 1.5; padding: 10px; resize: vertical; }
.task-document-footer { display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; border-top: 1px solid #e2e8ed; background: #fbfcfd; padding: 9px 12px; }
.report-link-section { display: grid; min-width: 0; min-height: 0; grid-template-columns: auto minmax(0, 1fr); gap: 8px; align-items: center; }
.report-link-section > strong { color: #5d6a73; font-size: 11px; font-weight: 850; text-transform: uppercase; }
.report-link-list { display: flex; min-width: 0; gap: 6px; overflow-x: auto; padding-bottom: 1px; }
.report-link-button { display: grid; flex: 0 0 min(280px, 40vw); min-width: 0; gap: 2px; border: 1px solid #d8e2df; border-radius: 6px; background: #fff; color: #111827; padding: 6px 8px; text-align: left; }
.report-link-button:disabled { opacity: 0.55; }
.report-link-button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 800; }
.report-link-button small { color: #64706b; overflow-wrap: anywhere; }
.editor-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.danger-actions { justify-content: flex-end; }
.danger-actions .btn { border-color: #fecaca; color: #991b1b; }
.source-message { margin: 0; border: 1px solid #bbf7d0; background: #f0fdf4; color: #166534; border-radius: 6px; padding: 8px 10px; font-size: 13px; }
.source-error { margin: 0; border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; border-radius: 6px; padding: 8px 10px; font-size: 13px; }
.launch-blocker { margin: 0; border-bottom: 1px solid #e2e8ed; background: #fff7ed; color: #9a3412; padding: 7px 12px; font-size: 12px; }
@media (max-width: 640px) {
.mdtodo-detail-header { flex-direction: column; align-items: stretch; }
.detail-toolbar { justify-content: flex-start; }
.provider-field { width: 100%; justify-content: space-between; }
.provider-field select { flex: 1 1 auto; }
.task-title-block { grid-template-columns: 1fr; }
.inline-editor { grid-template-columns: 1fr; }
.task-document-footer { grid-template-columns: 1fr; }
.report-link-section { grid-template-columns: 1fr; }
}
.document-workspace { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; min-width: 0; min-height: 0; height: 100%; background: #fff; color: #1b2d35; }
.document-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; border-bottom: 1px solid #c7d3d9; padding: 10px 12px; }
.title-region, .title-editor, .document-meta, .document-footer > div { display: flex; min-width: 0; align-items: center; gap: 7px; }
.title-region code { color: #276079; font: 800 12px ui-monospace, monospace; }
.title-button { overflow: hidden; border: 0; background: transparent; padding: 0; color: #152a33; font-size: 16px; font-weight: 760; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.title-editor input { min-width: 220px; border: 1px solid #8fa7b2; padding: 6px 8px; }
.document-meta { color: #657983; font: 700 10px ui-monospace, monospace; }
.authority-warning { color: #9b4a34; }
.document-notice { border-bottom: 1px solid #b7d8c2; background: #edf8f1; padding: 6px 12px; color: #276342; font-size: 12px; }
.document-notice[data-error="true"] { border-color: #e3b5ad; background: #fff1ef; color: #963e31; }
.document-body { min-height: 0; overflow: hidden; }
.markdown-document, .document-body textarea { box-sizing: border-box; width: 100%; height: 100%; min-height: 0; overflow: auto; border: 0; padding: 18px 22px; color: #172b34; font: 13px/1.65 system-ui; }
.document-body textarea { resize: none; background: #f8fbfc; font-family: ui-monospace, monospace; }
.markdown-document :deep(pre) { max-width: 100%; overflow: auto; border: 1px solid #cbd7dc; background: #f4f7f8; padding: 10px; }
.document-loading, .document-empty { padding: 22px; color: #647983; }
.document-footer { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; border-top: 1px solid #c7d3d9; background: #f2f6f7; padding: 7px 9px; }
.document-footer button, .document-footer select, .title-editor button { height: 29px; border: 1px solid #9fb2bb; background: #fff; padding: 0 8px; color: #24404c; font-size: 11px; font-weight: 700; }
.report-links { overflow: auto; }
.report-link-item { display: grid; gap: 2px; }
.report-link-item small { max-width: 260px; color: #98503c; font-size: 10px; }
.launch-blocker { max-width: 220px; color: #98503c; font-size: 10px; }
.report-links button { white-space: nowrap; }
@media (max-width: 720px) { .document-header { display: grid; } .document-footer { grid-template-columns: 1fr; } .markdown-document, .document-body textarea { padding: 14px; } }
</style>
@@ -1,162 +1,142 @@
<!-- SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite. -->
<!-- Responsibility: Rxx outline tree fold, search, status filter, current highlight, deep-link auto-expand. -->
<script setup lang="ts">
import { computed } from "vue";
import { computed, nextTick, ref } from "vue";
import type { MdtodoTaskPage, MdtodoTaskRecord } from "@/api";
import EmptyState from "@/components/common/EmptyState.vue";
import RefreshBoundary from "@/components/common/RefreshBoundary.vue";
import { statusLabel, taskIndent as indentStyle, useRxxTaskTree } from "@/composables/mdtodo/useMdtodoDisplay";
import { statusLabel } from "@/composables/mdtodo/useMdtodoDisplay";
import { taskParentRefs, treeKeyAction, visibleTasks } from "@/composables/mdtodo/mdtodoTaskTreeModel";
const props = withDefaults(defineProps<{
const props = defineProps<{
tasks: MdtodoTaskRecord[];
fileScopedTaskCount: number;
taskPage: MdtodoTaskPage | null;
selectedTaskRef: string | null;
collapsedTaskRefs: Set<string>;
collapsed: boolean;
loading: boolean;
refreshMode?: "persistent" | "blocking";
search: string;
statusFilter: string;
createDisabled: boolean;
}>(), {
refreshMode: "blocking"
});
const emit = defineEmits<{
"update:search": [value: string];
"update:statusFilter": [value: string];
"update:collapsed": [value: boolean];
"update:collapsedTaskRefs": [value: Set<string>];
selectTask: [taskRef: string];
loadMore: [];
openCreateTask: [];
status: string;
page: MdtodoTaskPage | null;
loading: boolean;
fileScopedTaskCount: number;
}>();
const tree = useRxxTaskTree(
() => props.tasks,
() => props.collapsedTaskRefs
);
const emit = defineEmits<{
select: [taskRef: string];
"update:collapsedTaskRefs": [value: Set<string>];
updateSearch: [value: string];
updateStatus: [value: string];
loadMore: [];
}>();
const visibleTaskRows = computed(() => tree.visibleTaskRows.value);
const hasVisibleTasks = computed(() => visibleTaskRows.value.length > 0);
const showHeaderRefresh = computed(() => props.loading && props.refreshMode === "persistent" && hasVisibleTasks.value);
const isFiltered = computed(() => props.tasks.length !== props.fileScopedTaskCount || Boolean(props.search.trim()) || props.statusFilter !== "all");
const statusSummary = computed(() => {
const counts = new Map<string, number>();
for (const task of props.tasks) {
const status = task.status || "unknown";
counts.set(status, (counts.get(status) ?? 0) + 1);
}
return [...counts.entries()].map(([status, count]) => ({ status, count, label: statusLabel(status) }));
});
const rows = ref<HTMLElement[]>([]);
const items = computed(() => visibleTasks(props.tasks, props.collapsedTaskRefs, props.selectedTaskRef));
function toggleTask(task: MdtodoTaskRecord): void {
function depth(task: MdtodoTaskRecord): number {
return task.depth ?? taskParentRefs(task, props.tasks).length;
}
function children(task: MdtodoTaskRecord): MdtodoTaskRecord[] {
return props.tasks.filter((item) => item.parentTaskRef === task.taskRef || (!item.parentTaskRef && taskParentRefs(item, props.tasks).at(-1) === task.taskRef));
}
function updateCollapse(task: MdtodoTaskRecord, collapse: boolean): void {
const next = new Set(props.collapsedTaskRefs);
if (next.has(task.taskRef)) next.delete(task.taskRef);
else next.add(task.taskRef);
collapse ? next.add(task.taskRef) : next.delete(task.taskRef);
emit("update:collapsedTaskRefs", next);
}
function focus(index: number): void {
void nextTick(() => rows.value[Math.max(0, Math.min(index, rows.value.length - 1))]?.focus());
}
function keydown(event: KeyboardEvent, task: MdtodoTaskRecord, index: number): void {
const hasChildren = children(task).length > 0;
const expanded = !props.collapsedTaskRefs.has(task.taskRef);
const action = treeKeyAction(event.key, expanded, hasChildren);
if (action === "none") return;
event.preventDefault();
if (action === "previous") focus(index - 1);
if (action === "next") focus(index + 1);
if (action === "expand") updateCollapse(task, false);
if (action === "collapse") updateCollapse(task, true);
if (action === "parent") {
const parentRef = taskParentRefs(task, props.tasks).at(-1);
const parentIndex = items.value.findIndex((item) => item.taskRef === parentRef);
if (parentIndex >= 0) focus(parentIndex);
}
}
</script>
<template>
<section class="mdtodo-task-panel" data-testid="mdtodo-task-tree" :data-collapsed="collapsed">
<header class="mdtodo-panel-header">
<div class="task-tree-title">
<h2>{{ collapsed ? '纲' : '任务树' }}</h2>
<p v-if="!collapsed">{{ visibleTaskRows.length }} / {{ taskPage?.total ?? fileScopedTaskCount }} tasks<span v-if="taskPage?.hasMore"> · window {{ taskPage.limit }}</span></p>
</div>
<div class="task-pane-actions">
<span v-if="showHeaderRefresh" class="task-refresh-indicator" role="status" aria-live="polite" data-testid="mdtodo-task-tree-refreshing">
<span class="task-refresh-dot" aria-hidden="true" />
<span v-if="!collapsed">同步中</span>
</span>
<button class="icon-button" type="button" data-testid="mdtodo-tree-new-task-open" aria-label="新建任务" :disabled="createDisabled" :title="createDisabled ? '当前任务正文不可读,Node 恢复后才能写入' : undefined" @click="emit('openCreateTask')">+</button>
</div>
</header>
<template v-if="!collapsed">
<div class="task-tools">
<input :value="search" data-testid="mdtodo-task-search" type="search" placeholder="R1 / title" aria-label="搜索任务" @input="emit('update:search', ($event.target as HTMLInputElement).value)" />
<select :value="statusFilter" data-testid="mdtodo-task-status-filter" aria-label="筛选状态" @change="emit('update:statusFilter', ($event.target as HTMLSelectElement).value)">
<option value="all">全部</option>
<option value="open">待办</option>
<option value="in_progress">进行中</option>
<option value="done">完成</option>
<option value="blocked">阻塞</option>
</select>
</div>
<RefreshBoundary class="task-tree-boundary" :loading="loading" :mode="refreshMode" :has-content="hasVisibleTasks" compact label="同步文件和任务">
<template #empty>
<EmptyState title="暂无任务" description="当前文件没有任务投影。" />
</template>
<div class="task-tree" role="tree" aria-label="MDTODO task tree" :aria-busy="loading ? 'true' : 'false'" :data-refreshing="loading ? 'true' : 'false'">
<div v-for="task in visibleTaskRows" :key="task.taskRef" class="task-row-shell" :style="indentStyle(task)">
<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="emit('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>
<button class="task-toggle" type="button" :data-testid="tree.hasChildren(task) ? 'mdtodo-task-toggle' : undefined" :aria-label="props.collapsedTaskRefs.has(task.taskRef) ? '展开任务' : '折叠任务'" :disabled="!tree.hasChildren(task)" @click.stop="toggleTask(task)">{{ tree.hasChildren(task) ? (props.collapsedTaskRefs.has(task.taskRef) ? '+' : '-') : '' }}</button>
</div>
<button v-if="taskPage?.hasMore" class="task-load-more" type="button" data-testid="mdtodo-task-load-more" :disabled="loading" @click="emit('loadMore')">
{{ loading ? '加载中…' : `加载更多(已载入 ${fileScopedTaskCount} / ${taskPage.total ?? '?'}` }}
</button>
<footer class="task-tree-facts" data-testid="mdtodo-task-tree-facts">
<span v-if="isFiltered"><strong>{{ props.tasks.length }}</strong> 当前筛选</span>
<span v-for="item in statusSummary" :key="item.status"><strong>{{ item.count }}</strong> {{ item.label }}</span>
<span><strong>{{ fileScopedTaskCount }}</strong> 已载入</span>
<span v-if="taskPage?.total !== undefined"><strong>{{ taskPage.total }}</strong> 总计</span>
</footer>
</div>
</RefreshBoundary>
</template>
</section>
<aside class="task-index" aria-label="任务索引">
<div class="task-index-tools">
<label>
<span class="sr-only">搜索任务</span>
<input data-testid="mdtodo-task-search" type="search" :value="search" placeholder="搜索 Rxx、标题" @input="emit('updateSearch', ($event.target as HTMLInputElement).value)" />
</label>
<select data-testid="mdtodo-task-status-filter" aria-label="任务状态" :value="status" @change="emit('updateStatus', ($event.target as HTMLSelectElement).value)">
<option value="all">全部状态</option>
<option value="open">Open</option>
<option value="in_progress">In progress</option>
<option value="done">Done</option>
<option value="blocked">Blocked</option>
</select>
</div>
<div class="task-index-count">
<span data-testid="mdtodo-task-page-fact">{{ fileScopedTaskCount }} / {{ page?.total ?? fileScopedTaskCount }} 已载入</span>
<span v-if="loading">同步中</span>
</div>
<ul class="task-tree" role="tree" data-testid="mdtodo-task-tree">
<li v-for="(task, index) in items" :key="task.taskRef" role="none" :style="{ '--depth': depth(task) }">
<button
v-if="children(task).length"
class="tree-toggle"
type="button"
tabindex="-1"
:aria-label="collapsedTaskRefs.has(task.taskRef) ? '展开子任务' : '折叠子任务'"
@click="updateCollapse(task, !collapsedTaskRefs.has(task.taskRef))"
>
<svg viewBox="0 0 12 12" aria-hidden="true"><path d="m4 2 4 4-4 4" /></svg>
</button>
<span v-else class="tree-spacer" />
<button
:ref="(element) => { if (element) rows[index] = element as HTMLElement }"
class="task-row"
role="treeitem"
type="button"
:tabindex="task.taskRef === selectedTaskRef || (!selectedTaskRef && index === 0) ? 0 : -1"
:aria-level="depth(task) + 1"
:aria-selected="task.taskRef === selectedTaskRef"
:aria-expanded="children(task).length ? !collapsedTaskRefs.has(task.taskRef) : undefined"
:data-testid="`mdtodo-task-${task.taskId || task.rxxId}`"
:data-task-id="task.taskId || task.rxxId"
@click="emit('select', task.taskRef)"
@keydown="keydown($event, task, index)"
>
<span class="task-id">{{ task.taskId || task.rxxId }}</span>
<span class="task-title">{{ task.title || "未命名任务" }}</span>
<span v-if="task.linkCount" class="task-links" :aria-label="`${task.linkCount} 个报告链接`">{{ task.linkCount }}</span>
<span class="task-state" :data-state="task.status || 'unknown'">{{ statusLabel(task.status) }}</span>
</button>
</li>
</ul>
<button v-if="page?.hasMore" class="load-more" type="button" :disabled="loading" data-testid="mdtodo-load-more" @click="emit('loadMore')">加载更多任务</button>
</aside>
</template>
<style scoped>
.mdtodo-task-panel { display: grid; min-width: 0; min-height: 0; height: 100%; max-height: none; align-content: stretch; grid-template-rows: auto auto minmax(0, 1fr); gap: 8px; overflow: hidden; padding: 10px 8px 10px 10px; }
.mdtodo-task-panel[data-collapsed="true"] { justify-items: center; padding: 8px 4px; overflow: hidden; grid-template-rows: auto; }
.mdtodo-panel-header { display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; }
.task-tree-title { min-width: 0; }
.mdtodo-panel-header h2 { min-width: 0; margin: 0; overflow: hidden; color: #111827; font-size: 14px; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
.mdtodo-panel-header p { margin: 2px 0 0; color: #64706b; font-size: 11px; }
.task-pane-actions { display: flex; gap: 6px; margin-left: auto; }
.task-refresh-indicator { display: inline-flex; min-width: 0; height: 30px; align-items: center; gap: 5px; color: #0f766e; font-size: 11px; font-weight: 800; white-space: nowrap; }
.task-refresh-dot { width: 13px; height: 13px; border: 2px solid #b7e4df; border-top-color: #0f766e; border-radius: 999px; animation: task-refresh-spin 0.8s linear infinite; }
.task-tools { display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr) minmax(96px, 0.55fr); gap: 6px; }
.task-tools input, .task-tools select { width: 100%; min-width: 0; height: 30px; border: 1px solid #cbd6dd; border-radius: 6px; background: #fff; color: #111827; padding: 0 8px; font: inherit; font-size: 12px; }
.task-tree-boundary { display: grid; min-width: 0; min-height: 0; height: 100%; }
.task-tree { display: flex; min-width: 0; min-height: 0; height: 100%; flex-direction: column; gap: 1px; overflow: auto; border-top: 1px solid #e3e9ee; padding-top: 2px; }
.task-tree[data-refreshing="true"] { border-top-color: #b7e4df; }
.task-row-shell { display: grid; grid-template-columns: minmax(0, 1fr) 24px; gap: 4px; align-items: center; }
.task-toggle { width: 24px; height: 24px; border: 1px solid transparent; border-radius: 4px; background: transparent; color: #52616b; font-weight: 850; line-height: 1; }
.task-toggle:hover:not(:disabled) { border-color: #cbd6dd; background: #fff; }
.task-toggle:disabled { border-color: transparent; background: transparent; }
.task-load-more { min-height: 34px; margin: 7px 4px; border: 1px solid #8abcb7; border-radius: 6px; background: #effaf8; color: #126f6a; font-size: 11px; font-weight: 800; }
.task-load-more:disabled { opacity: .55; }
.task-tree-facts { display: flex; min-height: 34px; flex-wrap: wrap; align-items: center; gap: 5px 10px; margin-top: auto; border-top: 1px solid #d9e2e8; background: #f1f5f7; color: #5d6a73; padding: 7px 8px; font-size: 10px; }
.task-tree-facts span { white-space: nowrap; }
.task-tree-facts strong { color: #16353a; font-size: 11px; }
.task-row { display: grid; width: 100%; min-width: 0; grid-template-columns: auto minmax(0, 1fr) auto; gap: 7px; align-items: center; border: 1px solid transparent; border-radius: 5px; background: transparent; color: #111827; padding: 7px 8px; text-align: left; }
.task-row:hover { border-color: #d9e2e8; background: #fff; }
.task-row[data-selected="true"] { border-color: #5bb7af; background: #e8faf6; box-shadow: inset 3px 0 0 #0f766e; }
.task-id { color: #0f766e; font-size: 12px; font-weight: 850; white-space: nowrap; }
.task-row strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-status { display: inline; width: auto; max-width: none; border: 0; border-radius: 0; background: transparent; color: #075985; padding: 0; font-size: 10px; font-weight: 850; line-height: inherit; }
.task-status::before { content: "["; }
.task-status::after { content: "]"; }
.task-status[data-status="done"] { color: #166534; }
.task-status[data-status="in_progress"] { color: #92400e; }
.task-status[data-status="blocked"] { color: #9a3412; }
.icon-button { display: inline-grid; width: 30px; height: 30px; place-items: center; border: 1px solid #cbd6dd; border-radius: 6px; background: #fff; color: #111827; font-weight: 850; }
.icon-button:disabled { opacity: 0.5; }
@keyframes task-refresh-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.task-refresh-dot { animation: none; }
}
@media (max-width: 720px) {
.mdtodo-task-panel { height: 100%; align-content: stretch; grid-template-rows: auto auto minmax(0, 1fr); }
.task-tree { max-height: none; }
}
.task-index { display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; min-width: 0; min-height: 0; height: 100%; border-right: 1px solid #b9c8cf; background: #edf3f5; }
.task-index-tools { display: grid; gap: 6px; border-bottom: 1px solid #c6d2d8; padding: 8px; }
.task-index-tools input, .task-index-tools select { box-sizing: border-box; width: 100%; height: 30px; border: 1px solid #aebdc5; background: #fff; padding: 0 8px; font-size: 12px; }
.task-index-count { display: flex; justify-content: space-between; border-bottom: 1px solid #c6d2d8; padding: 5px 9px; color: #58707b; font: 700 10px ui-monospace, monospace; text-transform: uppercase; }
.task-tree { min-height: 0; margin: 0; padding: 4px 0; overflow: auto; list-style: none; }
.task-tree li { display: grid; grid-template-columns: 22px minmax(0, 1fr); padding-left: calc(var(--depth) * 13px); }
.tree-toggle, .tree-spacer { width: 22px; height: 34px; border: 0; background: transparent; padding: 0; }
.tree-toggle svg { width: 11px; fill: none; stroke: #45616e; stroke-width: 1.6; }
.tree-toggle[aria-label^="折叠"] svg { transform: rotate(90deg); }
.task-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 7px; min-width: 0; height: 34px; }
.task-row { border: 0; border-left: 2px solid transparent; background: transparent; padding: 0 7px; text-align: left; cursor: pointer; }
.task-row[aria-selected="true"] { border-left-color: #25708d; background: #d9eaf0; }
.task-id { color: #315563; font: 750 11px ui-monospace, monospace; }
.task-title { overflow: hidden; color: #1b3039; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.task-links { color: #3b7185; font: 700 9px ui-monospace, monospace; }
.task-state { color: #687d86; font: 700 9px ui-monospace, monospace; text-transform: uppercase; }
.task-state[data-state="blocked"] { color: #a33b2e; }
.task-state[data-state="done"] { color: #28704b; }
.load-more { height: 34px; border: 0; border-top: 1px solid #bccbd2; background: #e5eef1; color: #285466; font-size: 11px; font-weight: 750; }
</style>
@@ -1,74 +1,72 @@
<!-- SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite. -->
<!-- Responsibility: narrow status toolbar Source/File dropdowns, search, status filter, info/settings/create/reindex actions. -->
<script setup lang="ts">
import type { MdtodoFileRecord, ProjectSource } from "@/api";
import ViewModeSwitch from "@/components/console/ViewModeSwitch.vue";
import { fileDisplayName } from "@/composables/mdtodo/useMdtodoDisplay";
import type { MdtodoViewMode } from "@/composables/mdtodo/useMdtodoViewState";
defineProps<{
sources: ProjectSource[];
selectedSourceId: string | null;
files: MdtodoFileRecord[];
selectedFileRef: string | null;
fileLoading: boolean;
reindexLoading: boolean;
createDisabled: boolean;
}>();
const emit = defineEmits<{
"update:selectedSourceId": [value: string | null];
"update:selectedFileRef": [value: string];
openSourceConfig: [];
reindex: [];
openInfo: [];
openCreateTask: [];
}>();
function onSourceSelect(event: Event): void {
emit("update:selectedSourceId", (event.target as HTMLSelectElement).value || null);
}
function onFileSelect(event: Event): void {
emit("update:selectedFileRef", (event.target as HTMLSelectElement).value);
}
defineProps<{ sources: ProjectSource[]; sourceId: string | null; files: MdtodoFileRecord[]; fileRef: string | null; view: MdtodoViewMode; busy: boolean; createDisabled: boolean; reindexDisabled: boolean }>();
const emit = defineEmits<{ selectSource: [value: string]; selectFile: [value: string]; selectView: [value: MdtodoViewMode]; create: []; configure: []; reindex: []; info: [] }>();
function selectView(value: string): void { emit("selectView", value === "queue" ? "queue" : "workspace"); }
</script>
<template>
<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="fileLoading || !files.length" @change="onFileSelect">
<option v-for="file in files" :key="file.fileRef" :value="file.fileRef">{{ fileDisplayName(file) }}</option>
</select>
</label>
<div class="toolbar-actions">
<button class="btn btn-secondary toolbar-create" type="button" data-testid="mdtodo-new-task-open" :disabled="createDisabled" :title="createDisabled ? '当前任务正文不可读,Node 恢复后才能写入' : undefined" @click="emit('openCreateTask')">+ 任务</button>
<button class="icon-button" type="button" data-testid="mdtodo-source-config-open" aria-label="配置 Source" @click="emit('openSourceConfig')"></button>
<button class="icon-button" type="button" data-testid="mdtodo-source-reindex" aria-label="重建索引" :disabled="!selectedSourceId || reindexLoading" @click="emit('reindex')"></button>
<button class="icon-button" type="button" data-testid="mdtodo-info-open" aria-label="查看摘要" @click="emit('openInfo')"></button>
<header class="mdtodo-command" data-testid="mdtodo-toolbar" aria-label="MDTODO 命令栏">
<div class="mdtodo-path">
<label>
Source
<select data-testid="mdtodo-source-select" :value="sourceId || ''" @change="emit('selectSource', ($event.target as HTMLSelectElement).value)">
<option v-for="item in sources" :key="item.sourceId" :value="item.sourceId">{{ item.displayName || item.sourceId }}</option>
</select>
</label>
<span aria-hidden="true">/</span>
<label>
File
<select data-testid="mdtodo-file-select" :value="fileRef || ''" :disabled="busy" @change="emit('selectFile', ($event.target as HTMLSelectElement).value)">
<option v-for="item in files" :key="item.fileRef" :value="item.fileRef">{{ fileDisplayName(item) }}</option>
</select>
</label>
</div>
</section>
<ViewModeSwitch
:model-value="view"
:options="[{ value: 'workspace', label: '工作区', icon: 'mdtodo' }, { value: 'queue', label: '队列', icon: 'queue' }]"
@update:model-value="selectView"
/>
<div class="mdtodo-actions">
<button type="button" data-testid="mdtodo-new-task-open" :disabled="createDisabled" @click="emit('create')">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 3v14M3 10h14" /></svg>新建任务
</button>
<button type="button" data-testid="mdtodo-source-reindex" :disabled="busy || reindexDisabled" aria-label="重建索引" @click="emit('reindex')">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M16 7a6 6 0 1 0 .4 5M16 3v4h-4" /></svg>
</button>
<button type="button" data-testid="mdtodo-source-config-open" aria-label="配置 Source" @click="emit('configure')">
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="3" /><path d="M10 2v3m0 10v3M2 10h3m10 0h3M4.3 4.3l2.1 2.1m7.2 7.2 2.1 2.1m0-11.4-2.1 2.1m-7.2 7.2-2.1 2.1" />
</svg>
</button>
<button type="button" data-testid="mdtodo-info-open" aria-label="查看信息" @click="emit('info')">
<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="7" /><path d="M10 9v5m0-8h.01" /></svg>
</button>
</div>
</header>
</template>
<style scoped>
.mdtodo-toolbar { display: grid; grid-template-columns: minmax(160px, 240px) minmax(260px, 1fr) auto; gap: 8px; align-items: center; border-bottom: 1px solid #d8e1e7; background: #ffffff; padding: 7px 10px; }
.toolbar-field { display: grid; min-width: 0; grid-template-columns: auto minmax(0, 1fr); gap: 7px; align-items: center; }
.toolbar-field span { color: #5d6a73; font-size: 10px; font-weight: 850; letter-spacing: 0; text-transform: uppercase; }
.toolbar-field select { width: 100%; min-width: 0; height: 32px; border: 1px solid #cbd6dd; border-radius: 6px; background: #fff; color: #111827; padding: 0 9px; font: inherit; font-size: 13px; }
.toolbar-actions { display: flex; gap: 6px; justify-content: flex-end; align-items: center; }
.toolbar-create { min-height: 32px; border-color: #a7c2c0; color: #0f4f4a; }
.icon-button { display: inline-grid; width: 32px; height: 32px; place-items: center; border: 1px solid #cbd6dd; border-radius: 6px; background: #fff; color: #111827; font-weight: 850; }
.icon-button:disabled { opacity: 0.5; }
@media (max-width: 980px) {
.mdtodo-toolbar { grid-template-columns: 1fr 1fr auto; }
}
@media (max-width: 640px) {
.mdtodo-toolbar { grid-template-columns: 1fr; }
.toolbar-field { grid-template-columns: 1fr; gap: 4px; }
.mdtodo-command { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 10px; border-bottom: 1px solid #b8c7cf; background: #f7fafb; padding: 8px 10px; color: #20323b; }
.mdtodo-path, .mdtodo-actions { display: flex; align-items: center; gap: 6px; min-width: 0; }
.mdtodo-path label { display: flex; align-items: center; gap: 5px; min-width: 0; font-size: 11px; font-weight: 750; text-transform: uppercase; letter-spacing: .06em; }
.mdtodo-path select { min-width: 0; max-width: 260px; height: 30px; border: 1px solid #aebfc8; background: #fff; padding: 0 25px 0 8px; color: #172830; }
.mdtodo-command button { display: inline-flex; height: 30px; align-items: center; justify-content: center; gap: 5px; border: 1px solid #9fb2bc; }
.mdtodo-command button { background: #fff; padding: 0 9px; color: #20323b; font-size: 12px; font-weight: 700; }
.mdtodo-command button:disabled { opacity: .45; }
.mdtodo-command svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.mdtodo-actions button:not(:first-child) { width: 30px; padding: 0; }
@media (max-width: 960px) { .mdtodo-command { grid-template-columns: minmax(0, 1fr) auto; } .mdtodo-actions { grid-column: 2; } }
@media (max-width: 540px) {
.mdtodo-command { grid-template-columns: 1fr; }
.mdtodo-path { display: grid; grid-template-columns: 1fr; }
.mdtodo-path > span { display: none; }
.mdtodo-path label { display: grid; grid-template-columns: 52px minmax(0, 1fr); }
.mdtodo-path select { max-width: none; }
.mdtodo-actions { grid-column: auto; justify-content: flex-end; }
}
</style>
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import type { MdtodoTaskRecord } from "@/api";
import { taskParentRefs, treeKeyAction, visibleTasks } from "./mdtodoTaskTreeModel";
const tasks: MdtodoTaskRecord[] = [
{ taskRef: "root", taskId: "R1", depth: 0 },
{ taskRef: "child", taskId: "R1.1", parentTaskRef: "root", depth: 1 },
{ taskRef: "deep", taskId: "R1.1.1", parentTaskRef: "child", depth: 2 },
{ taskRef: "sibling", taskId: "R1.2", parentTaskRef: "root", depth: 1 }
];
describe("MDTODO task tree model", () => {
it("uses parentTaskRef as the authority chain", () => {
expect(taskParentRefs(tasks[2]!, tasks)).toEqual(["root", "child"]);
});
it("falls back to complete Rxx segmentation", () => {
const fallback = tasks.map((task) => ({ ...task, parentTaskRef: null }));
expect(taskParentRefs(fallback[2]!, fallback)).toEqual(["root", "child"]);
});
it("keeps only the selected deep-link path visible through a collapsed parent", () => {
expect(visibleTasks(tasks, new Set(["root"]), "deep").map((task) => task.taskRef)).toEqual(["root", "child", "deep"]);
expect(visibleTasks(tasks, new Set(["root"]), null).map((task) => task.taskRef)).toEqual(["root"]);
});
it("maps directional keys to tree actions", () => {
expect(treeKeyAction("ArrowDown", true, true)).toBe("next");
expect(treeKeyAction("ArrowUp", true, true)).toBe("previous");
expect(treeKeyAction("ArrowRight", false, true)).toBe("expand");
expect(treeKeyAction("ArrowLeft", true, true)).toBe("collapse");
expect(treeKeyAction("ArrowLeft", false, false)).toBe("parent");
});
});
@@ -0,0 +1,38 @@
import type { MdtodoTaskRecord } from "@/api";
export type TreeKeyAction = "previous" | "next" | "expand" | "collapse" | "parent" | "none";
export function taskParentRefs(task: MdtodoTaskRecord, tasks: MdtodoTaskRecord[]): string[] {
const byRef = new Map(tasks.map((item) => [item.taskRef, item]));
const parents: string[] = [];
const visited = new Set<string>();
let parentRef = task.parentTaskRef || null;
while (parentRef && !visited.has(parentRef)) {
visited.add(parentRef);
parents.unshift(parentRef);
parentRef = byRef.get(parentRef)?.parentTaskRef || null;
}
if (parents.length) return parents;
const taskId = task.taskId || task.rxxId || "";
const parts = taskId.split(".");
const ids = parts.slice(1).map((_, index) => parts.slice(0, index + 1).join("."));
return ids.map((id) => tasks.find((item) => (item.taskId || item.rxxId) === id)?.taskRef).filter((value): value is string => Boolean(value));
}
export function visibleTasks(tasks: MdtodoTaskRecord[], collapsed: Set<string>, selectedTaskRef: string | null): MdtodoTaskRecord[] {
const selected = tasks.find((task) => task.taskRef === selectedTaskRef);
const selectedPath = new Set(selected ? [...taskParentRefs(selected, tasks), selected.taskRef] : []);
return tasks.filter((task) => {
const hiddenByCollapsedParent = taskParentRefs(task, tasks).some((parentRef) => collapsed.has(parentRef));
return !hiddenByCollapsedParent || selectedPath.has(task.taskRef);
});
}
export function treeKeyAction(key: string, expanded: boolean, hasChildren: boolean): TreeKeyAction {
if (key === "ArrowUp") return "previous";
if (key === "ArrowDown") return "next";
if (key === "ArrowRight" && hasChildren && !expanded) return "expand";
if (key === "ArrowLeft" && hasChildren && expanded) return "collapse";
if (key === "ArrowLeft") return "parent";
return "none";
}
@@ -1,9 +1,7 @@
// SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite.
// Responsibility: shared display helpers and Rxx tree derivation (no business authority).
// Responsibility: presentation-only file, status, date and reference formatting.
import { computed } from "vue";
import type { ComputedRef } from "vue";
import type { MdtodoFileRecord, MdtodoTaskRecord } from "@/api";
import type { MdtodoFileRecord } from "@/api";
/** File dropdown shows the direct docs/MDTODO/ file name, not internal titles. */
export function fileDisplayName(file: MdtodoFileRecord): string {
@@ -37,50 +35,3 @@ export function shortRef(value?: string | null): string {
return value.length > 42 ? `${value.slice(0, 24)}...${value.slice(-10)}` : value;
}
/** Indent style for a task row based on its Rxx depth. */
export function taskIndent(task: MdtodoTaskRecord): Record<string, string> {
return { paddingInlineStart: `${12 + Math.max(0, task.depth ?? 0) * 22}px` };
}
/**
* Rxx task tree: R1 is a root, R1.1 is a child of R1, R1.1.1 is a child of R1.1.
* Hierarchy is derived from taskRef/rxxId prefixes, NOT from Markdown heading
* counts or checkbox indentation. Returns children-by-parent map and visible rows.
*/
export function useRxxTaskTree(
tasks: ComputedRef<MdtodoTaskRecord[]> | (() => MdtodoTaskRecord[]),
collapsedTaskRefs: () => Set<string>
) {
const taskList = computed(() => (typeof tasks === "function" ? (tasks as () => MdtodoTaskRecord[])() : tasks.value));
const childrenByParent = computed(() => {
const byRef = new Map(taskList.value.map((task) => [task.taskRef, task]));
const map = new Map<string, MdtodoTaskRecord[]>();
for (const task of taskList.value) {
const parent = task.parentTaskRef && byRef.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().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;
});
function hasChildren(task: MdtodoTaskRecord): boolean {
return (childrenByParent.value.get(task.taskRef) ?? []).length > 0;
}
return { childrenByParent, visibleTaskRows, hasChildren, taskIndent };
}
@@ -0,0 +1,27 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { deleteTask } = vi.hoisted(() => ({ deleteTask: vi.fn() }));
vi.mock("@/api", () => ({ projectManagementAPI: { deleteTask, updateTask: vi.fn(), createTask: vi.fn() } }));
import { useMdtodoTaskMutation } from "./useMdtodoTaskMutation";
describe("useMdtodoTaskMutation delete sentinel", () => {
beforeEach(() => deleteTask.mockReset());
it("returns the deleted taskRef only after a successful API response and refresh", async () => {
deleteTask.mockResolvedValue({ ok: true, data: {} });
const mutation = useMdtodoTaskMutation();
const refresh = vi.fn().mockResolvedValue(undefined);
await expect(mutation.deleteTask("task-ref", { expectedFingerprint: "sha256:test" }, refresh)).resolves.toBe("task-ref");
expect(refresh).toHaveBeenCalledOnce();
});
it("returns null and preserves a visible error on API failure", async () => {
deleteTask.mockResolvedValue({ ok: false, error: "conflict", status: 409 });
const mutation = useMdtodoTaskMutation();
const refresh = vi.fn();
await expect(mutation.deleteTask("task-ref", { expectedFingerprint: "stale" }, refresh)).resolves.toBeNull();
expect(refresh).not.toHaveBeenCalled();
expect(mutation.taskMutationError.value).toBe("conflict");
});
});
@@ -82,7 +82,7 @@ export function useMdtodoTaskMutation() {
input: MdtodoTaskMutationInput,
onAfterMutate: () => Promise<void>
): Promise<string | null> {
return runTaskMutation(() => projectManagementAPI.deleteTask(taskRef, input), undefined, "任务已删除", onAfterMutate);
return runTaskMutation(() => projectManagementAPI.deleteTask(taskRef, input), taskRef, "任务已删除", onAfterMutate);
}
return {
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
import { reactive } from "vue";
import { useMdtodoViewState } from "./useMdtodoViewState";
function setup(query: Record<string, string> = {}) {
const route = reactive({ name: "ProjectMdtodoTask", params: { sourceId: "source", fileRef: "file", taskId: "R1" }, query, fullPath: "/projects/mdtodo/source/file/R1" });
const router = {
resolve: vi.fn((navigation: { query: Record<string, unknown> }) => ({ fullPath: JSON.stringify(navigation.query) })),
push: vi.fn(),
replace: vi.fn()
};
return { route, router, state: useMdtodoViewState(router as never, route as never) };
}
describe("useMdtodoViewState", () => {
it("provides stable defaults", () => {
const { state } = setup();
expect(state.view.value).toBe("workspace");
expect(state.tab.value).toBe("document");
expect(state.search.value).toBe("");
expect(state.status.value).toBe("all");
});
it("pushes view and tab changes", async () => {
const { router, state } = setup();
await state.update({ view: "queue", tab: "tasks" });
expect(router.push).toHaveBeenCalledWith(expect.objectContaining({ query: { view: "queue", tab: "tasks" } }));
});
it("replaces text filters and clears default query values", async () => {
const { router, state } = setup({ view: "queue", tab: "tasks", q: "old", status: "done" });
await state.update({ view: "workspace", tab: "document", search: "", status: "all" }, true);
expect(router.replace).toHaveBeenCalledWith(expect.objectContaining({ query: {} }));
});
it("does not write duplicate history", async () => {
const { route, router, state } = setup({ view: "queue" });
route.fullPath = JSON.stringify({ view: "queue" });
await state.update({ view: "queue" });
expect(router.push).not.toHaveBeenCalled();
expect(router.replace).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,35 @@
import { computed } from "vue";
import type { RouteLocationNormalizedLoaded, Router } from "vue-router";
export type MdtodoViewMode = "workspace" | "queue";
export type MdtodoMobileTab = "tasks" | "document" | "report";
function queryValue(route: RouteLocationNormalizedLoaded, key: string): string {
const value = route.query[key];
return typeof value === "string" ? value : Array.isArray(value) ? value[0] || "" : "";
}
export function useMdtodoViewState(router: Router, route: RouteLocationNormalizedLoaded) {
const view = computed<MdtodoViewMode>(() => queryValue(route, "view") === "queue" ? "queue" : "workspace");
const tab = computed<MdtodoMobileTab>(() => {
const value = queryValue(route, "tab");
return value === "tasks" || value === "report" ? value : "document";
});
const search = computed(() => queryValue(route, "q"));
const status = computed(() => queryValue(route, "status") || "all");
async function update(next: { view?: MdtodoViewMode; tab?: MdtodoMobileTab; search?: string; status?: string }, replace = false): Promise<void> {
const query = { ...route.query };
if (next.view !== undefined) next.view === "workspace" ? delete query.view : query.view = next.view;
if (next.tab !== undefined) next.tab === "document" ? delete query.tab : query.tab = next.tab;
if (next.search !== undefined) next.search.trim() ? query.q = next.search.trim() : delete query.q;
if (next.status !== undefined) next.status === "all" ? delete query.status : query.status = next.status;
const navigation = { name: route.name || undefined, params: route.params, query };
const resolved = router.resolve(navigation);
if (resolved.fullPath === route.fullPath) return;
if (replace) await router.replace(navigation);
else await router.push(navigation);
}
return { view, tab, search, status, update };
}
@@ -0,0 +1,457 @@
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { providerProfilesAPI, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskRecord } from "@/api";
import type { ApiError, ErrorDiagnostic, ProviderProfileCatalogResponse } from "@/types";
import { fileDisplayName } from "@/composables/mdtodo/useMdtodoDisplay";
import { useMdtodoReportPreview } from "@/composables/mdtodo/useMdtodoReportPreview";
import { useMdtodoRouteSelection } from "@/composables/mdtodo/useMdtodoRouteSelection";
import { useMdtodoSource } from "@/composables/mdtodo/useMdtodoSource";
import { useMdtodoTaskData } from "@/composables/mdtodo/useMdtodoTaskData";
import { useMdtodoTaskMutation } from "@/composables/mdtodo/useMdtodoTaskMutation";
import { useMdtodoViewState } from "@/composables/mdtodo/useMdtodoViewState";
import { useMdtodoWorkbenchLaunch } from "@/composables/mdtodo/useMdtodoWorkbenchLaunch";
export type MdtodoRefreshMode = "blocking" | "persistent";
export function useMdtodoWorkspaceController() {
const router = useRouter();
const route = useRoute();
const selection = useMdtodoRouteSelection({ router, route });
const source = useMdtodoSource();
const taskData = useMdtodoTaskData();
const mutation = useMdtodoTaskMutation();
const report = useMdtodoReportPreview();
const launch = useMdtodoWorkbenchLaunch(router);
const viewState = useMdtodoViewState(router, route);
const loading = ref(false);
const error = ref<string | null>(null);
const apiError = ref<ApiError | null>(null);
const diagnostic = ref<ErrorDiagnostic | null>(null);
const selectedSourceId = ref<string | null>(null);
const selectedFileRef = ref<string | null>(null);
const selectedTaskRef = ref<string | null>(null);
const collapsedTaskRefs = ref(new Set<string>());
const taskPaneWidth = ref(25);
const queuePaneWidth = ref(40);
const taskPaneCollapsed = ref(false);
const reportPaneWidth = ref(48);
const reportPaneCollapsed = ref(false);
const refreshMode = ref<MdtodoRefreshMode>("blocking");
const showInfo = ref(false);
const showSourceConfig = ref(false);
const showTaskCreate = ref(false);
const editingTitle = ref(false);
const editingBody = ref(false);
const editTitle = ref("");
const editStatus = ref("open");
const editBody = ref("");
const newTaskTitle = ref("");
const newTaskBody = ref("");
const deleteConfirm = ref(false);
const providerProfile = ref("");
const providerOptions = ref<Array<{ value: string; label: string; configured?: boolean }>>([]);
const providerReady = ref(false);
const providerError = ref<string | null>(null);
let refreshSequence = 0;
let queryTimer: ReturnType<typeof setTimeout> | null = null;
const selectedSource = computed(() => source.sources.value.find((item) => item.sourceId === selectedSourceId.value) ?? null);
const selectedFile = computed(() => taskData.files.value.find((item) => item.fileRef === selectedFileRef.value) ?? null);
const selectedTask = computed(() => taskData.tasks.value.find((item) => item.taskRef === selectedTaskRef.value) ?? null);
const selectedTaskProjection = computed(() => selectedTaskRef.value ? taskData.taskByRef.value.get(selectedTaskRef.value) ?? null : null);
const fileTasks = computed(() => selectedFileRef.value ? taskData.tasks.value.filter((item) => item.fileRef === selectedFileRef.value) : taskData.tasks.value);
const currentTaskDetail = computed(() => taskData.taskDetail.value?.taskRef === selectedTaskRef.value ? taskData.taskDetail.value : null);
const taskSearch = computed({
get: () => viewState.search.value,
set: (value: string) => { void viewState.update({ search: value }, true); }
});
const taskStatus = computed({
get: () => viewState.status.value,
set: (value: string) => { void viewState.update({ status: value }, true); }
});
const filteredTasks = computed(() => {
const query = taskSearch.value.trim().toLowerCase();
return fileTasks.value.filter((task) => {
const statusMatches = taskStatus.value === "all" || (task.status || "unknown") === taskStatus.value;
const textMatches = !query || [task.taskId, task.title, task.taskRef]
.some((value) => String(value || "").toLowerCase().includes(query));
return statusMatches && textMatches;
});
});
const taskDetailPartial = computed(() => taskData.taskDetailAuthority.value?.state === "unavailable");
const mutationDisabled = computed(() => Boolean(selectedTask.value && (taskData.taskDetailLoading.value || !currentTaskDetail.value || taskDetailPartial.value)));
const createDisabledReason = computed(() => {
if (!selectedSourceId.value) return "请先选择 Source";
if (!selectedFileRef.value) return "请先选择 MDTODO 文件";
if (!selectedFile.value?.fingerprint) return "文件 fingerprint 不可用,重建索引后再创建任务";
if (selectedTask.value && (taskData.taskDetailLoading.value || !currentTaskDetail.value)) return "任务详情 authority 尚未确认,请稍候";
if (taskDetailPartial.value) return "当前详情来自离线投影,Node 恢复后才能创建任务";
return null;
});
const createDisabled = computed(() => Boolean(createDisabledReason.value));
const selectedTaskBody = computed(() => currentTaskDetail.value?.body ?? selectedTask.value?.bodyPreview ?? selectedTaskProjection.value?.bodyPreview ?? "");
const selectedTaskLinks = computed(() => {
const detailLinks = markdownReportLinks(currentTaskDetail.value?.links ?? []);
if (detailLinks.length) return detailLinks;
const projectedLinks = markdownReportLinks(selectedTask.value?.links ?? selectedTaskProjection.value?.links ?? []);
if (projectedLinks.length) {
return taskDetailPartial.value ? projectedLinks.map(unavailableReportLink) : projectedLinks;
}
const linkCount = Number(selectedTask.value?.linkCount ?? selectedTaskProjection.value?.linkCount ?? 0);
return selectedTask.value && linkCount > 0 ? [projectedReportLink(selectedTask.value, linkCount)] : [];
});
const selectedFileName = computed(() => selectedFile.value ? fileDisplayName(selectedFile.value) : selectedTask.value?.fileRef || "-");
const selectedTaskBodyHtml = computed(() => report.markdownToHtml(selectedTaskBody.value));
const reportPreviewHtml = computed(() => report.markdownToHtml(report.reportPreview.value?.content ?? ""));
const reportOpen = computed(() => Boolean(report.reportPreview.value));
const taskStatusCounts = computed(() => fileTasks.value.reduce<Record<string, number>>((counts, task) => {
const key = task.status || "unknown";
counts[key] = (counts[key] ?? 0) + 1;
return counts;
}, {}));
const providerBlocker = computed(() => {
if (!providerReady.value) return "模型通道校验中";
if (providerError.value) return providerError.value;
if (!providerProfile.value) return "模型通道未选择";
const option = providerOptions.value.find((item) => item.value === providerProfile.value);
return option?.configured === false ? `模型通道 ${providerProfile.value} 未配置` : null;
});
const launchBlocker = computed(() => launch.isLaunchEnabled(source.navigation.value) ? providerBlocker.value : "Workbench Launch capability unavailable");
const launchDisabled = computed(() => !selectedTask.value || Boolean(launchBlocker.value) || launch.launchLoading.value);
function currentQuery() {
return {
search: taskSearch.value.trim() || undefined,
status: taskStatus.value === "all" ? undefined : taskStatus.value
};
}
function setError(value: unknown): void {
const result = value && typeof value === "object" && "error" in value ? value as { error?: string | null; apiError?: ApiError | null; diagnostic?: ErrorDiagnostic | null; status?: number } : null;
error.value = result?.error || (value instanceof Error ? value.message : String(value || "MDTODO 加载失败"));
apiError.value = result?.apiError ?? null;
diagnostic.value = result?.diagnostic ?? null;
}
async function syncRoute(replace = false, reportLinkId = report.activeReportLink.value?.linkId ?? null): Promise<void> {
await selection.syncSelectionRoute({ sourceId: selectedSourceId.value, fileRef: selectedFileRef.value, taskId: selectedTask.value?.taskId ?? null, reportLinkId, replace });
}
async function loadWindow(sourceId: string, options: { fileRef?: string | null; taskId?: string | null; reportLinkId?: string | null; updateRoute?: boolean; mode?: MdtodoRefreshMode } = {}): Promise<boolean> {
const sequence = ++refreshSequence;
refreshMode.value = options.mode ?? "blocking";
taskData.taskLoading.value = true;
try {
await taskData.loadFiles(sourceId);
if (sequence !== refreshSequence) return false;
const requestedFileExists = options.fileRef && taskData.files.value.some((item) => item.fileRef === options.fileRef);
const currentFileExists = taskData.files.value.some((item) => item.fileRef === selectedFileRef.value);
selectedFileRef.value = requestedFileExists
? options.fileRef ?? null
: currentFileExists
? selectedFileRef.value
: taskData.files.value[0]?.fileRef ?? null;
const requestedTask = options.taskId !== undefined ? options.taskId : selectedTaskRef.value;
await taskData.loadTaskWindow(sourceId, selectedFileRef.value, requestedTask, currentQuery());
if (sequence !== refreshSequence) return false;
const preferred = taskData.resolvePreferredTask(requestedTask);
const unresolved = Boolean(requestedTask && !preferred);
if (!unresolved) selectedTaskRef.value = preferred?.taskRef ?? taskData.tasks.value[0]?.taskRef ?? null;
if (refreshMode.value === "blocking") collapsedTaskRefs.value = new Set();
if (options.reportLinkId && selectedTaskRef.value) {
await Promise.all([taskData.loadLinks(selectedTaskRef.value), taskData.loadTaskDetail(selectedTaskRef.value)]);
await report.openReportPreviewById(selectedTaskRef.value, selectedTaskLinks.value, options.reportLinkId);
} else if (options.updateRoute !== false && !unresolved) await syncRoute(true);
error.value = null;
apiError.value = null;
diagnostic.value = null;
return true;
} catch (value) {
if (sequence === refreshSequence) setError(value);
return false;
} finally {
if (sequence === refreshSequence) taskData.taskLoading.value = false;
}
}
async function loadPage(): Promise<void> {
loading.value = true;
error.value = null;
try {
const result = await source.loadNavigationAndSources();
const routeSourceId = selection.readSelection().sourceId;
selectedSourceId.value = routeSourceId && result.sources.some((item) => item.sourceId === routeSourceId) ? routeSourceId : result.sources[0]?.sourceId ?? null;
if (selectedSourceId.value) {
await loadWindow(selectedSourceId.value, { ...selection.routeSelectionOptions(true), mode: "blocking" });
}
} catch (value) {
setError(value);
} finally {
loading.value = false;
}
}
async function selectSource(sourceId: string): Promise<void> {
const previous = { sourceId: selectedSourceId.value, fileRef: selectedFileRef.value, taskRef: selectedTaskRef.value };
const workspace = snapshotTaskWorkspace();
selectedSourceId.value = sourceId || null;
if (selectedSourceId.value) {
const loaded = await loadWindow(selectedSourceId.value, { updateRoute: false, mode: "blocking" });
if (loaded) {
report.closeReportPreview();
await syncRoute(false);
}
else {
selectedSourceId.value = previous.sourceId;
selectedFileRef.value = previous.fileRef;
selectedTaskRef.value = previous.taskRef;
restoreTaskWorkspace(workspace);
}
}
}
async function selectFile(fileRef: string): Promise<void> {
const previous = { fileRef: selectedFileRef.value, taskRef: selectedTaskRef.value };
const workspace = snapshotTaskWorkspace();
if (selectedSourceId.value) {
const loaded = await loadWindow(selectedSourceId.value, { fileRef: fileRef || null, taskId: null, updateRoute: false, mode: "blocking" });
if (loaded) {
report.closeReportPreview();
await syncRoute(false);
}
else {
selectedFileRef.value = previous.fileRef;
selectedTaskRef.value = previous.taskRef;
restoreTaskWorkspace(workspace);
}
}
}
async function selectTask(taskRef: string): Promise<void> {
selectedTaskRef.value = taskRef;
report.closeReportPreview();
await syncRoute();
await viewState.update({ tab: "document" }, true);
}
async function applyRoute(): Promise<void> {
const next = selection.readSelection();
const sourceId = next.sourceId && source.sources.value.some((item) => item.sourceId === next.sourceId) ? next.sourceId : selectedSourceId.value;
if (!sourceId) return;
const sourceChanged = sourceId !== selectedSourceId.value;
const fileChanged = Boolean(next.fileRef && next.fileRef !== selectedFileRef.value);
selectedSourceId.value = sourceId;
if (sourceChanged || fileChanged || !taskData.tasks.value.length) {
await loadWindow(sourceId, { ...selection.routeSelectionOptions(false), mode: "blocking" });
return;
}
const task = next.taskId ? taskData.resolvePreferredTask(next.taskId) : selectedTask.value;
if (next.taskId && !task) {
await loadWindow(sourceId, { ...selection.routeSelectionOptions(false), mode: "persistent" });
return;
}
selectedTaskRef.value = task?.taskRef ?? selectedTaskRef.value;
if (next.linkId && selectedTaskRef.value) {
await Promise.all([taskData.loadLinks(selectedTaskRef.value), taskData.loadTaskDetail(selectedTaskRef.value)]);
await report.openReportPreviewById(selectedTaskRef.value, selectedTaskLinks.value, next.linkId);
} else report.closeReportPreview();
}
async function reloadWindow(): Promise<void> {
if (!selectedSourceId.value) return;
await loadWindow(selectedSourceId.value, { fileRef: selectedFileRef.value, taskId: selectedTaskRef.value, updateRoute: false, mode: taskData.tasks.value.length ? "persistent" : "blocking" });
if (selectedTaskRef.value) await Promise.all([taskData.loadTaskDetail(selectedTaskRef.value), taskData.loadLinks(selectedTaskRef.value)]);
}
function taskFingerprint(): string | undefined {
return selectedTask.value?.sourceFingerprint || selectedFile.value?.fingerprint || undefined;
}
function snapshotTaskWorkspace() {
return {
files: [...taskData.files.value],
tasks: [...taskData.tasks.value],
taskPage: taskData.taskPage.value,
taskDetail: taskData.taskDetail.value,
authority: taskData.taskDetailAuthority.value,
links: [...taskData.links.value]
};
}
function restoreTaskWorkspace(snapshot: ReturnType<typeof snapshotTaskWorkspace>): void {
taskData.files.value = snapshot.files;
taskData.tasks.value = snapshot.tasks;
taskData.taskPage.value = snapshot.taskPage;
taskData.taskDetail.value = snapshot.taskDetail;
taskData.taskDetailAuthority.value = snapshot.authority;
taskData.links.value = snapshot.links;
}
function rejectMutation(): boolean {
if (!mutationDisabled.value) return false;
mutation.taskMutationMessage.value = null;
mutation.taskMutationError.value = "当前仅有最近一次任务投影;Node 恢复并读到源 Markdown 后才能写入。";
return true;
}
function resetEditor(task: MdtodoTaskRecord | null): void {
editingTitle.value = false;
editingBody.value = false;
editTitle.value = task?.title || "";
editStatus.value = task?.status || "open";
const detail = taskData.taskDetail.value;
editBody.value = detail && detail.taskRef === task?.taskRef ? detail.body || "" : "";
deleteConfirm.value = false;
}
async function saveBasics(): Promise<void> {
if (rejectMutation() || !selectedTask.value) return;
const input: MdtodoTaskMutationInput = { expectedFingerprint: taskFingerprint() };
if (editTitle.value.trim() && editTitle.value.trim() !== selectedTask.value.title) input.title = editTitle.value.trim();
if (editStatus.value !== (selectedTask.value.status || "open")) input.status = editStatus.value;
if (!input.title && !input.status) mutation.taskMutationMessage.value = "任务标题和状态没有变化";
else if (await mutation.updateTask(selectedTask.value.taskRef, input, "任务已保存", reloadWindow)) editingTitle.value = false;
}
async function saveBody(): Promise<void> {
if (rejectMutation() || !selectedTask.value) return;
if (await mutation.updateTask(selectedTask.value.taskRef, { expectedFingerprint: taskFingerprint(), body: editBody.value }, "正文已保存", reloadWindow)) editingBody.value = false;
}
async function createTask(kind: "root" | "subtask" | "continue"): Promise<void> {
if (rejectMutation()) return;
const title = newTaskTitle.value.trim();
if (!title) { mutation.taskMutationError.value = "新任务标题不能为空"; return; }
const input: MdtodoTaskMutationInput = { sourceId: selectedSourceId.value || undefined, fileRef: selectedFileRef.value || undefined, expectedFingerprint: taskFingerprint(), title, body: newTaskBody.value };
if (kind === "subtask" && selectedTask.value) input.parentTaskRef = selectedTask.value.taskRef;
if (kind === "continue" && selectedTask.value) input.afterTaskRef = selectedTask.value.taskRef;
const nextTaskRef = await mutation.createTask(input, "任务已创建", reloadWindow);
if (nextTaskRef) {
report.closeReportPreview();
selectedTaskRef.value = nextTaskRef;
newTaskTitle.value = "";
newTaskBody.value = "";
showTaskCreate.value = false;
await syncRoute(false);
}
}
async function deleteTask(): Promise<void> {
if (rejectMutation() || !selectedTask.value) return;
if (!deleteConfirm.value) { deleteConfirm.value = true; mutation.taskMutationMessage.value = "再次点击确认删除当前任务及子任务"; return; }
const deletedTaskRef = selectedTask.value.taskRef;
const expectedFingerprint = taskFingerprint();
const fallbackTaskRef = taskData.tasks.value.find((task) => task.taskRef !== deletedTaskRef)?.taskRef ?? null;
const previousTaskRef = selectedTaskRef.value;
selectedTaskRef.value = null;
const reloadAfterDelete = async () => {
if (selectedSourceId.value) {
await loadWindow(selectedSourceId.value, { fileRef: selectedFileRef.value, taskId: null, updateRoute: false, mode: "persistent" });
}
};
const result = await mutation.deleteTask(deletedTaskRef, { expectedFingerprint }, reloadAfterDelete);
if (!result) {
selectedTaskRef.value = previousTaskRef;
return;
}
report.closeReportPreview();
deleteConfirm.value = false;
selectedTaskRef.value = taskData.tasks.value.find((task) => task.taskRef !== deletedTaskRef)?.taskRef ?? fallbackTaskRef;
await syncRoute(false, null);
}
async function openReport(link: MdtodoTaskLinkRecord): Promise<void> {
if (!selectedTaskRef.value || !await report.openReportPreview(selectedTaskRef.value, link)) return;
reportPaneCollapsed.value = false;
await syncRoute();
await viewState.update({ tab: "report" }, true);
}
async function closeReport(): Promise<void> {
report.closeReportPreview();
await syncRoute(false, null);
if (viewState.tab.value === "report") await viewState.update({ tab: "document" }, true);
}
async function launchWorkbench(): Promise<void> {
if (!selectedTask.value || launchDisabled.value) return;
const result = await launch.launchTask(selectedTask.value, selectedTaskBody.value, selectedTaskLinks.value, selectedFileName.value, source.navigation.value, providerProfile.value);
if (result) { await taskData.loadLinks(selectedTask.value.taskRef); await launch.navigateToWorkbench(result); }
}
function openSourceConfig(): void { source.openSourceForm(selectedSource.value); showSourceConfig.value = true; }
async function saveSource(): Promise<void> { const sourceId = await source.saveSourceConfig(); if (sourceId) { showSourceConfig.value = false; await selectSource(sourceId); } }
async function reindex(): Promise<void> { if (selectedSourceId.value && await source.reindexSource(selectedSourceId.value)) await reloadWindow(); }
async function loadMore(): Promise<void> { taskData.taskLoading.value = true; try { await taskData.loadMoreTaskWindow(); } catch (value) { setError(value); } finally { taskData.taskLoading.value = false; } }
function setProvider(value: string): void { providerProfile.value = value; const query = { ...route.query }; value ? query.provider = value : delete query.provider; void router.replace({ query }); }
async function loadProviders(): Promise<void> {
providerReady.value = false;
const response = await providerProfilesAPI.catalog();
providerReady.value = true;
if (!response.ok) {
providerError.value = "模型通道目录不可用";
return;
}
providerOptions.value = providerCatalogOptions(response.data);
const requested = typeof route.query.provider === "string" ? route.query.provider : "";
const requestedAvailable = providerOptions.value.some((item) => item.value === requested && item.configured !== false);
setProvider(requestedAvailable ? requested : providerOptions.value.find((item) => item.configured !== false)?.value || "");
}
watch(selectedTaskRef, (taskRef) => { void Promise.all([taskData.loadLinks(taskRef), taskData.loadTaskDetail(taskRef)]); });
watch(selectedTask, resetEditor, { immediate: true });
watch(taskData.taskDetail, (detail) => { if (detail?.taskRef === selectedTaskRef.value && !editingBody.value) editBody.value = detail.body || ""; });
watch([taskSearch, taskStatus], () => { if (queryTimer) clearTimeout(queryTimer); queryTimer = setTimeout(() => { void reloadWindow(); }, 250); });
watch(() => route.fullPath, () => { if (!loading.value && source.sources.value.length) void applyRoute(); });
onMounted(() => { void loadProviders(); void loadPage(); });
onUnmounted(() => { if (queryTimer) clearTimeout(queryTimer); });
return {
router, route, selection, source, taskData, mutation, report, launch, viewState,
loading, error, apiError, diagnostic,
selectedSourceId, selectedFileRef, selectedTaskRef, selectedSource, selectedFile, selectedTask,
collapsedTaskRefs, taskPaneWidth, queuePaneWidth, taskPaneCollapsed, reportPaneWidth, reportPaneCollapsed,
refreshMode, showInfo, showSourceConfig, showTaskCreate,
editingTitle, editingBody, editTitle, editStatus, editBody, newTaskTitle, newTaskBody, deleteConfirm,
providerProfile, providerOptions, providerReady, providerError,
fileTasks, filteredTasks, taskSearch, taskStatus, taskStatusCounts,
currentTaskDetail, taskDetailPartial, mutationDisabled, createDisabled, createDisabledReason,
selectedTaskBody, selectedTaskLinks, selectedFileName, selectedTaskBodyHtml,
reportPreviewHtml, reportOpen, launchBlocker, launchDisabled,
loadPage, selectSource, selectFile, selectTask, reloadWindow, loadMore,
saveBasics, saveBody, createTask, deleteTask,
openReport, closeReport, launchWorkbench, openSourceConfig, saveSource, reindex, setProvider, resetEditor
};
}
function providerCatalogOptions(payload: ProviderProfileCatalogResponse | null) {
return (payload?.items ?? []).map((item) => ({ value: item.profile, label: item.profile, configured: item.configured }));
}
function markdownReportLinks(links: MdtodoTaskLinkRecord[]): MdtodoTaskLinkRecord[] {
return links.filter((link) => link.kind === "markdown-report");
}
function projectedReportLink(task: MdtodoTaskRecord, linkCount: number): MdtodoTaskLinkRecord {
return {
linkId: `projected-report-unavailable-${task.taskRef}`,
label: linkCount === 1 ? `${task.taskId || task.rxxId} 报告` : `${task.taskId || task.rxxId} 报告 (${linkCount})`,
href: "",
kind: "markdown-report",
relativePath: null,
ordinal: 1,
availability: "projected-link-unavailable",
message: "任务投影确认存在报告链接;恢复 HWPOD 详情读取或重建索引后可打开。",
valuesRedacted: true
};
}
function unavailableReportLink(link: MdtodoTaskLinkRecord): MdtodoTaskLinkRecord {
return {
...link,
availability: "projected-link-unavailable",
message: "报告索引可用,但 HWPOD 源当前不可读。",
valuesRedacted: true
};
}
@@ -23,9 +23,6 @@ test.describe("project management pages", () => {
await expect(page.getByTestId("mdtodo-file-select")).toContainText("hwlab-v03-mdtodo-web-sample.md");
await expect(page.getByTestId("mdtodo-file-select")).toContainText("20260609_频率判断_用户反馈.md");
await expect(page.getByTestId("mdtodo-file-select")).not.toContainText("Task_Report");
await expect(page.locator(".mdtodo-summary-grid .metric-card")).toHaveCount(0);
await expect(page.locator(".mdtodo-source-panel")).toHaveCount(0);
await expect(page.locator(".mdtodo-file-panel")).toHaveCount(0);
const workspaceBottomGap = await page.getByTestId("project-management-mdtodo").evaluate((shell) => {
const workspace = shell.querySelector(".mdtodo-workspace");
if (!workspace) return Number.POSITIVE_INFINITY;
@@ -34,7 +31,7 @@ test.describe("project management pages", () => {
return Math.abs(shellRect.bottom - workspaceRect.bottom);
});
expect(workspaceBottomGap).toBeLessThanOrEqual(2);
const outlineRatio = await page.locator(".mdtodo-task-panel").evaluate((panel) => {
const outlineRatio = await page.locator(".task-index").evaluate((panel) => {
const workspace = panel.closest(".mdtodo-workspace");
if (!workspace) return 0;
return panel.getBoundingClientRect().width / workspace.getBoundingClientRect().width;
@@ -42,36 +39,33 @@ test.describe("project management pages", () => {
expect(outlineRatio).toBeGreaterThan(0.24);
expect(outlineRatio).toBeLessThan(0.36);
await expect(page.getByTestId("mdtodo-task-tree")).toContainText("实现项目根导航");
await expect(page.getByTestId("mdtodo-task-tree-facts")).toContainText("已载入");
const taskTreeBottomGap = await page.getByTestId("mdtodo-task-tree").evaluate((panel) => {
const facts = panel.querySelector('[data-testid="mdtodo-task-tree-facts"]');
if (!facts) return Number.POSITIVE_INFINITY;
return Math.max(0, panel.getBoundingClientRect().bottom - facts.getBoundingClientRect().bottom);
});
expect(taskTreeBottomGap).toBeLessThanOrEqual(12);
await expect(page.getByTestId("mdtodo-task-page-fact")).toContainText("已载入");
await expect(page.locator('[data-task-id="R1"]')).toBeVisible();
await expect(page.locator('[data-task-id="R1.1"]')).toBeVisible();
await expect(page.locator('[data-task-id="R1.1.1"]')).toBeVisible();
await expect(page.getByTestId("mdtodo-task-detail")).not.toContainText("mdtodo:hwlab-v03-mdtodo:file_plan:R1");
await expect(page.getByTestId("mdtodo-body-rendered")).toContainText("Root notes");
await expect(page.getByTestId("mdtodo-body-read")).toContainText("Root notes");
await page.getByTestId("mdtodo-report-link").click();
await expect(page.getByTestId("mdtodo-report-preview")).toContainText("Report body from fake server");
await expect(page).toHaveURL(/\/projects\/mdtodo\/sources\/hwlab-v03-mdtodo\/files\/file_plan\/tasks\/R1\/reports\/lnk_r11_report/u);
await page.goBack();
await expect(page).toHaveURL(/\/files\/file_plan\/tasks\/R1(?:\?|$)/u);
await page.goForward();
await expect(page.getByTestId("mdtodo-report-preview")).toContainText("Report body from fake server");
await page.getByTestId("mdtodo-report-fullscreen").click();
await expect(page.getByTestId("mdtodo-report-fullscreen-dialog")).toContainText("R1.1 implementation report");
await page.locator('[role="dialog"]').filter({ has: page.getByTestId("mdtodo-report-fullscreen-dialog") }).getByLabel("关闭", { exact: true }).click();
await page.getByTestId("mdtodo-info-open").click();
await expect(page.getByTestId("mdtodo-info-popover")).toContainText("Source");
await expect(page.getByTestId("mdtodo-info-popover")).toContainText("File Tasks");
await expect(page.getByTestId("mdtodo-info-popover")).toContainText("Tasks");
await expect(page.getByTestId("mdtodo-info-popover")).toContainText("TaskRef");
await expect(page.getByTestId("mdtodo-info-popover")).toContainText("Task / Window");
await expect(page.getByTestId("mdtodo-info-popover")).toContainText("Task Ref");
await expect(page.getByTestId("mdtodo-info-popover")).toContainText("mdtodo:hwlab-v03-mdtodo:file_plan:R1");
await page.getByRole("dialog", { name: "MDTODO 摘要" }).getByLabel("关闭", { exact: true }).click();
await page.getByRole("dialog", { name: "MDTODO 信息" }).getByLabel("关闭", { exact: true }).click();
await page.getByTestId("mdtodo-file-select").selectOption("file_feedback");
await expect(page).toHaveURL(/\/projects\/mdtodo\/sources\/hwlab-v03-mdtodo\/files\/file_feedback\/tasks\/R1/u);
await expect(page.getByTestId("mdtodo-title-read")).toContainText("这是用户的反馈");
await expect(page.getByTestId("mdtodo-body-rendered")).toContainText("sys_trip");
await expect(page.getByTestId("mdtodo-body-read")).toContainText("sys_trip");
await page.getByTestId("mdtodo-report-link").click();
await expect(page.getByTestId("mdtodo-report-preview")).toContainText("int_trip");
await expect(page.getByTestId("mdtodo-report-preview")).toContainText("sys_trip");
@@ -89,44 +83,45 @@ test.describe("project management pages", () => {
await expect(page.getByTestId("mdtodo-source-message")).toContainText("Probe ok");
await page.getByTestId("mdtodo-source-reindex-dialog").click();
await expect(page.getByTestId("mdtodo-source-message")).toContainText("Reindex 3 files / 5 tasks");
await page.getByRole("dialog", { name: "Source 配置" }).getByRole("button", { name: "关闭" }).click();
await page.getByRole("dialog", { name: "MDTODO Source" }).getByLabel("关闭", { exact: true }).click();
await page.getByTestId("mdtodo-title-read").dblclick();
await page.getByTestId("mdtodo-edit-title").fill("实现项目根导航 - edited");
await page.getByTestId("mdtodo-edit-status").selectOption("in_progress");
await page.getByTestId("mdtodo-edit-save").click();
await expect(page.getByTestId("mdtodo-task-mutation-message")).toContainText("任务已保存");
await expect(page.getByTestId("mdtodo-task-notice")).toContainText("任务已保存");
await expect(page.getByTestId("mdtodo-task-detail")).toContainText("实现项目根导航 - edited");
await expect(page.locator('[data-task-id="R1"]')).toContainText("进行中");
await page.getByTestId("mdtodo-body-rendered").dblclick();
await page.getByTestId("mdtodo-edit-body").fill("Body saved from Playwright.");
await page.getByTestId("mdtodo-edit-body-save").click();
await expect(page.getByTestId("mdtodo-task-mutation-message")).toContainText("正文已保存");
await expect(page.locator(".task-create-box")).toHaveCount(0);
await page.getByTestId("mdtodo-body-read").dblclick();
await page.getByTestId("mdtodo-body-editor").fill("Body saved from Playwright.");
await page.getByTestId("mdtodo-body-save").click();
await expect(page.getByTestId("mdtodo-task-notice")).toContainText("正文已保存");
await page.getByTestId("mdtodo-new-task-open").click();
await expect(page.getByTestId("mdtodo-task-create-dialog")).toBeVisible();
await page.getByTestId("mdtodo-new-title").fill("Playwright child");
await page.getByTestId("mdtodo-new-body").fill("Child from E2E.");
await page.getByTestId("mdtodo-add-subtask").click();
await expect(page.getByTestId("mdtodo-task-mutation-message")).toContainText("任务已创建");
await expect(page.getByTestId("mdtodo-task-notice")).toContainText("任务已创建");
await expect(page).toHaveURL(/\/tasks\/R1\.2/u);
await expect(page.locator('[data-task-id="R1.2"]')).toContainText("Playwright child");
await page.getByTestId("mdtodo-new-task-open").click();
await page.getByTestId("mdtodo-new-title").fill("Playwright sibling");
await page.getByTestId("mdtodo-continue-task").click();
await expect(page.locator('[data-task-id="R1.3"]')).toContainText("Playwright sibling");
await page.getByTestId("mdtodo-delete-task").click();
await expect(page.getByTestId("mdtodo-task-mutation-message")).toContainText("再次点击确认删除");
await expect(page.getByTestId("mdtodo-task-notice")).toContainText("再次点击确认删除");
await page.getByTestId("mdtodo-delete-task").click();
await expect(page.getByTestId("mdtodo-task-mutation-message")).toContainText("任务已删除");
await expect(page.getByTestId("mdtodo-task-notice")).toContainText("任务已删除");
await expect(page).toHaveURL(/\/tasks\/R1(?:\?|$)/u);
await expect(page.locator('[data-task-id="R1.3"]')).toHaveCount(0);
await page.getByTestId("mdtodo-info-open").click();
await expect(page.getByTestId("mdtodo-info-popover")).toContainText("ses_project_seed");
await page.getByRole("dialog", { name: "MDTODO 摘要" }).getByLabel("关闭", { exact: true }).click();
await expect(page.getByTestId("mdtodo-workbench-launch")).toBeEnabled();
await page.getByRole("dialog", { name: "MDTODO 信息" }).getByLabel("关闭", { exact: true }).click();
await expect(page.getByTestId("mdtodo-launch-workbench")).toBeEnabled();
await expect(page.locator("iframe")).toHaveCount(0);
await saveScreenshot(page, testInfo, "project-management-mdtodo");
const workbenchPagePromise = page.waitForEvent("popup");
await page.getByTestId("mdtodo-workbench-launch").click();
await page.getByTestId("mdtodo-launch-workbench").click();
const workbenchPage = await workbenchPagePromise;
await expect(workbenchPage).toHaveURL(/\/workbench\/sessions\/ses_project_launch_/u);
await expect(workbenchPage.locator("#workspace")).toBeVisible();
@@ -194,11 +189,11 @@ test.describe("project management pages", () => {
await expect(page).toHaveURL((url) => url.pathname.endsWith("/files/file_long/tasks/R205"));
await expect(page.getByTestId("mdtodo-title-read")).toContainText("Long tree task 205");
await expect(page.locator('[data-task-id="R205"]')).toBeVisible();
await expect(page.getByTestId("mdtodo-task-load-more")).toContainText("101 / 205");
await page.getByTestId("mdtodo-task-load-more").click();
await expect(page.getByTestId("mdtodo-task-load-more")).toContainText("201 / 205");
await page.getByTestId("mdtodo-task-load-more").click();
await expect(page.getByTestId("mdtodo-task-load-more")).toHaveCount(0);
await expect(page.getByTestId("mdtodo-task-page-fact")).toContainText("101 / 205");
await page.getByTestId("mdtodo-load-more").click();
await expect(page.getByTestId("mdtodo-task-page-fact")).toContainText("201 / 205");
await page.getByTestId("mdtodo-load-more").click();
await expect(page.getByTestId("mdtodo-load-more")).toHaveCount(0);
await expect(page.locator('[data-task-id="R204"]')).toBeVisible();
await expect(page).toHaveURL((url) => url.pathname.endsWith("/files/file_long/tasks/R205") && url.searchParams.get("provider") === "codex-api");
});
@@ -234,19 +229,17 @@ test.describe("project management pages", () => {
});
await page.goto("/projects/mdtodo/sources/hwlab-v03-mdtodo/files/file_plan/tasks/R1");
await expect(page.getByTestId("mdtodo-task-detail-notice")).toContainText("最近一次项目投影");
await expect(page.getByTestId("mdtodo-task-detail")).toContainText("offline projection");
await expect(page.getByTestId("mdtodo-title-read")).toBeDisabled();
await expect(page.getByTestId("mdtodo-edit-status")).toBeDisabled();
await expect(page.getByTestId("mdtodo-status-save")).toBeDisabled();
await expect(page.getByTestId("mdtodo-delete-task")).toBeDisabled();
await expect(page.getByTestId("mdtodo-new-task-open")).toBeDisabled();
await expect(page.getByTestId("mdtodo-tree-new-task-open")).toBeDisabled();
await page.getByTestId("mdtodo-body-rendered").dblclick({ force: true });
await expect(page.getByTestId("mdtodo-edit-body")).toHaveCount(0);
await page.getByTestId("mdtodo-body-read").dblclick({ force: true });
await expect(page.getByTestId("mdtodo-body-editor")).toHaveCount(0);
await page.getByTestId("mdtodo-report-link").click();
await expect(page.getByTestId("mdtodo-report-preview")).toContainText("报告索引待刷新");
await expect(page.getByTestId("mdtodo-report-link")).toBeDisabled();
await expect(page.getByTestId("mdtodo-task-detail")).toContainText(/|Node /u);
expect(reportPreviewRequests).toBe(0);
await expect(page.getByTestId("mdtodo-workbench-launch")).toBeEnabled();
await expect(page.getByTestId("mdtodo-launch-workbench")).toBeEnabled();
});
});