feat: refine mdtodo project workspace

This commit is contained in:
root
2026-06-26 14:32:51 +00:00
parent 3d34f721ea
commit 59180fa498
4 changed files with 370 additions and 99 deletions
@@ -382,7 +382,8 @@ function projectManagementSourceFromBody(body: JsonRecord, base: JsonRecord): Js
function projectManagementFiles(): JsonRecord[] {
return [
{ sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", projectId: "project_hwlab_v03", relativePath: "docs/MDTODO/hwlab-v03-mdtodo-web-sample.md", title: "项目管理任务", taskCount: 3, parseError: null, revision: 3, fingerprint: "sha256:e2e-plan", updatedAt: "2026-06-25T09:00:00.000Z" },
{ sourceId: "hwlab-v03-mdtodo", fileRef: "file_rollout", projectId: "project_hwlab_v03", relativePath: "docs/MDTODO/D601-v03-rollout.md", title: "D601 发布任务", taskCount: 1, parseError: null, revision: 1, fingerprint: "sha256:e2e-rollout", updatedAt: "2026-06-25T09:00:00.000Z" }
{ sourceId: "hwlab-v03-mdtodo", fileRef: "file_rollout", projectId: "project_hwlab_v03", relativePath: "docs/MDTODO/D601-v03-rollout.md", title: "D601 发布任务", taskCount: 1, parseError: null, revision: 1, fingerprint: "sha256:e2e-rollout", updatedAt: "2026-06-25T09:00:00.000Z" },
{ sourceId: "hwlab-v03-mdtodo", fileRef: "file_feedback", projectId: "project_hwlab_v03", relativePath: "docs/MDTODO/20260609_频率判断_用户反馈.md", title: "这是用户的反馈,你分析原因:", taskCount: 1, parseError: null, revision: 1, fingerprint: "sha256:e2e-feedback", updatedAt: "2026-06-26T13:00:00.000Z" }
];
}
@@ -397,11 +398,13 @@ function projectManagementSeedTasks(links: JsonRecord[] = []): JsonRecord[] {
const r11 = "mdtodo:hwlab-v03-mdtodo:file_plan:R1.1";
const r111 = "mdtodo:hwlab-v03-mdtodo:file_plan:R1.1.1";
const r2 = "mdtodo:hwlab-v03-mdtodo:file_rollout:R2";
const feedbackR1 = "mdtodo:hwlab-v03-mdtodo:file_feedback:R1";
return [
{ taskRef: r1, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "R1", title: "实现项目根导航", status: "done", parentTaskRef: null, depth: 0, lineNumber: 4, ordinal: 1, linkCount: linkCount(r1), sourceFingerprint: "sha256:e2e-plan", bodyPreview: "Root notes", body: "Root notes\n\n参考报告 [R1.1](./20260110_LOG/R1.1_log_implementation.md)", updatedAt: "2026-06-25T09:00:00.000Z" },
{ taskRef: r11, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "R1.1", title: "展示 MDTODO 任务详情", status: "open", parentTaskRef: r1, depth: 1, lineNumber: 8, ordinal: 2, linkCount: linkCount(r11), sourceFingerprint: "sha256:e2e-plan", bodyPreview: "Detail body", body: "Detail body", updatedAt: "2026-06-25T09:00:00.000Z" },
{ taskRef: r111, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "R1.1.1", title: "Source 顶部控制", status: "in_progress", parentTaskRef: r11, depth: 2, lineNumber: 12, ordinal: 3, linkCount: linkCount(r111), sourceFingerprint: "sha256:e2e-plan", bodyPreview: "Toolbar body", body: "Toolbar body", updatedAt: "2026-06-25T09:00:00.000Z" },
{ taskRef: r2, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_rollout", taskId: "R2", title: "D601 v03 页面验收", status: "blocked", parentTaskRef: null, depth: 0, lineNumber: 3, ordinal: 4, linkCount: linkCount(r2), sourceFingerprint: "sha256:e2e-rollout", bodyPreview: "Rollout body", body: "Rollout body", updatedAt: "2026-06-25T09:00:00.000Z" }
{ taskRef: r2, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_rollout", taskId: "R2", title: "D601 v03 页面验收", status: "blocked", parentTaskRef: null, depth: 0, lineNumber: 3, ordinal: 4, linkCount: linkCount(r2), sourceFingerprint: "sha256:e2e-rollout", bodyPreview: "Rollout body", body: "Rollout body", updatedAt: "2026-06-25T09:00:00.000Z" },
{ taskRef: feedbackR1, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_feedback", taskId: "R1", title: "这是用户的反馈,你分析原因:", status: "open", parentTaskRef: null, depth: 0, lineNumber: 4, ordinal: 5, linkCount: linkCount(feedbackR1), sourceFingerprint: "sha256:e2e-feedback", bodyPreview: "我 reset sys_trip 后,返回 sys_trip=false,但万用表测量端子还是通的。", body: "我 reset sys_trip 后,返回的代码中 sys_trip 已经 false 了,但万用表测量端子还是通的。是因为 int_trip:true 吗?\n\n完成任务后将详细报告写入 [R1](details/20260609_频率判断_用户反馈/20260609_161845_Task_Report.md)。", updatedAt: "2026-06-26T13:00:00.000Z" }
];
}
@@ -445,7 +448,7 @@ function projectManagementReportPreview(response: ServerResponse, url: URL): voi
report: {
sourceId: task.sourceId,
relativePath: link.relativePath,
content: "# R1.1 implementation report\n\nReport body from fake server.",
content: reportContentForLink(link),
fingerprint: "sha256:e2e-report",
revision: "sha256:e2e-report",
byteCount: 56,
@@ -460,7 +463,19 @@ function projectManagementReportPreview(response: ServerResponse, url: URL): voi
}
function projectManagementTaskLinks(task: JsonRecord, body: string): JsonRecord[] {
if (String(task.taskId) !== "R1" || !body.includes("R1.1_log_implementation.md")) return [];
if (String(task.taskId) !== "R1") return [];
if (body.includes("20260609_161845_Task_Report.md")) {
return [{
linkId: "lnk_feedback_r1_report",
label: "R1",
href: "details/20260609_频率判断_用户反馈/20260609_161845_Task_Report.md",
kind: "markdown-report",
relativePath: "details/20260609_频率判断_用户反馈/20260609_161845_Task_Report.md",
ordinal: 1,
valuesRedacted: true
}];
}
if (!body.includes("R1.1_log_implementation.md")) return [];
return [{
linkId: "lnk_r11_report",
label: "R1.1",
@@ -472,6 +487,31 @@ function projectManagementTaskLinks(task: JsonRecord, body: string): JsonRecord[
}];
}
function reportContentForLink(link: JsonRecord): string {
if (String(link.relativePath ?? "").includes("20260609_161845_Task_Report.md")) {
return [
"# R1 用户反馈分析报告",
"",
"停机继电器仍然动作时,需要同时确认外部 sys_trip 和内部 int_trip 的输出投票。",
"",
"```json",
"{",
" \"result\": {",
" \"sys_reset\": true,",
" \"int_trip\": true,",
" \"sys_trip\": false,",
" \"sys_alarm\": false",
" },",
" \"id\": 222",
"}",
"```",
"",
"如果 `int_trip` 仍为 true,理论上停机继电器仍可能保持动作。"
].join("\n");
}
return "# R1.1 implementation report\n\nReport body from fake server.";
}
function projectManagementUpdateTask(response: ServerResponse, taskRef: string, body: JsonRecord): void {
const task = state.projectTasks.find((item) => item.taskRef === taskRef);
if (!task) return json(response, 404, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "task_not_found" }, valuesRedacted: true });
+3
View File
@@ -17,6 +17,9 @@ const routes: RouteRecordRaw[] = [
{ path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } },
{ path: "/projects", name: "Projects", component: ProjectsView, meta: { requiresAuth: true, navId: "project.overview", title: "项目", section: "project" } },
{ path: "/projects/mdtodo", name: "ProjectMdtodo", component: ProjectMdtodoView, meta: { requiresAuth: true, navId: "project.mdtodo", title: "MDTODO", section: "project" } },
{ path: "/projects/mdtodo/sources/:sourceId/files/:fileRef", name: "ProjectMdtodoFile", component: ProjectMdtodoView, meta: { requiresAuth: true, navId: "project.mdtodo", title: "MDTODO", section: "project" } },
{ path: "/projects/mdtodo/sources/:sourceId/files/:fileRef/tasks/:taskId", name: "ProjectMdtodoTask", component: ProjectMdtodoView, meta: { requiresAuth: true, navId: "project.mdtodo", title: "MDTODO", section: "project" } },
{ path: "/projects/mdtodo/sources/:sourceId/files/:fileRef/tasks/:taskId/reports/:linkId", name: "ProjectMdtodoReport", component: ProjectMdtodoView, meta: { requiresAuth: true, navId: "project.mdtodo", title: "MDTODO", section: "project" } },
{ path: "/api-keys", name: "ApiKeys", component: () => import("@/views/user/ApiKeysView.vue"), meta: { requiresAuth: true, navId: "user.apiKeys", title: "API Keys", section: "user" } },
{ path: "/usage", name: "Usage", component: () => import("@/views/user/UsageView.vue"), meta: { requiresAuth: true, navId: "user.usage", title: "用量与性能", section: "user" } },
{ path: "/billing", name: "Billing", component: () => import("@/views/user/BillingView.vue"), meta: { requiresAuth: true, navId: "user.billing", title: "充值与订阅", section: "user" } },
@@ -4,15 +4,15 @@
<script setup lang="ts">
import DOMPurify from "dompurify";
import { marked } from "marked";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { agentAPI, projectManagementAPI, workbenchAPI, type MdtodoFileRecord, type MdtodoReportPreviewRecord, type MdtodoTaskDetailRecord, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "@/api";
import EmptyState from "@/components/common/EmptyState.vue";
import LoadingState from "@/components/common/LoadingState.vue";
import PageHeader from "@/components/common/PageHeader.vue";
import type { ApiError, ErrorDiagnostic } from "@/types";
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const taskLoading = ref(false);
const taskDetailLoading = ref(false);
@@ -26,6 +26,7 @@ const sourceReindexLoading = ref(false);
const showInfo = ref(false);
const showSourceConfig = ref(false);
const showReportFullscreen = ref(false);
const showTaskCreateDialog = ref(false);
const launchError = ref<string | null>(null);
const taskMutationError = ref<string | null>(null);
const taskMutationMessage = ref<string | null>(null);
@@ -48,9 +49,13 @@ const activeReportLink = ref<MdtodoTaskLinkRecord | null>(null);
const selectedSourceId = ref<string | null>(null);
const selectedFileRef = ref<string | null>(null);
const selectedTaskRef = ref<string | null>(null);
const applyingRouteSelection = ref(false);
const taskSearch = ref("");
const taskStatusFilter = ref("all");
const collapsedTaskRefs = ref<Set<string>>(new Set());
const taskPaneCollapsed = ref(false);
const taskPaneWidth = ref(30);
const taskPaneResize = ref<{ left: number; width: number } | null>(null);
const editingTitle = ref(false);
const editingBody = ref(false);
const editTitle = ref("");
@@ -122,18 +127,25 @@ const selectedTaskLinks = computed(() => taskDetail.value?.links ?? []);
const selectedTaskBodyHtml = computed(() => markdownToHtml(selectedTaskBody.value));
const reportPreviewHtml = computed(() => markdownToHtml(reportPreview.value?.content ?? ""));
const selectedFileName = computed(() => selectedFile.value ? fileDisplayName(selectedFile.value) : selectedTask.value?.fileRef || "-");
const workspaceStyle = computed(() => ({ "--mdtodo-task-pane-width": taskPaneCollapsed.value ? "44px" : `${taskPaneWidth.value}%` }));
onMounted(() => void loadPage());
onBeforeUnmount(() => stopTaskPaneResize());
watch(selectedSourceId, async (sourceId) => {
if (!sourceId || loading.value) return;
await loadFilesAndTasks(sourceId);
if (!sourceId || loading.value || applyingRouteSelection.value) return;
await loadFilesAndTasks(sourceId, { updateRoute: true });
});
watch(selectedTaskRef, async (taskRef) => {
await Promise.all([loadLinks(taskRef), loadTaskDetail(taskRef)]);
});
watch(() => route.fullPath, async () => {
if (loading.value || !sources.value.length) return;
await applyRouteSelection();
});
watch(selectedTask, (task) => resetTaskEditor(task), { immediate: true });
watch(taskDetail, (detail) => {
@@ -151,8 +163,13 @@ async function loadPage(): Promise<void> {
if (!sourceResponse.ok) throw sourceResponse;
navigation.value = navigationResponse.data;
sources.value = sourceResponse.data?.sources ?? [];
selectedSourceId.value = selectedSourceId.value && sources.value.some((source) => source.sourceId === selectedSourceId.value) ? selectedSourceId.value : sources.value[0]?.sourceId ?? null;
if (selectedSourceId.value) await loadFilesAndTasks(selectedSourceId.value);
const sourceFromRoute = routeParam("sourceId");
selectedSourceId.value = sourceFromRoute && sources.value.some((source) => source.sourceId === sourceFromRoute)
? sourceFromRoute
: selectedSourceId.value && sources.value.some((source) => source.sourceId === selectedSourceId.value)
? selectedSourceId.value
: sources.value[0]?.sourceId ?? null;
if (selectedSourceId.value) await loadFilesAndTasks(selectedSourceId.value, routeSelectionOptions(true));
else await loadLinks(null);
} catch (err) {
setError(err);
@@ -161,14 +178,24 @@ async function loadPage(): Promise<void> {
}
}
async function loadFilesAndTasks(sourceId: string): Promise<void> {
async function loadFilesAndTasks(sourceId: string, options: { fileRef?: string | null; taskId?: string | null; reportLinkId?: string | null; updateRoute?: boolean } = {}): Promise<void> {
taskLoading.value = true;
try {
const fileResponse = await projectManagementAPI.files(sourceId);
if (!fileResponse.ok) throw fileResponse;
files.value = fileResponse.data?.files ?? [];
selectedFileRef.value = selectedFileRef.value && files.value.some((file) => file.fileRef === selectedFileRef.value) ? selectedFileRef.value : files.value[0]?.fileRef ?? null;
await loadTaskWindow(sourceId, selectedFileRef.value, selectedTaskRef.value);
selectedFileRef.value = options.fileRef && files.value.some((file) => file.fileRef === options.fileRef)
? options.fileRef
: selectedFileRef.value && files.value.some((file) => file.fileRef === selectedFileRef.value)
? selectedFileRef.value
: files.value[0]?.fileRef ?? null;
await loadTaskWindow(sourceId, selectedFileRef.value, options.taskId ?? selectedTaskRef.value);
if (options.reportLinkId && selectedTaskRef.value) {
await Promise.all([loadLinks(selectedTaskRef.value), loadTaskDetail(selectedTaskRef.value)]);
await openReportPreviewById(options.reportLinkId, false);
} else if (options.updateRoute !== false) {
void syncSelectionRoute({ replace: true });
}
} catch (err) {
setError(err);
} finally {
@@ -221,10 +248,13 @@ function onFileSelect(event: Event): void {
async function selectFile(fileRef: string): Promise<void> {
selectedFileRef.value = fileRef || null;
reportPreview.value = null;
activeReportLink.value = null;
if (!selectedSourceId.value) return;
taskLoading.value = true;
try {
await loadTaskWindow(selectedSourceId.value, selectedFileRef.value, null);
void syncSelectionRoute({ replace: false });
} catch (err) {
setError(err);
} finally {
@@ -234,6 +264,9 @@ async function selectFile(fileRef: string): Promise<void> {
function selectTask(taskRef: string): void {
selectedTaskRef.value = taskRef;
reportPreview.value = null;
activeReportLink.value = null;
void syncSelectionRoute({ replace: false });
}
function toggleTask(task: MdtodoTaskRecord): void {
@@ -247,6 +280,36 @@ function hasChildren(task: MdtodoTaskRecord): boolean {
return (childrenByParent.value.get(task.taskRef) ?? []).length > 0;
}
function startTaskPaneResize(event: PointerEvent): void {
if (taskPaneCollapsed.value) return;
const workspace = (event.currentTarget as HTMLElement).closest(".mdtodo-workspace");
const rect = workspace?.getBoundingClientRect();
if (!rect || rect.width <= 0) return;
event.preventDefault();
taskPaneResize.value = { left: rect.left, width: rect.width };
window.addEventListener("pointermove", resizeTaskPane);
window.addEventListener("pointerup", stopTaskPaneResize, { once: true });
}
function resizeTaskPane(event: PointerEvent): void {
const state = taskPaneResize.value;
if (!state) return;
const percent = ((event.clientX - state.left) / state.width) * 100;
taskPaneWidth.value = Math.min(45, Math.max(22, Math.round(percent)));
}
function stopTaskPaneResize(): void {
taskPaneResize.value = null;
window.removeEventListener("pointermove", resizeTaskPane);
window.removeEventListener("pointerup", stopTaskPaneResize);
}
function openTaskCreateDialog(): void {
taskMutationError.value = null;
taskMutationMessage.value = null;
showTaskCreateDialog.value = true;
}
function openSourceConfig(): void {
const source = selectedSource.value;
sourceMessage.value = null;
@@ -362,6 +425,7 @@ async function createTask(kind: "root" | "subtask" | "continue"): Promise<void>
if (!taskMutationError.value) {
newTaskTitle.value = "";
newTaskBody.value = "";
showTaskCreateDialog.value = false;
}
}
@@ -408,8 +472,11 @@ async function openReportPreview(link: MdtodoTaskLinkRecord): Promise<void> {
try {
const response = await projectManagementAPI.reportPreview(taskRef, link.linkId);
if (!response.ok) throw response;
reportPreview.value = response.data?.report ?? null;
const report = response.data?.report ?? null;
if (!report?.content) throw new Error("报告内容未返回");
reportPreview.value = report;
activeReportLink.value = response.data?.link ?? link;
void syncSelectionRoute({ replace: false, reportLinkId: activeReportLink.value?.linkId ?? link.linkId });
} catch (err) {
reportError.value = projectApiError(err).error;
} finally {
@@ -417,6 +484,17 @@ async function openReportPreview(link: MdtodoTaskLinkRecord): Promise<void> {
}
}
async function openReportPreviewById(linkId: string, updateRoute = true): Promise<void> {
const target = selectedTaskLinks.value.find((link) => link.linkId === linkId || link.label === linkId || link.relativePath?.includes(linkId) || link.href?.includes(linkId));
if (!target) {
reportError.value = `报告链接未找到: ${linkId}`;
return;
}
await openReportPreview(target);
if (!updateRoute) return;
void syncSelectionRoute({ replace: true, reportLinkId: target.linkId });
}
async function runTaskMutation(action: () => ReturnType<typeof projectManagementAPI.updateTask>, preferredTaskRef: string | undefined, message: string): Promise<void> {
const sourceId = selectedSourceId.value;
if (!sourceId) return;
@@ -449,12 +527,56 @@ async function loadTaskWindow(sourceId: string, fileRef: string | null, preferre
if (!taskResponse.ok) throw taskResponse;
tasks.value = taskResponse.data?.tasks ?? [];
taskPage.value = taskResponse.data?.page ?? null;
const preferredTask = preferredTaskRef ? tasks.value.find((task) => task.taskRef === preferredTaskRef) : null;
const preferredTask = preferredTaskRef ? tasks.value.find((task) => task.taskRef === preferredTaskRef || task.taskId === preferredTaskRef || task.rxxId === preferredTaskRef) : null;
selectedTaskRef.value = preferredTask?.taskRef ?? tasks.value[0]?.taskRef ?? null;
collapsedTaskRefs.value = new Set();
if (!selectedTaskRef.value) await Promise.all([loadLinks(null), loadTaskDetail(null)]);
}
async function applyRouteSelection(): Promise<void> {
const sourceId = routeParam("sourceId");
const nextSourceId = sourceId && sources.value.some((source) => source.sourceId === sourceId) ? sourceId : selectedSourceId.value;
if (!nextSourceId) return;
applyingRouteSelection.value = true;
try {
selectedSourceId.value = nextSourceId;
await loadFilesAndTasks(nextSourceId, routeSelectionOptions(false));
} finally {
applyingRouteSelection.value = false;
}
}
function routeSelectionOptions(updateRoute: boolean): { fileRef?: string | null; taskId?: string | null; reportLinkId?: string | null; updateRoute: boolean } {
return {
fileRef: routeParam("fileRef"),
taskId: routeParam("taskId"),
reportLinkId: routeParam("linkId"),
updateRoute
};
}
async function syncSelectionRoute(options: { replace?: boolean; reportLinkId?: string | null } = {}): Promise<void> {
const sourceId = selectedSourceId.value;
const fileRef = selectedFileRef.value;
const task = selectedTask.value;
if (!sourceId || !fileRef) return;
const name = options.reportLinkId && task ? "ProjectMdtodoReport" : task ? "ProjectMdtodoTask" : "ProjectMdtodoFile";
const params: Record<string, string> = { sourceId, fileRef };
if (task?.taskId) params.taskId = task.taskId;
if (options.reportLinkId) params.linkId = options.reportLinkId;
const navigation = { name, params };
const resolved = router.resolve(navigation);
if (resolved.fullPath === route.fullPath) return;
if (options.replace) await router.replace(navigation);
else await router.push(navigation);
}
function routeParam(key: string): string | null {
const value = route.params[key];
const text = Array.isArray(value) ? value[0] : value;
return typeof text === "string" && text.trim() ? text.trim() : null;
}
function taskFingerprint(): string | undefined {
return selectedTask.value?.sourceFingerprint || selectedFile.value?.fingerprint || undefined;
}
@@ -495,20 +617,23 @@ async function launchSelectedTask(): Promise<void> {
launchContext
});
if (!response.ok) throw response;
if (response.data?.sessionId) {
const sessionId = response.data?.sessionId;
if (!sessionId) throw new Error("Workbench launch 未返回 sessionId");
if (sessionId) {
const chat = await agentAPI.sendAgentMessage({
message: prompt,
prompt,
sessionId: response.data.sessionId,
sessionId,
providerProfile: "codex",
projectId: task.projectId,
taskRef: task.taskRef,
launchContext
}, 120000);
if (!chat.ok) throw chat;
await waitWorkbenchSessionHasPrompt(sessionId);
}
await loadLinks(task.taskRef);
const target = response.data?.workbenchUrl || (response.data?.sessionId ? `/workbench/sessions/${encodeURIComponent(response.data.sessionId)}` : null);
const target = response.data?.workbenchUrl || `/workbench/sessions/${encodeURIComponent(sessionId)}`;
if (target) await router.push(target);
} catch (err) {
launchError.value = projectApiError(err).error;
@@ -517,6 +642,23 @@ async function launchSelectedTask(): Promise<void> {
}
}
async function waitWorkbenchSessionHasPrompt(sessionId: string): Promise<void> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await workbenchAPI.session(sessionId, 8000);
if (response.ok) {
const session = response.data?.session as { messageCount?: number; messages?: unknown[]; firstUserMessagePreview?: string | null } | undefined;
const count = Number(session?.messageCount ?? (Array.isArray(session?.messages) ? session.messages.length : 0));
if (count > 0 || String(session?.firstUserMessagePreview ?? "").trim()) return;
}
await sleep(800);
}
throw new Error("Workbench session 已创建但未投影 MDTODO prompt");
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function setError(err: unknown): void {
const context = projectApiError(err);
error.value = context.error;
@@ -597,7 +739,13 @@ function displayDate(value?: string | null): string {
<template>
<section class="route-stack mdtodo-page" data-testid="project-management-mdtodo">
<PageHeader eyebrow="Project Management" title="MDTODO" description="MDTODO source、文件、任务树和 Workbench link 的项目工作区。" />
<header class="mdtodo-compact-header">
<div>
<span>Project Management</span>
<h1>MDTODO</h1>
</div>
<p>{{ selectedFileName }}<small v-if="selectedTask"> / {{ selectedTask.taskId }}</small></p>
</header>
<LoadingState v-if="loading && !sources.length" label="加载 MDTODO source" />
<section v-else-if="error" class="data-panel mdtodo-error" data-testid="project-management-error">
<EmptyState title="MDTODO 加载失败" :description="error" :api-error="apiError" :diagnostic="diagnostic" action-label="重试" @action="loadPage" />
@@ -626,11 +774,15 @@ function displayDate(value?: string | null): string {
<p v-if="sourceMessage" class="source-message" data-testid="mdtodo-source-message">{{ sourceMessage }}</p>
<p v-if="sourceError" class="source-error" data-testid="mdtodo-source-error">{{ sourceError }}</p>
<section class="mdtodo-workspace">
<section class="data-panel mdtodo-task-panel" data-testid="mdtodo-task-tree">
<section class="mdtodo-workspace" :style="workspaceStyle" :data-tree-collapsed="taskPaneCollapsed">
<section class="data-panel mdtodo-task-panel" data-testid="mdtodo-task-tree" :data-collapsed="taskPaneCollapsed">
<header class="mdtodo-panel-header">
<div><h2>任务树</h2><p>{{ filteredTasks.length }} / {{ taskPage?.total ?? fileScopedTasks.length }} tasks<span v-if="taskPage?.hasMore"> · window {{ taskPage.limit }}</span></p></div>
<div class="task-tools">
<div><h2>{{ taskPaneCollapsed ? '纲' : '任务树' }}</h2><p v-if="!taskPaneCollapsed">{{ filteredTasks.length }} / {{ taskPage?.total ?? fileScopedTasks.length }} tasks<span v-if="taskPage?.hasMore"> · window {{ taskPage.limit }}</span></p></div>
<div class="task-pane-actions">
<button class="icon-button" type="button" data-testid="mdtodo-new-task-open" aria-label="新建任务" :disabled="!selectedFileRef || taskMutationLoading" @click="openTaskCreateDialog">+</button>
<button class="icon-button" type="button" data-testid="mdtodo-task-pane-toggle" :aria-label="taskPaneCollapsed ? '展开任务树' : '收起任务树'" @click="taskPaneCollapsed = !taskPaneCollapsed">{{ taskPaneCollapsed ? '' : '' }}</button>
</div>
<div v-if="!taskPaneCollapsed" class="task-tools">
<input v-model="taskSearch" data-testid="mdtodo-task-search" type="search" placeholder="R1 / title" aria-label="搜索任务" />
<select v-model="taskStatusFilter" data-testid="mdtodo-task-status-filter" aria-label="筛选状态">
<option value="all">全部</option>
@@ -641,6 +793,7 @@ function displayDate(value?: string | null): string {
</select>
</div>
</header>
<template v-if="!taskPaneCollapsed">
<LoadingState v-if="taskLoading" compact label="同步文件和任务" />
<EmptyState v-else-if="!visibleTaskRows.length" title="暂无任务" description="当前文件没有任务投影。" />
<div v-else class="task-tree" role="tree" aria-label="MDTODO task tree">
@@ -653,56 +806,65 @@ function displayDate(value?: string | null): string {
</button>
</div>
</div>
</template>
</section>
<div class="task-pane-resizer" data-testid="mdtodo-task-pane-resizer" role="separator" aria-orientation="vertical" :aria-valuenow="taskPaneWidth" @pointerdown="startTaskPaneResize" />
<aside class="data-panel mdtodo-detail-panel" data-testid="mdtodo-task-detail">
<header class="mdtodo-panel-header"><div><h2>任务详情</h2><p>{{ selectedTask ? statusLabel(selectedTask.status) : '未选择' }}</p></div></header>
<header class="mdtodo-detail-header">
<div class="task-title-block">
<span class="task-id-large">{{ selectedTask?.taskId || 'R?' }}</span>
<div v-if="selectedTask && editingTitle" class="inline-editor">
<input v-model="editTitle" data-testid="mdtodo-edit-title" :disabled="taskMutationLoading" @keyup.enter="saveTaskBasics" @keyup.esc="cancelTitleEdit" />
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-save" :disabled="taskMutationLoading" @click="saveTaskBasics">保存</button>
<button class="btn btn-secondary" type="button" :disabled="taskMutationLoading" @click="cancelTitleEdit">取消</button>
</div>
<button v-else-if="selectedTask" class="task-title-read" type="button" data-testid="mdtodo-title-read" @dblclick="beginTitleEdit">{{ selectedTask.title || selectedTask.taskId }}</button>
<strong v-else>未选择任务</strong>
</div>
<div v-if="selectedTask" class="detail-toolbar">
<select v-model="editStatus" data-testid="mdtodo-edit-status" :disabled="taskMutationLoading" aria-label="任务状态">
<option value="open">待办</option>
<option value="in_progress">进行中</option>
<option value="done">完成</option>
<option value="blocked">阻塞</option>
</select>
<button class="btn btn-secondary" type="button" data-testid="mdtodo-status-save" :disabled="taskMutationLoading" @click="saveTaskBasics">保存状态</button>
<button class="btn btn-primary" type="button" data-testid="mdtodo-workbench-launch" :disabled="launchButtonDisabled" :aria-disabled="launchButtonDisabled" @click="launchSelectedTask">
{{ launchLoading ? '启动中' : '在 Workbench 执行' }}
</button>
</div>
</header>
<EmptyState v-if="!selectedTask" title="未选择任务" description="选择任务后显示 taskRef、状态、文件和 Workbench link 摘要。" />
<template v-else>
<dl class="task-detail-list">
<div>
<dt>Title</dt>
<dd v-if="editingTitle" class="inline-editor">
<input v-model="editTitle" data-testid="mdtodo-edit-title" :disabled="taskMutationLoading" @keyup.enter="saveTaskBasics" @keyup.esc="cancelTitleEdit" />
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-save" :disabled="taskMutationLoading" @click="saveTaskBasics">保存</button>
<button class="btn btn-secondary" type="button" :disabled="taskMutationLoading" @click="cancelTitleEdit">取消</button>
</dd>
<dd v-else class="editable-value" data-testid="mdtodo-title-read" @dblclick="beginTitleEdit">{{ selectedTask.title || selectedTask.taskId }}</dd>
</div>
<div><dt>TaskRef</dt><dd><code>{{ selectedTask.taskRef }}</code></dd></div>
<div><dt>File</dt><dd><code>{{ selectedFileName }}</code></dd></div>
<div><dt>Updated</dt><dd>{{ displayDate(selectedTask.updatedAt) }}</dd></div>
<div>
<dt>Status</dt>
<dd class="status-inline">
<select v-model="editStatus" data-testid="mdtodo-edit-status" :disabled="taskMutationLoading">
<option value="open">待办</option>
<option value="in_progress">进行中</option>
<option value="done">完成</option>
<option value="blocked">阻塞</option>
</select>
<button class="btn btn-secondary" type="button" data-testid="mdtodo-status-save" :disabled="taskMutationLoading" @click="saveTaskBasics">保存状态</button>
</dd>
</div>
</dl>
<section class="task-editor" data-testid="mdtodo-task-editor">
<LoadingState v-if="taskDetailLoading" compact label="同步任务详情" />
<p v-if="taskDetailError" class="source-error" data-testid="mdtodo-task-detail-error">{{ taskDetailError }}</p>
<section class="task-body-section" data-testid="mdtodo-task-body">
<header><strong>正文</strong></header>
<div v-if="editingBody" class="inline-body-editor">
<textarea v-model="editBody" data-testid="mdtodo-edit-body" :disabled="taskMutationLoading" rows="8" />
<div class="editor-actions">
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-body-save" :disabled="taskMutationLoading" @click="saveTaskBody">保存正文</button>
<button class="btn btn-secondary" type="button" :disabled="taskMutationLoading" @click="cancelBodyEdit">取消</button>
<section class="task-detail-content">
<section class="task-editor" data-testid="mdtodo-task-editor">
<LoadingState v-if="taskDetailLoading" compact label="同步任务详情" />
<p v-if="taskDetailError" class="source-error" data-testid="mdtodo-task-detail-error">{{ taskDetailError }}</p>
<section class="task-body-section" data-testid="mdtodo-task-body">
<header><strong>正文</strong><small>双击编辑</small></header>
<div v-if="editingBody" class="inline-body-editor">
<textarea v-model="editBody" data-testid="mdtodo-edit-body" :disabled="taskMutationLoading" rows="14" />
<div class="editor-actions">
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-body-save" :disabled="taskMutationLoading" @click="saveTaskBody">保存正文</button>
<button class="btn btn-secondary" type="button" :disabled="taskMutationLoading" @click="cancelBodyEdit">取消</button>
</div>
</div>
<article v-else class="markdown-body task-body-rendered" data-testid="mdtodo-body-rendered" @dblclick="beginBodyEdit">
<div v-if="selectedTaskBodyHtml" v-html="selectedTaskBodyHtml" />
<p v-else class="empty-inline">暂无正文</p>
</article>
</section>
<div class="editor-actions danger-actions">
<button class="btn btn-secondary" type="button" data-testid="mdtodo-delete-task" :disabled="taskMutationLoading" @click="deleteSelectedTask">{{ deleteConfirm ? '确认删除' : '删除任务' }}</button>
<button v-if="deleteConfirm" class="btn btn-secondary" type="button" data-testid="mdtodo-delete-cancel" :disabled="taskMutationLoading" @click="deleteConfirm = false">取消</button>
</div>
<article v-else class="markdown-body task-body-rendered" data-testid="mdtodo-body-rendered" @dblclick="beginBodyEdit">
<div v-if="selectedTaskBodyHtml" v-html="selectedTaskBodyHtml" />
<p v-else class="empty-inline">暂无正文</p>
</article>
<p v-if="taskMutationMessage" class="source-message" data-testid="mdtodo-task-mutation-message">{{ taskMutationMessage }}</p>
<p v-if="taskMutationError" class="source-error" data-testid="mdtodo-task-mutation-error">{{ taskMutationError }}</p>
</section>
<section v-if="selectedTaskLinks.length || reportPreview || reportError" class="report-section" data-testid="mdtodo-report-section">
<aside class="report-section" data-testid="mdtodo-report-section">
<header><strong>报告</strong><span v-if="reportLoading">加载中</span></header>
<div v-if="selectedTaskLinks.length" class="report-link-list">
<button v-for="link in selectedTaskLinks" :key="link.linkId" class="report-link-button" type="button" data-testid="mdtodo-report-link" :disabled="link.kind !== 'markdown-report' || reportLoading" @click="openReportPreview(link)">
@@ -710,6 +872,7 @@ function displayDate(value?: string | null): string {
<small>{{ link.relativePath || link.kind }}</small>
</button>
</div>
<p v-else class="empty-inline">暂无报告链接</p>
<p v-if="reportError" class="source-error" data-testid="mdtodo-report-error">{{ reportError }}</p>
<article v-if="reportPreview" class="report-preview" data-testid="mdtodo-report-preview">
<header>
@@ -718,26 +881,8 @@ function displayDate(value?: string | null): string {
</header>
<div class="markdown-body" v-html="reportPreviewHtml" />
</article>
</section>
<div class="task-create-box">
<label><span>New task</span><input v-model="newTaskTitle" data-testid="mdtodo-new-title" :disabled="taskMutationLoading" placeholder="新任务标题" /></label>
<textarea v-model="newTaskBody" data-testid="mdtodo-new-body" :disabled="taskMutationLoading" rows="3" placeholder="新任务正文,可留空。" />
<div class="editor-actions">
<button class="btn btn-secondary" type="button" data-testid="mdtodo-add-root" :disabled="taskMutationLoading" @click="createTask('root')">新增根任务</button>
<button class="btn btn-secondary" type="button" data-testid="mdtodo-add-subtask" :disabled="taskMutationLoading" @click="createTask('subtask')">新增子任务</button>
<button class="btn btn-secondary" type="button" data-testid="mdtodo-continue-task" :disabled="taskMutationLoading" @click="createTask('continue')">延续同级</button>
</div>
</div>
<div class="editor-actions danger-actions">
<button class="btn btn-secondary" type="button" data-testid="mdtodo-delete-task" :disabled="taskMutationLoading" @click="deleteSelectedTask">{{ deleteConfirm ? '确认删除' : '删除任务' }}</button>
<button v-if="deleteConfirm" class="btn btn-secondary" type="button" data-testid="mdtodo-delete-cancel" :disabled="taskMutationLoading" @click="deleteConfirm = false">取消</button>
</div>
<p v-if="taskMutationMessage" class="source-message" data-testid="mdtodo-task-mutation-message">{{ taskMutationMessage }}</p>
<p v-if="taskMutationError" class="source-error" data-testid="mdtodo-task-mutation-error">{{ taskMutationError }}</p>
</aside>
</section>
<button class="btn btn-primary" type="button" data-testid="mdtodo-workbench-launch" :disabled="launchButtonDisabled" :aria-disabled="launchButtonDisabled" @click="launchSelectedTask">
{{ launchLoading ? '启动中' : '在 Workbench 执行' }}
</button>
<p class="launch-blocker" data-testid="mdtodo-workbench-launch-blocker">{{ workbenchLaunchEnabled ? 'Workbench Launch API ready' : 'Workbench Launch capability unavailable' }}</p>
<p v-if="launchError" class="launch-blocker" data-testid="mdtodo-workbench-launch-error">{{ launchError }}</p>
<section class="task-links" data-testid="mdtodo-workbench-link-summary">
@@ -756,14 +901,35 @@ function displayDate(value?: string | null): string {
<section class="mdtodo-dialog mdtodo-info-dialog">
<header><h2>MDTODO 摘要</h2><button class="icon-button" type="button" aria-label="关闭摘要" @click="showInfo = false">×</button></header>
<dl class="summary-list">
<div><dt>Source</dt><dd>{{ sources.length }} / {{ selectedSource?.displayName || '-' }}</dd></div>
<div><dt>Files</dt><dd>{{ files.length }} / {{ selectedFile?.relativePath || '-' }}</dd></div>
<div><dt>Source</dt><dd>{{ sources.length }} / {{ selectedSource?.displayName || selectedSourceId || '-' }}</dd></div>
<div><dt>Source ID</dt><dd><code>{{ selectedSourceId || '-' }}</code></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>Title</dt><dd>{{ selectedTask?.title || '-' }}</dd></div>
<div><dt>TaskRef</dt><dd><code>{{ selectedTask?.taskRef || '-' }}</code></dd></div>
<div><dt>Updated</dt><dd>{{ displayDate(selectedTask?.updatedAt) }}</dd></div>
<div><dt>Tasks</dt><dd>open {{ taskStatusCounts.open || 0 }} / progress {{ taskStatusCounts.in_progress || 0 }} / done {{ taskStatusCounts.done || 0 }}</dd></div>
<div><dt>Links</dt><dd>{{ links.length }} / {{ workbenchLaunchEnabled ? 'launch enabled' : 'launch unavailable' }}</dd></div>
</dl>
</section>
</div>
<div v-if="showTaskCreateDialog" class="modal-backdrop" data-testid="mdtodo-task-create-dialog" role="dialog" aria-modal="true" aria-label="新建 MDTODO 任务" @click.self="showTaskCreateDialog = false">
<section class="mdtodo-dialog task-create-dialog">
<header><h2>新建任务</h2><button class="icon-button" type="button" aria-label="关闭新建任务" @click="showTaskCreateDialog = false">×</button></header>
<div class="task-create-box">
<label><span>New task</span><input v-model="newTaskTitle" data-testid="mdtodo-new-title" :disabled="taskMutationLoading" placeholder="新任务标题" /></label>
<textarea v-model="newTaskBody" data-testid="mdtodo-new-body" :disabled="taskMutationLoading" rows="5" placeholder="新任务正文,可留空。" />
<div class="editor-actions">
<button class="btn btn-secondary" type="button" data-testid="mdtodo-add-root" :disabled="taskMutationLoading" @click="createTask('root')">新增根任务</button>
<button class="btn btn-secondary" type="button" data-testid="mdtodo-add-subtask" :disabled="taskMutationLoading || !selectedTask" @click="createTask('subtask')">新增子任务</button>
<button class="btn btn-secondary" type="button" data-testid="mdtodo-continue-task" :disabled="taskMutationLoading || !selectedTask" @click="createTask('continue')">延续同级</button>
</div>
<p v-if="taskMutationError" class="source-error" data-testid="mdtodo-task-mutation-error">{{ taskMutationError }}</p>
</div>
</section>
</div>
<div v-if="showReportFullscreen && reportPreview" class="modal-backdrop" data-testid="mdtodo-report-fullscreen-dialog" role="dialog" aria-modal="true" aria-label="报告预览" @click.self="showReportFullscreen = false">
<section class="mdtodo-dialog report-fullscreen-dialog">
<header><h2>{{ activeReportLink?.label || reportPreview.relativePath || 'Report' }}</h2><button class="icon-button" type="button" aria-label="关闭报告" @click="showReportFullscreen = false">×</button></header>
@@ -796,8 +962,13 @@ function displayDate(value?: string | null): string {
</template>
<style scoped>
.mdtodo-page { min-width: 0; }
.mdtodo-toolbar { display: grid; grid-template-columns: minmax(220px, 0.9fr) minmax(260px, 1.1fr) auto; gap: 10px; align-items: end; border: 1px solid #d8e2df; border-radius: 8px; background: #fbfdfb; padding: 10px; }
.mdtodo-page { display: grid; min-width: 0; gap: 10px; }
.mdtodo-compact-header { display: flex; min-width: 0; min-height: 44px; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid #d8e2df; padding: 0 2px 8px; }
.mdtodo-compact-header span { color: #64706b; font-size: 11px; font-weight: 800; text-transform: uppercase; }
.mdtodo-compact-header h1 { margin: 1px 0 0; color: #14211d; font-size: 20px; line-height: 1.1; }
.mdtodo-compact-header p { min-width: 0; margin: 0; overflow: hidden; color: #52615c; font-size: 12px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
.mdtodo-compact-header small { color: #0f766e; font: inherit; }
.mdtodo-toolbar { display: grid; grid-template-columns: minmax(220px, 0.9fr) minmax(260px, 1.1fr) auto; gap: 10px; align-items: end; border: 1px solid #d8e2df; border-radius: 8px; background: #fbfdfb; padding: 8px; }
.toolbar-field { display: grid; min-width: 0; gap: 5px; }
.toolbar-field span { color: #52615c; font-size: 11px; font-weight: 800; letter-spacing: 0; text-transform: uppercase; }
.toolbar-field select,
@@ -812,13 +983,19 @@ function displayDate(value?: string | null): string {
.source-error { 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; }
.mdtodo-workspace { display: grid; min-height: min(720px, calc(100dvh - 220px)); grid-template-columns: minmax(420px, 1.15fr) minmax(340px, 0.85fr); gap: 12px; }
.mdtodo-workspace { display: grid; min-height: min(760px, calc(100dvh - 158px)); grid-template-columns: minmax(44px, var(--mdtodo-task-pane-width, 30%)) 6px minmax(0, 1fr); gap: 8px; }
.task-pane-resizer { border-inline: 1px solid transparent; border-radius: 999px; background: linear-gradient(90deg, transparent 2px, #cbd8d2 2px, #cbd8d2 4px, transparent 4px); cursor: col-resize; }
.mdtodo-workspace[data-tree-collapsed="true"] { grid-template-columns: 44px 0 minmax(0, 1fr); }
.mdtodo-workspace[data-tree-collapsed="true"] .task-pane-resizer { pointer-events: none; opacity: 0; }
.mdtodo-task-panel,
.mdtodo-detail-panel,
.mdtodo-error { display: grid; min-width: 0; min-height: 0; align-content: start; gap: 12px; padding: 14px; overflow: auto; }
.mdtodo-error { display: grid; min-width: 0; min-height: 0; align-content: start; gap: 12px; padding: 12px; overflow: auto; }
.mdtodo-task-panel[data-collapsed="true"] { justify-items: center; padding: 8px 4px; overflow: hidden; }
.mdtodo-detail-panel { align-content: stretch; grid-template-rows: auto minmax(0, 1fr) auto auto; overflow: hidden; }
.mdtodo-panel-header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; }
.mdtodo-panel-header h2 { margin: 0; color: #14211d; font-size: 16px; line-height: 1.2; }
.mdtodo-panel-header p { margin: 3px 0 0; color: #64706b; font-size: 12px; }
.task-pane-actions { display: flex; gap: 6px; margin-left: auto; }
.task-tools { display: grid; width: min(360px, 48%); grid-template-columns: minmax(130px, 1fr) minmax(110px, 0.7fr); gap: 8px; }
.task-tree { display: grid; gap: 5px; }
.task-row-shell { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 4px; align-items: center; }
@@ -832,6 +1009,14 @@ function displayDate(value?: string | null): string {
.task-status[data-status="done"] { border-color: #86efac; background: #dcfce7; color: #166534; }
.task-status[data-status="in_progress"] { border-color: #fde68a; background: #fffbeb; color: #92400e; }
.task-status[data-status="blocked"] { border-color: #fed7aa; background: #fff7ed; color: #9a3412; }
.mdtodo-detail-header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid #e2ece8; padding-bottom: 8px; }
.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 #99f6e4; border-radius: 6px; background: #ecfdf5; color: #0f766e; padding: 5px 8px; font-size: 12px; font-weight: 850; }
.task-title-read { min-width: 0; overflow: hidden; border: 0; border-radius: 6px; background: transparent; color: #14211d; padding: 5px 6px; font: inherit; font-size: 17px; font-weight: 850; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.task-title-read:hover { background: #f4fbf8; }
.detail-toolbar { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.detail-toolbar select { min-width: 112px; height: 34px; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; padding: 0 8px; font: inherit; }
.task-detail-content { display: grid; min-width: 0; min-height: 0; grid-template-columns: minmax(0, 1fr) minmax(260px, 0.32fr); gap: 10px; }
.task-detail-list,
.summary-list { display: grid; gap: 8px; margin: 0; }
.task-detail-list div,
@@ -846,7 +1031,7 @@ function displayDate(value?: string | null): string {
.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 #cbd8d4; border-radius: 6px; color: #14211d; font: inherit; padding: 8px 9px; }
.status-inline { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; }
.task-editor { display: grid; gap: 10px; border: 1px solid #d8e2df; border-radius: 8px; background: #fbfefd; padding: 12px; }
.task-editor { display: grid; min-width: 0; min-height: 0; grid-template-rows: auto minmax(0, 1fr) auto auto auto; gap: 10px; border: 1px solid #d8e2df; border-radius: 8px; background: #fbfefd; padding: 12px; }
.editor-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(132px, 0.32fr); gap: 10px; }
.task-editor label { display: grid; min-width: 0; gap: 5px; color: #52615c; font-size: 12px; font-weight: 800; }
.task-editor input,
@@ -855,10 +1040,11 @@ function displayDate(value?: string | null): string {
.task-editor textarea { min-height: 72px; resize: vertical; line-height: 1.4; }
.editor-block { display: grid; gap: 5px; }
.task-body-section,
.report-section { display: grid; gap: 8px; border-bottom: 1px solid #e2ece8; padding-bottom: 10px; }
.report-section { display: grid; min-width: 0; min-height: 0; gap: 8px; }
.task-body-section header,
.report-section header,
.report-preview header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 10px; color: #14211d; font-size: 13px; }
.task-body-section header small { color: #64706b; font-size: 12px; }
.inline-body-editor { display: grid; gap: 8px; }
.markdown-body { min-width: 0; color: #14211d; font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
.markdown-body :deep(p),
@@ -866,18 +1052,20 @@ function displayDate(value?: string | null): string {
.markdown-body :deep(ol),
.markdown-body :deep(pre),
.markdown-body :deep(blockquote) { margin: 0 0 8px; }
.markdown-body :deep(pre) { overflow: auto; border-radius: 6px; background: #0f172a; color: #e5e7eb; padding: 10px; }
.markdown-body :deep(pre) { max-width: 100%; overflow: auto; border: 1px solid #d8e2df; border-radius: 6px; background: #f8faf9; color: #14211d; 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; }
.task-body-rendered { min-height: 92px; max-height: 240px; overflow: auto; border: 1px solid #d8e2df; border-radius: 6px; background: #fff; padding: 10px; }
.task-body-rendered { min-height: min(520px, calc(100dvh - 330px)); overflow: auto; border: 1px solid #d8e2df; border-radius: 6px; background: #fff; padding: 12px; }
.empty-inline { margin: 0; color: #64706b; }
.report-section { grid-template-rows: auto auto auto minmax(0, 1fr); border: 1px solid #d8e2df; border-radius: 8px; background: #fff; padding: 10px; overflow: hidden; }
.report-link-list { display: grid; gap: 6px; }
.report-link-button { display: grid; width: 100%; min-width: 0; gap: 3px; border: 1px solid #d8e2df; border-radius: 6px; background: #fff; color: #14211d; padding: 8px 10px; 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; }
.report-preview { display: grid; gap: 8px; border: 1px solid #d8e2df; border-radius: 6px; background: #fff; padding: 10px; }
.report-preview > .markdown-body { max-height: 300px; overflow: auto; }
.report-preview { display: grid; min-height: 0; gap: 8px; border-top: 1px solid #e2ece8; padding-top: 8px; }
.report-preview > .markdown-body { min-height: 0; overflow: auto; }
.task-create-box { display: grid; gap: 8px; border-top: 1px solid #e2ece8; padding-top: 10px; }
.editor-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.danger-actions { border-top: 1px solid #e2ece8; padding-top: 10px; }
@@ -892,6 +1080,8 @@ function displayDate(value?: string | null): string {
.mdtodo-dialog { display: grid; width: min(720px, calc(100vw - 32px)); gap: 14px; border: 1px solid #cbd8d2; border-radius: 8px; background: #fff; color: #14211d; padding: 16px; box-shadow: 0 24px 60px rgba(15, 23, 42, 0.22); }
.mdtodo-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.mdtodo-dialog h2 { margin: 0; font-size: 18px; }
.task-create-dialog { width: min(620px, calc(100vw - 32px)); }
.task-create-dialog 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; resize: vertical; }
.report-fullscreen-dialog { width: min(1120px, calc(100vw - 32px)); min-height: min(780px, calc(100dvh - 80px)); align-content: start; }
.report-fullscreen-body { max-height: calc(100dvh - 170px); overflow: auto; border: 1px solid #e2e8f0; border-radius: 6px; padding: 14px; }
.source-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
@@ -902,15 +1092,23 @@ function displayDate(value?: string | null): string {
@media (max-width: 980px) {
.mdtodo-toolbar,
.mdtodo-workspace { grid-template-columns: 1fr; }
.task-pane-resizer { display: none; }
.toolbar-actions { justify-content: flex-start; }
.task-tools { width: 100%; grid-template-columns: 1fr; }
.mdtodo-workspace { min-height: 0; }
.mdtodo-task-panel[data-collapsed="true"] { justify-items: stretch; }
.task-detail-content { grid-template-columns: 1fr; }
.report-section { max-height: none; }
}
@media (max-width: 640px) {
.source-form { grid-template-columns: 1fr; }
.editor-grid { grid-template-columns: 1fr; }
.inline-editor,
.status-inline { grid-template-columns: 1fr; }
.mdtodo-compact-header,
.mdtodo-detail-header { align-items: stretch; flex-direction: column; }
.detail-toolbar { justify-content: flex-start; }
.task-title-block { grid-template-columns: 1fr; }
.task-row { grid-template-columns: minmax(48px, auto) minmax(0, 1fr); }
.task-status { grid-column: 1 / -1; }
}
@@ -19,17 +19,27 @@ test.describe("project management pages", () => {
await expect(page.getByTestId("mdtodo-source-select")).toHaveValue("hwlab-v03-mdtodo");
await expect(page.getByTestId("mdtodo-file-select")).toHaveValue("file_plan");
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 outlineRatio = await page.locator(".mdtodo-task-panel").evaluate((panel) => {
const workspace = panel.closest(".mdtodo-workspace");
if (!workspace) return 0;
return panel.getBoundingClientRect().width / workspace.getBoundingClientRect().width;
});
expect(outlineRatio).toBeGreaterThan(0.24);
expect(outlineRatio).toBeLessThan(0.36);
await expect(page.getByTestId("mdtodo-task-tree")).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")).toContainText("mdtodo:hwlab-v03-mdtodo:file_plan:R1");
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 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.getByTestId("mdtodo-report-fullscreen").click();
await expect(page.getByTestId("mdtodo-report-fullscreen-dialog")).toContainText("R1.1 implementation report");
await page.getByLabel("关闭报告").click();
@@ -37,7 +47,23 @@ test.describe("project management pages", () => {
await expect(page.getByTestId("mdtodo-info-popover")).toContainText("Source");
await expect(page.getByTestId("mdtodo-info-popover")).toContainText("Files");
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("mdtodo:hwlab-v03-mdtodo:file_plan:R1");
await page.getByLabel("关闭摘要").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 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");
const reportPreBg = await page.locator('[data-testid="mdtodo-report-preview"] pre').first().evaluate((node) => getComputedStyle(node).backgroundColor);
expect(reportPreBg).not.toBe("rgb(15, 23, 42)");
await page.getByTestId("mdtodo-file-select").selectOption("file_plan");
await expect(page).toHaveURL(/\/projects\/mdtodo\/sources\/hwlab-v03-mdtodo\/files\/file_plan\/tasks\/R1/u);
await expect(page.getByTestId("mdtodo-title-read")).toContainText("实现项目根导航");
await page.getByTestId("mdtodo-source-config-open").click();
await expect(page.getByTestId("mdtodo-source-config-dialog")).toBeVisible();
await expect(page.getByTestId("mdtodo-source-form-hwpod")).toHaveValue("d601-f103-v2");
@@ -45,7 +71,7 @@ test.describe("project management pages", () => {
await page.getByTestId("mdtodo-source-probe").click();
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 2 files / 4 tasks");
await expect(page.getByTestId("mdtodo-source-message")).toContainText("Reindex 3 files / 5 tasks");
await page.getByLabel("关闭配置").click();
await page.getByTestId("mdtodo-title-read").dblclick();
await page.getByTestId("mdtodo-edit-title").fill("实现项目根导航 - edited");
@@ -58,11 +84,15 @@ test.describe("project management pages", () => {
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-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.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");