feat: restructure mdtodo project page

This commit is contained in:
lyon
2026-06-25 22:12:13 +08:00
parent cc7c5c2ecb
commit 315cfb280f
5 changed files with 401 additions and 110 deletions
@@ -294,6 +294,26 @@ async function projectManagementPayload(request: IncomingMessage, response: Serv
const links = state.projectLinks;
const tasks = projectManagementTasks(links);
if (path === "/v1/project-management/mdtodo/sources" && method === "POST") {
const body = await readJson(request);
return json(response, 201, projectManagementEnvelope({ source: projectManagementSourceFromBody(body, source) }));
}
const sourceMutationMatch = path.match(/^\/v1\/project-management\/mdtodo\/sources\/([^/]+)$/u);
if (sourceMutationMatch && method === "PATCH") {
const body = await readJson(request);
const sourceId = decodeURIComponent(sourceMutationMatch[1] ?? String(source.sourceId));
return json(response, 200, projectManagementEnvelope({ source: projectManagementSourceFromBody(body, { ...source, sourceId }) }));
}
const sourceProbeMatch = path.match(/^\/v1\/project-management\/mdtodo\/sources\/([^/]+)\/probe$/u);
if (sourceProbeMatch && method === "POST") {
const sourceId = decodeURIComponent(sourceProbeMatch[1] ?? String(source.sourceId));
return json(response, 200, projectManagementEnvelope({ probe: { ok: true, status: "ok", sourceId, sourceKind: "hwpod-workspace", entries: files.length, valuesRedacted: true } }));
}
const sourceReindexMatch = path.match(/^\/v1\/project-management\/mdtodo\/sources\/([^/]+)\/reindex$/u);
if (sourceReindexMatch && method === "POST") {
const sourceId = decodeURIComponent(sourceReindexMatch[1] ?? String(source.sourceId));
return json(response, 200, projectManagementEnvelope({ projection: { sourceId, sourceKind: "hwpod-workspace", rootRef: "docs/MDTODO/", documentCount: files.length, taskCount: tasks.length, projectedAt: "2026-06-25T09:05:00.000Z", valuesRedacted: true } }));
}
if (path === "/v1/project-management/workbench-links" && method === "POST") {
const body = await readJson(request);
const link = projectManagementLinkFromBody(body);
@@ -330,27 +350,49 @@ function projectManagementEnvelope(payload: JsonRecord): JsonRecord {
}
function projectManagementSource(): JsonRecord {
return { sourceId: "hwlab-v03-mdtodo", kind: "mdtodo-file-tree", displayName: "HWLAB MDTODO", projectId: "project_hwlab_v03", status: "active", rootRef: "[redacted]", valuesRedacted: true };
return { sourceId: "hwlab-v03-mdtodo", kind: "mdtodo-file-tree", sourceKind: "hwpod-workspace", displayName: "HWLAB v0.3 MDTODO", projectId: "project_hwlab_v03", status: "active", hwpodId: "d601-f103-v2", nodeId: "node-d601-f103-v2", workspaceRootRef: "", mdtodoRootRef: "docs/MDTODO/", maxFiles: 120, valuesRedacted: true };
}
function projectManagementSourceFromBody(body: JsonRecord, base: JsonRecord): JsonRecord {
return {
...base,
sourceId: String(body.sourceId ?? base.sourceId ?? "hwlab-v03-mdtodo"),
sourceKind: String(body.sourceKind ?? "hwpod-workspace"),
displayName: String(body.displayName ?? base.displayName ?? "HWLAB v0.3 MDTODO"),
projectId: String(body.projectId ?? base.projectId ?? "project_hwlab_v03"),
hwpodId: String(body.hwpodId ?? base.hwpodId ?? "d601-f103-v2"),
nodeId: String(body.nodeId ?? base.nodeId ?? "node-d601-f103-v2"),
workspaceRootRef: String(body.workspaceRootRef ?? base.workspaceRootRef ?? ""),
mdtodoRootRef: String(body.mdtodoRootRef ?? base.mdtodoRootRef ?? "docs/MDTODO/"),
maxFiles: positiveInteger(body.maxFiles, Number(base.maxFiles ?? 120)),
status: "active",
valuesRedacted: true
};
}
function projectManagementFiles(): JsonRecord[] {
return [
{ sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", projectId: "project_hwlab_v03", relativePath: "project-management/PJ2026-01/MDTODO.md", title: "项目管理任务", taskCount: 2, parseError: null, revision: 1, updatedAt: "2026-06-25T09:00:00.000Z" },
{ sourceId: "hwlab-v03-mdtodo", fileRef: "file_rollout", projectId: "project_hwlab_v03", relativePath: "ops/D601-v03-rollout.md", title: "D601 发布任务", taskCount: 1, parseError: null, revision: 1, updatedAt: "2026-06-25T09:00:00.000Z" }
{ 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" }
];
}
function projectManagementTasks(links: JsonRecord[] = []): JsonRecord[] {
const linkCount = (taskRef: string) => links.filter((link) => link.taskRef === taskRef).length;
const r1 = "mdtodo:hwlab-v03-mdtodo:file_plan:R1";
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";
return [
{ taskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:t1", projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "t1", title: "实现项目根导航", status: "done", parentTaskRef: null, depth: 0, lineNumber: 4, ordinal: 1, linkCount: linkCount("mdtodo:hwlab-v03-mdtodo:file_plan:t1"), updatedAt: "2026-06-25T09:00:00.000Z" },
{ taskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:t2", projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "t2", title: "展示 MDTODO 任务详情", status: "open", parentTaskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:t1", depth: 1, lineNumber: 5, ordinal: 2, linkCount: linkCount("mdtodo:hwlab-v03-mdtodo:file_plan:t2"), updatedAt: "2026-06-25T09:00:00.000Z" },
{ taskRef: "mdtodo:hwlab-v03-mdtodo:file_rollout:t1", projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_rollout", taskId: "t1", title: "D601 v03 页面验收", status: "blocked", parentTaskRef: null, depth: 0, lineNumber: 3, ordinal: 3, linkCount: linkCount("mdtodo:hwlab-v03-mdtodo:file_rollout:t1"), updatedAt: "2026-06-25T09:00:00.000Z" }
{ 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), 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), 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), 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), updatedAt: "2026-06-25T09:00:00.000Z" }
];
}
function projectManagementSeedLinks(): JsonRecord[] {
return [{ linkId: "lnk_project_task_root", projectId: "project_hwlab_v03", taskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:t1", sessionId: "ses_project_seed", traceId: "trc_project_seed", role: "reference", updatedAt: "2026-06-25T09:00:00.000Z" }];
return [{ linkId: "lnk_project_task_root", projectId: "project_hwlab_v03", taskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:R1", sessionId: "ses_project_seed", traceId: "trc_project_seed", role: "reference", updatedAt: "2026-06-25T09:00:00.000Z" }];
}
function projectManagementLinkFromBody(body: JsonRecord): JsonRecord {
+1 -1
View File
@@ -14,7 +14,7 @@ export { apiKeysAPI } from "./apiKeys";
export { usageAPI } from "./usage";
export { billingAPI } from "./billing";
export { systemAPI, type SkillUploadFileInput } from "./system";
export { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectWorkbenchLinkRecord } from "./projectManagement";
export { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement";
import { fetchJson } from "./client";
import { accessAPI } from "./access";
@@ -58,13 +58,31 @@ export interface ProjectRecord {
export interface ProjectSource {
sourceId: string;
kind?: string;
sourceKind?: string;
displayName?: string;
projectId?: string;
status?: string;
hwpodId?: string | null;
nodeId?: string | null;
workspaceRootRef?: string | null;
mdtodoRootRef?: string | null;
maxFiles?: number;
updatedAt?: string;
valuesRedacted?: boolean;
}
export interface ProjectSourceInput {
sourceId?: string;
sourceKind?: string;
displayName?: string;
projectId?: string;
hwpodId?: string;
nodeId?: string;
workspaceRootRef?: string;
mdtodoRootRef?: string;
maxFiles?: number;
}
export interface MdtodoFileRecord {
sourceId: string;
fileRef: string;
@@ -109,6 +127,9 @@ export interface ProjectWorkbenchLinkRecord {
export interface ProjectListResponse extends ProjectManagementEnvelope { projects?: ProjectRecord[] }
export interface ProjectSourceListResponse extends ProjectManagementEnvelope { sources?: ProjectSource[] }
export interface ProjectSourceMutationResponse extends ProjectManagementEnvelope { source?: ProjectSource }
export interface ProjectSourceProbeResponse extends ProjectManagementEnvelope { probe?: { ok?: boolean; status?: string; sourceId?: string; sourceKind?: string; entries?: number; valuesRedacted?: boolean } }
export interface ProjectSourceReindexResponse extends ProjectManagementEnvelope { projection?: ProjectProjectionSummary & { sourceKind?: string; rootRef?: string; valuesRedacted?: boolean } }
export interface MdtodoFileListResponse extends ProjectManagementEnvelope { files?: MdtodoFileRecord[] }
export interface MdtodoTaskListResponse extends ProjectManagementEnvelope { tasks?: MdtodoTaskRecord[] }
export interface ProjectWorkbenchLinkListResponse extends ProjectManagementEnvelope { links?: ProjectWorkbenchLinkRecord[] }
@@ -129,6 +150,10 @@ export const projectManagementAPI = {
navigation: (): Promise<ApiResult<ProjectNavigationResponse>> => fetchJson("/v1/project-management/navigation", { timeoutMs: 12000, timeoutName: "project navigation" }),
projects: (): Promise<ApiResult<ProjectListResponse>> => fetchJson("/v1/project-management/projects", { timeoutMs: 12000, timeoutName: "project list" }),
sources: (): Promise<ApiResult<ProjectSourceListResponse>> => fetchJson("/v1/project-management/mdtodo/sources", { timeoutMs: 12000, timeoutName: "project mdtodo sources" }),
createSource: (input: ProjectSourceInput): Promise<ApiResult<ProjectSourceMutationResponse>> => fetchJson("/v1/project-management/mdtodo/sources", { method: "POST", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo source create" }),
updateSource: (sourceId: string, input: ProjectSourceInput): Promise<ApiResult<ProjectSourceMutationResponse>> => fetchJson(`/v1/project-management/mdtodo/sources/${encodeURIComponent(sourceId)}`, { method: "PATCH", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo source update" }),
probeSource: (sourceId: string): Promise<ApiResult<ProjectSourceProbeResponse>> => fetchJson(`/v1/project-management/mdtodo/sources/${encodeURIComponent(sourceId)}/probe`, { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "project mdtodo source probe" }),
reindexSource: (sourceId: string): Promise<ApiResult<ProjectSourceReindexResponse>> => fetchJson(`/v1/project-management/mdtodo/sources/${encodeURIComponent(sourceId)}/reindex`, { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "project mdtodo source reindex" }),
files: (sourceId?: string | null): Promise<ApiResult<MdtodoFileListResponse>> => fetchJson(`/v1/project-management/mdtodo/files${queryString({ sourceId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo files" }),
tasks: (query: MdtodoTaskQuery = {}): Promise<ApiResult<MdtodoTaskListResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks${queryString({ sourceId: query.sourceId, fileRef: query.fileRef, projectId: query.projectId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo tasks" }),
workbenchLinks: (query: ProjectWorkbenchLinkQuery = {}): Promise<ApiResult<ProjectWorkbenchLinkListResponse>> => fetchJson(`/v1/project-management/workbench-links${queryString({ taskRef: query.taskRef, projectId: query.projectId, sessionId: query.sessionId })}`, { timeoutMs: 12000, timeoutName: "project workbench links" })
@@ -2,9 +2,9 @@
<!-- Responsibility: MDTODO project page consuming public ProjectTask DTOs and Workbench launch DTOs without Workbench or Markdown internals. -->
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { projectManagementAPI, workbenchAPI, type MdtodoFileRecord, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource, type ProjectWorkbenchLinkRecord } from "@/api";
import { projectManagementAPI, workbenchAPI, type MdtodoFileRecord, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "@/api";
import EmptyState from "@/components/common/EmptyState.vue";
import LoadingState from "@/components/common/LoadingState.vue";
import PageHeader from "@/components/common/PageHeader.vue";
@@ -15,7 +15,14 @@ const loading = ref(false);
const taskLoading = ref(false);
const linkLoading = ref(false);
const launchLoading = ref(false);
const sourceSaving = ref(false);
const sourceProbeLoading = ref(false);
const sourceReindexLoading = ref(false);
const showInfo = ref(false);
const showSourceConfig = ref(false);
const launchError = ref<string | null>(null);
const sourceMessage = ref<string | null>(null);
const sourceError = ref<string | null>(null);
const error = ref<string | null>(null);
const apiError = ref<ApiError | null>(null);
const diagnostic = ref<ErrorDiagnostic | null>(null);
@@ -27,18 +34,65 @@ const links = ref<ProjectWorkbenchLinkRecord[]>([]);
const selectedSourceId = ref<string | null>(null);
const selectedFileRef = ref<string | null>(null);
const selectedTaskRef = ref<string | null>(null);
const taskSearch = ref("");
const taskStatusFilter = ref("all");
const collapsedTaskRefs = ref<Set<string>>(new Set());
const sourceForm = reactive<ProjectSourceInput>({
sourceId: "d601-f103-v2-mdtodo",
sourceKind: "hwpod-workspace",
displayName: "D601 F103 MDTODO",
projectId: "project_hwlab_v03",
hwpodId: "d601-f103-v2",
nodeId: "node-d601-f103-v2",
workspaceRootRef: "",
mdtodoRootRef: "docs/MDTODO/",
maxFiles: 120
});
const selectedSource = computed(() => sources.value.find((source) => source.sourceId === selectedSourceId.value) ?? null);
const selectedFile = computed(() => files.value.find((file) => file.fileRef === selectedFileRef.value) ?? null);
const selectedTask = computed(() => tasks.value.find((task) => task.taskRef === selectedTaskRef.value) ?? null);
const filteredTasks = computed(() => selectedFileRef.value ? tasks.value.filter((task) => task.fileRef === selectedFileRef.value) : tasks.value);
const taskStatusCounts = computed(() => filteredTasks.value.reduce((acc, task) => {
const fileScopedTasks = computed(() => selectedFileRef.value ? tasks.value.filter((task) => task.fileRef === selectedFileRef.value) : tasks.value);
const filteredTasks = computed(() => {
const query = taskSearch.value.trim().toLowerCase();
return fileScopedTasks.value.filter((task) => {
const statusOk = taskStatusFilter.value === "all" || (task.status || "unknown") === taskStatusFilter.value;
if (!statusOk) return false;
if (!query) return true;
return [task.taskId, task.title, task.taskRef].some((value) => String(value ?? "").toLowerCase().includes(query));
});
});
const taskStatusCounts = computed(() => fileScopedTasks.value.reduce((acc, task) => {
const key = task.status || "unknown";
acc[key] = (acc[key] ?? 0) + 1;
return acc;
}, {} as Record<string, number>));
const taskByRef = computed(() => new Map(filteredTasks.value.map((task) => [task.taskRef, task])));
const childrenByParent = computed(() => {
const map = new Map<string, MdtodoTaskRecord[]>();
for (const task of filteredTasks.value) {
const parent = task.parentTaskRef && taskByRef.value.has(task.parentTaskRef) ? task.parentTaskRef : "root";
const list = map.get(parent) ?? [];
list.push(task);
map.set(parent, list);
}
for (const list of map.values()) list.sort((a, b) => (a.ordinal ?? 0) - (b.ordinal ?? 0) || String(a.taskId ?? "").localeCompare(String(b.taskId ?? "")));
return map;
});
const visibleTaskRows = computed(() => {
const rows: MdtodoTaskRecord[] = [];
const visit = (task: MdtodoTaskRecord) => {
rows.push(task);
if (collapsedTaskRefs.value.has(task.taskRef)) return;
for (const child of childrenByParent.value.get(task.taskRef) ?? []) visit(child);
};
for (const task of childrenByParent.value.get("root") ?? []) visit(task);
return rows;
});
const workbenchLaunchEnabled = computed(() => navigation.value?.navigation?.capabilities?.workbenchLaunch === true);
const launchButtonDisabled = computed(() => !selectedTask.value || !workbenchLaunchEnabled.value || launchLoading.value);
const selectedSourceCanReindex = computed(() => Boolean(selectedSourceId.value && !taskLoading.value && !sourceReindexLoading.value));
onMounted(() => void loadPage());
@@ -62,7 +116,7 @@ async function loadPage(): Promise<void> {
if (!sourceResponse.ok) throw sourceResponse;
navigation.value = navigationResponse.data;
sources.value = sourceResponse.data?.sources ?? [];
selectedSourceId.value = sources.value[0]?.sourceId ?? null;
selectedSourceId.value = selectedSourceId.value && sources.value.some((source) => source.sourceId === selectedSourceId.value) ? selectedSourceId.value : sources.value[0]?.sourceId ?? null;
if (selectedSourceId.value) await loadFilesAndTasks(selectedSourceId.value);
else await loadLinks(null);
} catch (err) {
@@ -83,9 +137,10 @@ async function loadFilesAndTasks(sourceId: string): Promise<void> {
if (!taskResponse.ok) throw taskResponse;
files.value = fileResponse.data?.files ?? [];
tasks.value = taskResponse.data?.tasks ?? [];
selectedFileRef.value = files.value[0]?.fileRef ?? null;
selectedFileRef.value = selectedFileRef.value && files.value.some((file) => file.fileRef === selectedFileRef.value) ? selectedFileRef.value : files.value[0]?.fileRef ?? null;
const firstVisibleTask = selectedFileRef.value ? tasks.value.find((task) => task.fileRef === selectedFileRef.value) : tasks.value[0];
selectedTaskRef.value = firstVisibleTask?.taskRef ?? null;
collapsedTaskRefs.value = new Set();
if (!selectedTaskRef.value) await loadLinks(null);
} catch (err) {
setError(err);
@@ -107,8 +162,16 @@ async function loadLinks(taskRef: string | null): Promise<void> {
}
}
function onSourceSelect(event: Event): void {
selectedSourceId.value = (event.target as HTMLSelectElement).value || null;
}
function onFileSelect(event: Event): void {
selectFile((event.target as HTMLSelectElement).value);
}
function selectFile(fileRef: string): void {
selectedFileRef.value = fileRef;
selectedFileRef.value = fileRef || null;
const nextTask = tasks.value.find((task) => task.fileRef === fileRef) ?? null;
selectedTaskRef.value = nextTask?.taskRef ?? null;
}
@@ -117,6 +180,89 @@ function selectTask(taskRef: string): void {
selectedTaskRef.value = taskRef;
}
function toggleTask(task: MdtodoTaskRecord): void {
const next = new Set(collapsedTaskRefs.value);
if (next.has(task.taskRef)) next.delete(task.taskRef);
else next.add(task.taskRef);
collapsedTaskRefs.value = next;
}
function hasChildren(task: MdtodoTaskRecord): boolean {
return (childrenByParent.value.get(task.taskRef) ?? []).length > 0;
}
function openSourceConfig(): void {
const source = selectedSource.value;
sourceMessage.value = null;
sourceError.value = null;
Object.assign(sourceForm, {
sourceId: source?.sourceKind === "hwpod-workspace" ? source.sourceId : "d601-f103-v2-mdtodo",
sourceKind: "hwpod-workspace",
displayName: source?.sourceKind === "hwpod-workspace" ? source.displayName : "D601 F103 MDTODO",
projectId: source?.projectId || "project_hwlab_v03",
hwpodId: source?.hwpodId || "d601-f103-v2",
nodeId: source?.nodeId || "node-d601-f103-v2",
workspaceRootRef: source?.workspaceRootRef || "",
mdtodoRootRef: source?.mdtodoRootRef || "docs/MDTODO/",
maxFiles: source?.maxFiles || 120
});
showSourceConfig.value = true;
}
async function saveSourceConfig(): Promise<void> {
sourceSaving.value = true;
sourceMessage.value = null;
sourceError.value = null;
try {
const sourceId = String(sourceForm.sourceId || "").trim();
const exists = Boolean(sourceId && sources.value.some((source) => source.sourceId === sourceId));
const response = exists ? await projectManagementAPI.updateSource(sourceId, sourceForm) : await projectManagementAPI.createSource(sourceForm);
if (!response.ok) throw response;
selectedSourceId.value = response.data?.source?.sourceId ?? sourceId;
sourceMessage.value = "Source 已保存";
await loadPage();
} catch (err) {
sourceError.value = projectApiError(err).error;
} finally {
sourceSaving.value = false;
}
}
async function probeSelectedSource(): Promise<void> {
const sourceId = selectedSourceId.value || sourceForm.sourceId;
if (!sourceId) return;
sourceProbeLoading.value = true;
sourceMessage.value = null;
sourceError.value = null;
try {
const response = await projectManagementAPI.probeSource(sourceId);
if (!response.ok) throw response;
sourceMessage.value = `Probe ${response.data?.probe?.status || "ok"}`;
} catch (err) {
sourceError.value = projectApiError(err).error;
} finally {
sourceProbeLoading.value = false;
}
}
async function reindexSelectedSource(): Promise<void> {
const sourceId = selectedSourceId.value;
if (!sourceId) return;
sourceReindexLoading.value = true;
sourceMessage.value = null;
sourceError.value = null;
try {
const response = await projectManagementAPI.reindexSource(sourceId);
if (!response.ok) throw response;
sourceMessage.value = `Reindex ${response.data?.projection?.documentCount ?? 0} files / ${response.data?.projection?.taskCount ?? 0} tasks`;
await loadFilesAndTasks(sourceId);
} catch (err) {
sourceError.value = projectApiError(err).error;
} finally {
sourceReindexLoading.value = false;
}
}
async function launchSelectedTask(): Promise<void> {
const task = selectedTask.value;
if (!task || !task.taskRef || !task.projectId || launchButtonDisabled.value) return;
@@ -166,6 +312,7 @@ function projectApiError(err: unknown): { error: string; apiError: ApiError | nu
function statusLabel(value?: string | null): string {
if (value === "done") return "完成";
if (value === "blocked") return "阻塞";
if (value === "in_progress") return "进行中";
if (value === "open") return "待办";
return value || "未知";
}
@@ -177,7 +324,7 @@ function sourceStatusTone(value?: string | null): string {
}
function taskIndent(task: MdtodoTaskRecord): Record<string, string> {
return { paddingInlineStart: `${10 + Math.max(0, task.depth ?? 0) * 16}px` };
return { paddingInlineStart: `${12 + Math.max(0, task.depth ?? 0) * 22}px` };
}
function shortRef(value?: string | null): string {
@@ -195,76 +342,83 @@ function displayDate(value?: string | null): string {
<template>
<section class="route-stack mdtodo-page" data-testid="project-management-mdtodo">
<PageHeader eyebrow="Project Management" title="MDTODO" description=" source、文件任务查看当前项目上下文、任务状态和关联工作台摘要。" />
<PageHeader eyebrow="Project Management" title="MDTODO" description="MDTODO source、文件任务树和 Workbench link 的项目工作区。" />
<LoadingState v-if="loading && !sources.length" label="加载 MDTODO source" />
<section v-else-if="error" class="data-panel mdtodo-error" data-testid="project-management-error">
<EmptyState title="MDTODO 加载失败" :description="error" :api-error="apiError" :diagnostic="diagnostic" action-label="重试" @action="loadPage" />
</section>
<template v-else>
<section class="mdtodo-summary-grid" data-testid="mdtodo-summary" aria-label="MDTODO 摘要">
<article class="metric-card"><span>Source</span><strong>{{ sources.length }}</strong><small>{{ selectedSource?.displayName || '未选择' }}</small></article>
<article class="metric-card"><span>Files</span><strong>{{ files.length }}</strong><small>{{ selectedFile?.relativePath || 'no file' }}</small></article>
<article class="metric-card"><span>Tasks</span><strong>{{ filteredTasks.length }}</strong><small>open {{ taskStatusCounts.open || 0 }} / done {{ taskStatusCounts.done || 0 }}</small></article>
<article class="metric-card"><span>Links</span><strong>{{ links.length }}</strong><small>{{ workbenchLaunchEnabled ? 'launch enabled' : 'launch unavailable' }}</small></article>
<section class="mdtodo-toolbar" data-testid="mdtodo-toolbar" aria-label="MDTODO controls">
<label class="toolbar-field" data-testid="mdtodo-source-list">
<span>Source</span>
<select data-testid="mdtodo-source-select" :value="selectedSourceId || ''" @change="onSourceSelect">
<option v-for="source in sources" :key="source.sourceId" :value="source.sourceId">{{ source.displayName || source.sourceId }}</option>
</select>
</label>
<label class="toolbar-field" data-testid="mdtodo-file-control">
<span>File</span>
<select data-testid="mdtodo-file-select" :value="selectedFileRef || ''" :disabled="taskLoading || !files.length" @change="onFileSelect">
<option v-for="file in files" :key="file.fileRef" :value="file.fileRef">{{ file.title || file.relativePath || file.fileRef }}</option>
</select>
</label>
<div class="toolbar-actions">
<button class="icon-button" type="button" data-testid="mdtodo-source-config-open" aria-label="配置 Source" @click="openSourceConfig"></button>
<button class="icon-button" type="button" data-testid="mdtodo-source-reindex" aria-label="重建索引" :disabled="!selectedSourceCanReindex" @click="reindexSelectedSource"></button>
<button class="icon-button" type="button" data-testid="mdtodo-info-open" aria-label="查看摘要" @click="showInfo = true">?</button>
</div>
</section>
<p v-if="sourceMessage" class="source-message" data-testid="mdtodo-source-message">{{ sourceMessage }}</p>
<p v-if="sourceError" class="source-error" data-testid="mdtodo-source-error">{{ sourceError }}</p>
<section class="mdtodo-workspace">
<aside class="data-panel mdtodo-source-panel" data-testid="mdtodo-source-list">
<header class="mdtodo-panel-header"><h2>Source</h2><button class="btn btn-secondary btn-sm" type="button" :disabled="loading" @click="loadPage">刷新</button></header>
<EmptyState v-if="!sources.length" title="暂无 source" description="ProjectSource registry 没有返回可见 MDTODO source。" />
<button v-for="source in sources" v-else :key="source.sourceId" class="source-option" type="button" :data-selected="source.sourceId === selectedSourceId" :data-source-id="source.sourceId" @click="selectedSourceId = source.sourceId">
<span class="pm-status" :data-tone="sourceStatusTone(source.status)">{{ source.status || 'unknown' }}</span>
<strong>{{ source.displayName || source.sourceId }}</strong>
<small>{{ source.kind || 'mdtodo' }} / {{ source.projectId || '-' }}</small>
</button>
</aside>
<aside class="data-panel mdtodo-file-panel" data-testid="mdtodo-file-list">
<header class="mdtodo-panel-header"><h2>文件</h2><span>{{ taskLoading ? '同步中' : `${files.length}` }}</span></header>
<LoadingState v-if="taskLoading" compact label="同步文件和任务" />
<EmptyState v-else-if="!files.length" title="暂无 TODO 文件" description="服务已连接,但当前 source 尚未发现可投影的 TODO。" />
<button v-for="file in files" v-else :key="file.fileRef" class="file-option" type="button" :data-selected="file.fileRef === selectedFileRef" :data-file-ref="file.fileRef" @click="selectFile(file.fileRef)">
<strong>{{ file.title || file.relativePath || file.fileRef }}</strong>
<small>{{ file.relativePath || file.fileRef }}</small>
<span :data-status="file.parseError ? 'error' : 'ok'">{{ file.parseError ? 'parse error' : `${file.taskCount ?? 0} tasks` }}</span>
</button>
</aside>
<section class="data-panel mdtodo-task-panel" data-testid="mdtodo-task-tree">
<header class="mdtodo-panel-header"><h2>任务树</h2><span>{{ filteredTasks.length }} tasks</span></header>
<EmptyState v-if="!filteredTasks.length" title="暂无任务" description="当前文件没有任务投影;可以刷新 source 状态后再查看。" />
<header class="mdtodo-panel-header">
<div><h2>任务树</h2><p>{{ filteredTasks.length }} / {{ fileScopedTasks.length }} tasks</p></div>
<div class="task-tools">
<input v-model="taskSearch" data-testid="mdtodo-task-search" type="search" placeholder="R1 / title" aria-label="搜索任务" />
<select v-model="taskStatusFilter" data-testid="mdtodo-task-status-filter" aria-label="筛选状态">
<option value="all">全部</option>
<option value="open">待办</option>
<option value="in_progress">进行中</option>
<option value="done">完成</option>
<option value="blocked">阻塞</option>
</select>
</div>
</header>
<LoadingState v-if="taskLoading" compact label="同步文件和任务" />
<EmptyState v-else-if="!visibleTaskRows.length" title="暂无任务" description="当前文件没有任务投影。" />
<div v-else class="task-tree" role="tree" aria-label="MDTODO task tree">
<button v-for="task in filteredTasks" :key="task.taskRef" class="task-row" role="treeitem" type="button" :style="taskIndent(task)" :data-selected="task.taskRef === selectedTaskRef" :data-task-ref="task.taskRef" :data-task-status="task.status || 'unknown'" @click="selectTask(task.taskRef)">
<span class="task-status" :data-status="task.status || 'unknown'">{{ statusLabel(task.status) }}</span>
<strong>{{ task.title || task.taskId || 'Untitled task' }}</strong>
<small>line {{ task.lineNumber ?? '-' }} / links {{ task.linkCount ?? 0 }}</small>
</button>
<div v-for="task in visibleTaskRows" :key="task.taskRef" class="task-row-shell" :style="taskIndent(task)">
<button class="task-toggle" type="button" :aria-label="collapsedTaskRefs.has(task.taskRef) ? '展开任务' : '折叠任务'" :disabled="!hasChildren(task)" @click.stop="toggleTask(task)">{{ hasChildren(task) ? (collapsedTaskRefs.has(task.taskRef) ? '+' : '-') : '' }}</button>
<button class="task-row" role="treeitem" type="button" :data-selected="task.taskRef === selectedTaskRef" :data-task-ref="task.taskRef" :data-task-id="task.taskId" :data-task-status="task.status || 'unknown'" @click="selectTask(task.taskRef)">
<span class="task-id">{{ task.taskId || 'R?' }}</span>
<strong>{{ task.title || task.taskId || 'Untitled task' }}</strong>
<span class="task-status" :data-status="task.status || 'unknown'">{{ statusLabel(task.status) }}</span>
</button>
</div>
</div>
</section>
<aside class="data-panel mdtodo-detail-panel" data-testid="mdtodo-task-detail">
<header class="mdtodo-panel-header"><h2>任务详情</h2><span>{{ selectedTask ? statusLabel(selectedTask.status) : '未选择' }}</span></header>
<header class="mdtodo-panel-header"><div><h2>任务详情</h2><p>{{ selectedTask ? statusLabel(selectedTask.status) : '未选择' }}</p></div></header>
<EmptyState v-if="!selectedTask" title="未选择任务" description="选择任务后显示 taskRef、状态、文件和 Workbench link 摘要。" />
<template v-else>
<dl class="task-detail-list">
<div><dt>Title</dt><dd>{{ selectedTask.title || selectedTask.taskId }}</dd></div>
<div><dt>TaskRef</dt><dd><code>{{ selectedTask.taskRef }}</code></dd></div>
<div><dt>Project</dt><dd><code>{{ selectedTask.projectId || '-' }}</code></dd></div>
<div><dt>FileRef</dt><dd><code>{{ selectedTask.fileRef || '-' }}</code></dd></div>
<div><dt>File</dt><dd><code>{{ selectedFile?.relativePath || selectedTask.fileRef || '-' }}</code></dd></div>
<div><dt>Updated</dt><dd>{{ displayDate(selectedTask.updatedAt) }}</dd></div>
</dl>
<button class="btn btn-primary" type="button" data-testid="mdtodo-workbench-launch" :disabled="launchButtonDisabled" :aria-disabled="launchButtonDisabled" @click="launchSelectedTask">
{{ launchLoading ? '启动中' : '在 Workbench 执行' }}
</button>
<p class="launch-blocker" data-testid="mdtodo-workbench-launch-blocker">{{ workbenchLaunchEnabled ? '公共 Workbench Launch API 已可用。' : 'Workbench Launch capability 暂不可用。' }}</p>
<p class="launch-blocker" data-testid="mdtodo-workbench-launch-blocker">{{ workbenchLaunchEnabled ? 'Workbench Launch API ready' : 'Workbench Launch capability unavailable' }}</p>
<p v-if="launchError" class="launch-blocker" data-testid="mdtodo-workbench-launch-error">{{ launchError }}</p>
<section class="task-links" data-testid="mdtodo-workbench-link-summary">
<header><strong>Workbench Links</strong><span v-if="linkLoading">同步中</span></header>
<ul v-if="links.length">
<li v-for="link in links" :key="link.linkId">
<strong>{{ link.sessionId || link.linkId }}</strong>
<small>{{ shortRef(link.traceId || link.taskRef || link.projectId) }}</small>
</li>
<li v-for="link in links" :key="link.linkId"><strong>{{ link.sessionId || link.linkId }}</strong><small>{{ shortRef(link.traceId || link.taskRef || link.projectId) }}</small></li>
</ul>
<p v-else>暂无关联 session</p>
</section>
@@ -272,64 +426,113 @@ function displayDate(value?: string | null): string {
</aside>
</section>
</template>
<div v-if="showInfo" class="modal-backdrop" data-testid="mdtodo-info-popover" role="dialog" aria-modal="true" aria-label="MDTODO 摘要" @click.self="showInfo = false">
<section class="mdtodo-dialog mdtodo-info-dialog">
<header><h2>MDTODO 摘要</h2><button class="icon-button" type="button" aria-label="关闭摘要" @click="showInfo = false">×</button></header>
<dl class="summary-list">
<div><dt>Source</dt><dd>{{ sources.length }} / {{ selectedSource?.displayName || '-' }}</dd></div>
<div><dt>Files</dt><dd>{{ files.length }} / {{ selectedFile?.relativePath || '-' }}</dd></div>
<div><dt>Tasks</dt><dd>open {{ taskStatusCounts.open || 0 }} / progress {{ taskStatusCounts.in_progress || 0 }} / done {{ taskStatusCounts.done || 0 }}</dd></div>
<div><dt>Links</dt><dd>{{ links.length }} / {{ workbenchLaunchEnabled ? 'launch enabled' : 'launch unavailable' }}</dd></div>
</dl>
</section>
</div>
<div v-if="showSourceConfig" class="modal-backdrop" data-testid="mdtodo-source-config-dialog" role="dialog" aria-modal="true" aria-label="配置 MDTODO Source" @click.self="showSourceConfig = false">
<section class="mdtodo-dialog source-config-dialog">
<header><h2>Source 配置</h2><button class="icon-button" type="button" aria-label="关闭配置" @click="showSourceConfig = false">×</button></header>
<form class="source-form" @submit.prevent="saveSourceConfig">
<label><span>Source ID</span><input v-model="sourceForm.sourceId" data-testid="mdtodo-source-form-id" required /></label>
<label><span>Display Name</span><input v-model="sourceForm.displayName" data-testid="mdtodo-source-form-name" required /></label>
<label><span>HWPOD ID</span><input v-model="sourceForm.hwpodId" data-testid="mdtodo-source-form-hwpod" required /></label>
<label><span>Node ID</span><input v-model="sourceForm.nodeId" data-testid="mdtodo-source-form-node" required /></label>
<label><span>Workspace Root</span><input v-model="sourceForm.workspaceRootRef" data-testid="mdtodo-source-form-workspace" /></label>
<label><span>MDTODO Root</span><input v-model="sourceForm.mdtodoRootRef" data-testid="mdtodo-source-form-root" required /></label>
<label><span>Max Files</span><input v-model.number="sourceForm.maxFiles" data-testid="mdtodo-source-form-max-files" type="number" min="1" max="500" /></label>
<footer>
<button class="btn btn-primary" data-testid="mdtodo-source-save" type="submit" :disabled="sourceSaving">{{ sourceSaving ? '保存中' : '保存' }}</button>
<button class="btn btn-secondary" data-testid="mdtodo-source-probe" type="button" :disabled="sourceProbeLoading" @click="probeSelectedSource">{{ sourceProbeLoading ? 'Probe...' : 'Probe' }}</button>
<button class="btn btn-secondary" data-testid="mdtodo-source-reindex-dialog" type="button" :disabled="sourceReindexLoading" @click="reindexSelectedSource">{{ sourceReindexLoading ? 'Reindex...' : 'Reindex' }}</button>
</footer>
<p v-if="sourceMessage" class="source-message">{{ sourceMessage }}</p>
<p v-if="sourceError" class="source-error">{{ sourceError }}</p>
</form>
</section>
</div>
</section>
</template>
<style scoped>
.mdtodo-page { min-width: 0; }
.mdtodo-summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.mdtodo-summary-grid .metric-card strong { color: #0f172a; font-size: 28px; line-height: 1.15; }
.mdtodo-summary-grid .metric-card small { min-width: 0; overflow: hidden; color: #64748b; text-overflow: ellipsis; white-space: nowrap; }
.mdtodo-workspace { display: grid; min-height: min(720px, calc(100dvh - 190px)); grid-template-columns: minmax(210px, 0.72fr) minmax(230px, 0.82fr) minmax(360px, 1.28fr) minmax(320px, 0.96fr); gap: 12px; }
.mdtodo-source-panel,
.mdtodo-file-panel,
.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; }
.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,
.task-tools input,
.task-tools select,
.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; }
.toolbar-actions { display: flex; gap: 8px; justify-content: flex-end; }
.icon-button { display: inline-grid; width: 36px; height: 36px; place-items: center; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; font-weight: 850; }
.icon-button:disabled { opacity: 0.5; }
.source-message,
.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-task-panel,
.mdtodo-detail-panel,
.mdtodo-error { display: grid; min-width: 0; min-height: 0; align-content: start; gap: 10px; padding: 14px; overflow: auto; }
.mdtodo-panel-header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 10px; }
.mdtodo-panel-header h2 { margin: 0; color: #0f172a; font-size: 16px; line-height: 1.2; }
.mdtodo-panel-header span { color: #64748b; font-size: 12px; white-space: nowrap; }
.source-option,
.file-option,
.task-row { display: grid; width: 100%; min-width: 0; gap: 5px; border: 1px solid #e2e8f0; border-radius: 8px; background: #ffffff; color: #0f172a; padding: 10px; text-align: left; }
.source-option[data-selected="true"],
.file-option[data-selected="true"],
.task-row[data-selected="true"] { border-color: #0891b2; background: #ecfeff; box-shadow: inset 3px 0 0 #0891b2; }
.source-option strong,
.file-option strong,
.mdtodo-error { display: grid; min-width: 0; min-height: 0; align-content: start; gap: 12px; padding: 14px; overflow: auto; }
.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-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; }
.task-toggle { width: 24px; height: 24px; border: 1px solid #d8e2df; border-radius: 4px; background: #fff; color: #31423c; font-weight: 850; line-height: 1; }
.task-toggle:disabled { border-color: transparent; background: transparent; }
.task-row { display: grid; width: 100%; min-width: 0; grid-template-columns: minmax(52px, 0.22fr) minmax(0, 1fr) auto; gap: 8px; align-items: center; border: 1px solid #d8e2df; border-radius: 6px; background: #fff; color: #14211d; padding: 8px 10px; text-align: left; }
.task-row[data-selected="true"] { border-color: #0f766e; background: #ecfdf5; box-shadow: inset 3px 0 0 #0f766e; }
.task-id { color: #0f766e; font-size: 12px; font-weight: 850; }
.task-row strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.source-option small,
.file-option small,
.task-row small { min-width: 0; overflow: hidden; color: #64748b; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.file-option span { width: fit-content; border-radius: 999px; background: #f1f5f9; color: #475569; padding: 3px 7px; font-size: 11px; font-weight: 750; }
.file-option span[data-status="error"] { background: #fef2f2; color: #991b1b; }
.task-tree { display: grid; gap: 6px; }
.pm-status,
.task-status { display: inline-flex; width: fit-content; max-width: 100%; align-items: center; border-radius: 999px; border: 1px solid #cbd5e1; padding: 4px 8px; font-size: 11px; font-weight: 750; line-height: 1; }
.pm-status[data-tone="ok"] { border-color: #86efac; background: #dcfce7; color: #166534; }
.pm-status[data-tone="error"] { border-color: #fecaca; background: #fef2f2; color: #991b1b; }
.pm-status[data-tone="pending"] { border-color: #fde68a; background: #fffbeb; color: #92400e; }
.task-status { display: inline-flex; width: fit-content; max-width: 100%; align-items: center; border-radius: 999px; border: 1px solid #bae6fd; background: #f0f9ff; color: #075985; padding: 4px 8px; font-size: 11px; font-weight: 750; line-height: 1; }
.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; }
.task-status { border-color: #bae6fd; background: #f0f9ff; color: #075985; }
.task-detail-list { display: grid; gap: 8px; margin: 0; }
.task-detail-list div { display: grid; gap: 4px; border-bottom: 1px solid #e2e8f0; padding-bottom: 8px; }
.task-detail-list dt { color: #64748b; font-size: 11px; font-weight: 800; text-transform: uppercase; }
.task-detail-list dd { min-width: 0; margin: 0; color: #0f172a; font-size: 13px; line-height: 1.45; overflow-wrap: anywhere; }
.task-detail-list code { font-size: 12px; }
.launch-blocker { margin: 0; border-radius: 8px; background: #f8fafc; color: #475569; padding: 10px; font-size: 12px; line-height: 1.45; }
.task-links { display: grid; gap: 8px; }
.task-links header { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: #0f172a; }
.task-links header span,
.task-links p { margin: 0; color: #64748b; font-size: 12px; }
.task-detail-list,
.summary-list { display: grid; gap: 8px; margin: 0; }
.task-detail-list div,
.summary-list div { display: grid; gap: 4px; border-bottom: 1px solid #e2e8f0; padding-bottom: 8px; }
.task-detail-list dt,
.summary-list dt { color: #64706b; font-size: 11px; font-weight: 800; text-transform: uppercase; }
.task-detail-list dd,
.summary-list dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #14211d; font-size: 13px; }
.task-detail-list code { color: #0f766e; font-size: 12px; }
.launch-blocker { margin: 0; color: #64706b; font-size: 12px; }
.task-links { display: grid; gap: 8px; border-top: 1px solid #e2e8f0; padding-top: 10px; }
.task-links header { display: flex; justify-content: space-between; gap: 10px; color: #14211d; font-size: 13px; }
.task-links ul { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
.task-links li { display: grid; min-width: 0; gap: 4px; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; }
.task-links li strong,
.task-links li small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-links li small { color: #64748b; }
@media (max-width: 1320px) { .mdtodo-workspace { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 820px) { .mdtodo-summary-grid,
.task-links li { display: grid; gap: 3px; border: 1px solid #d8e2df; border-radius: 6px; padding: 8px; }
.task-links small { color: #64706b; overflow-wrap: anywhere; }
.modal-backdrop { position: fixed; z-index: 60; inset: 0; display: grid; place-items: start center; overflow: auto; background: rgba(15, 23, 42, 0.38); padding: 8dvh 16px 32px; }
.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; }
.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 footer { display: flex; grid-column: 1 / -1; flex-wrap: wrap; gap: 8px; }
.source-form .source-message,
.source-form .source-error { grid-column: 1 / -1; }
@media (max-width: 980px) {
.mdtodo-toolbar,
.mdtodo-workspace { grid-template-columns: 1fr; }
.mdtodo-workspace { min-height: auto; }
.toolbar-actions { justify-content: flex-start; }
.task-tools { width: 100%; grid-template-columns: 1fr; }
.mdtodo-workspace { min-height: 0; }
}
@media (max-width: 640px) {
.source-form { grid-template-columns: 1fr; }
.task-row { grid-template-columns: minmax(48px, auto) minmax(0, 1fr); }
.task-status { grid-column: 1 / -1; }
}
</style>
@@ -15,10 +15,31 @@ test.describe("project management pages", () => {
await page.getByTestId("open-mdtodo-page").click();
await expect(page).toHaveURL(/\/projects\/mdtodo/u);
await expect(page.getByTestId("project-management-mdtodo")).toBeVisible();
await expect(page.getByTestId("mdtodo-source-list")).toContainText("HWLAB MDTODO");
await expect(page.getByTestId("mdtodo-file-list")).toContainText("项目管理任务");
await expect(page.getByTestId("mdtodo-toolbar")).toBeVisible();
await expect(page.getByTestId("mdtodo-source-select")).toHaveValue("hwlab-v03-mdtodo");
await expect(page.getByTestId("mdtodo-file-select")).toHaveValue("file_plan");
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);
await expect(page.getByTestId("mdtodo-task-tree")).toContainText("实现项目根导航");
await expect(page.getByTestId("mdtodo-task-detail")).toContainText("mdtodo:hwlab-v03-mdtodo:file_plan:t1");
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 page.getByTestId("mdtodo-info-open").click();
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 page.getByLabel("关闭摘要").click();
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");
await expect(page.getByTestId("mdtodo-source-form-root")).toHaveValue("docs/MDTODO/");
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 page.getByLabel("关闭配置").click();
await expect(page.getByTestId("mdtodo-workbench-link-summary")).toContainText("ses_project_seed");
await expect(page.getByTestId("mdtodo-workbench-launch")).toBeEnabled();
await expect(page.locator("iframe")).toHaveCount(0);
@@ -34,9 +55,9 @@ test.describe("project management pages", () => {
expect(sessionDetail.ok()).toBeTruthy();
const sessionPayload = await sessionDetail.json();
expect(sessionPayload.session?.launchContext?.projectId ?? sessionPayload.session?.metadata?.launchContext?.projectId).toBe("project_hwlab_v03");
expect(sessionPayload.session?.launchContext?.taskRef ?? sessionPayload.session?.metadata?.launchContext?.taskRef).toBe("mdtodo:hwlab-v03-mdtodo:file_plan:t1");
expect(sessionPayload.session?.launchContext?.taskRef ?? sessionPayload.session?.metadata?.launchContext?.taskRef).toBe("mdtodo:hwlab-v03-mdtodo:file_plan:R1");
const links = await page.request.get("/v1/project-management/workbench-links?taskRef=mdtodo:hwlab-v03-mdtodo:file_plan:t1");
const links = await page.request.get("/v1/project-management/workbench-links?taskRef=mdtodo:hwlab-v03-mdtodo:file_plan:R1");
expect(links.ok()).toBeTruthy();
const linksPayload = await links.json();
expect(linksPayload.links.some((link: { sessionId?: string }) => link.sessionId === sessionId)).toBeTruthy();