refactor(web): 重写 MDTODO 三栏工作台
This commit is contained in:
@@ -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 authority;Probe 与 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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user