Merge pull request #2143 from pikasTech/feat/2135-project-pages

feat: add project management pages
This commit is contained in:
Lyon
2026-06-25 17:27:13 +08:00
committed by GitHub
9 changed files with 762 additions and 1 deletions
@@ -226,6 +226,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
if (path === "/v1/live-builds") return json(response, 200, { status: "ok", builds: [] });
if (path === "/v1/hwpod/specs") return json(response, 200, hwpodSpecsPayload());
if (path === "/v1/hwpod-node-ops") return json(response, 200, hwpodNodeOpsPayload());
if (path.startsWith("/v1/project-management")) return projectManagementPayload(response, url, method);
if (path === "/v1/web-performance/summary") {
if (state.scenarioId === "performance-summary-error") return errorDiagnosticResponse(response, 502, "/v1/web-performance/summary", "upstream_unavailable", "暂时无法连接上游。");
const matrixStatus = performanceSummaryErrorStatus(state.scenarioId);
@@ -284,6 +285,64 @@ function hwpodNodeOpsPayload(): JsonRecord {
};
}
function projectManagementPayload(response: ServerResponse, url: URL, method: string): void {
if (method !== "GET" && method !== "HEAD") return json(response, 405, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "method_not_allowed" }, valuesRedacted: true });
const path = url.pathname;
const source = projectManagementSource();
const files = projectManagementFiles();
const tasks = projectManagementTasks();
const links = projectManagementLinks();
if (path === "/v1/project-management" || path === "/v1/project-management/navigation") {
return json(response, 200, projectManagementEnvelope({
navigation: {
id: "project-management",
label: "Project Management",
actor: { id: "usr_e2e_admin", role: "admin", authMethod: "web-session" },
items: [
{ id: "projects", label: "Projects", apiRoute: "/v1/project-management/projects" },
{ id: "mdtodo", label: "MDTODO", apiRoute: "/v1/project-management/mdtodo/tasks" }
],
capabilities: { mdtodoProjection: true, workbenchLaunch: false, workbenchLinks: true, sourceOfTruth: "markdown-files" }
},
projection: { sourceId: source.sourceId, documentCount: files.length, taskCount: tasks.length, projectedAt: "2026-06-25T09:00:00.000Z" }
}));
}
if (path === "/v1/project-management/projects") return json(response, 200, projectManagementEnvelope({ projects: [{ projectId: "project_hwlab_v03", name: "HWLAB MDTODO", sourceIds: [source.sourceId], sourceOfTruth: "markdown-files", status: "active" }] }));
if (path === "/v1/project-management/mdtodo/sources") return json(response, 200, projectManagementEnvelope({ sources: [source] }));
if (path === "/v1/project-management/mdtodo/files") return json(response, 200, projectManagementEnvelope({ files: files.filter((file) => !url.searchParams.get("sourceId") || file.sourceId === url.searchParams.get("sourceId")) }));
if (path === "/v1/project-management/mdtodo/tasks") return json(response, 200, projectManagementEnvelope({ tasks: tasks.filter((task) => (!url.searchParams.get("sourceId") || task.sourceId === url.searchParams.get("sourceId")) && (!url.searchParams.get("fileRef") || task.fileRef === url.searchParams.get("fileRef"))) }));
if (path === "/v1/project-management/workbench-links") return json(response, 200, projectManagementEnvelope({ links: links.filter((link) => !url.searchParams.get("taskRef") || link.taskRef === url.searchParams.get("taskRef")) }));
return json(response, 404, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "not_found" }, valuesRedacted: true });
}
function projectManagementEnvelope(payload: JsonRecord): JsonRecord {
return { ok: true, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", ...payload, valuesRedacted: true };
}
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 };
}
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" }
];
}
function projectManagementTasks(): JsonRecord[] {
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: 1, 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: 0, 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: 0, updatedAt: "2026-06-25T09:00:00.000Z" }
];
}
function projectManagementLinks(): 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" }];
}
function webPerformanceSummaryPayload(url: URL): JsonRecord {
const sampleWindow = fakePerformanceWindow(url.searchParams.get("window"));
const rows = [
+6
View File
@@ -1,3 +1,6 @@
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo.
// Responsibility: Cloud Web API barrel, including project-management public API exports.
export { fetchJson, fetchText, type ActivityRef, type ActivityRefSource, type ApiRequestOptions } from "./client";
export { authAPI, HWLAB_WEB_SESSION_COOKIE } from "./auth";
export { workbenchAPI } from "./workbench";
@@ -11,6 +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";
import { fetchJson } from "./client";
import { accessAPI } from "./access";
@@ -24,6 +28,7 @@ import { usageAPI } from "./usage";
import { billingAPI } from "./billing";
import { workbenchAPI } from "./workbench";
import { systemAPI } from "./system";
import { projectManagementAPI } from "./projectManagement";
import type { ApiResult, LiveBuildsPayload, LiveProbePayload, SkillsResponse } from "@/types";
export const api = {
@@ -37,6 +42,7 @@ export const api = {
apiKeys: apiKeysAPI,
usage: usageAPI,
billing: billingAPI,
projectManagement: projectManagementAPI,
system: systemAPI,
healthLive: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }),
health: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }),
@@ -0,0 +1,144 @@
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo.
// Responsibility: Cloud Web client for the public project-management same-origin API.
import { fetchJson } from "./client";
import type { ApiResult } from "@/types";
export interface ProjectManagementEnvelope {
contractVersion?: string;
serviceId?: string;
ok?: boolean;
valuesRedacted?: boolean;
}
export interface ProjectNavigationItem {
id: string;
label: string;
apiRoute?: string;
}
export interface ProjectNavigationCapabilities {
mdtodoProjection?: boolean;
workbenchLaunch?: boolean;
workbenchLinks?: boolean;
sourceOfTruth?: string;
[key: string]: unknown;
}
export interface ProjectNavigation {
id: string;
label: string;
actor?: { id?: string | null; role?: string | null; authMethod?: string | null } | null;
items?: ProjectNavigationItem[];
capabilities?: ProjectNavigationCapabilities;
}
export interface ProjectProjectionSummary {
sourceId?: string;
documentCount?: number;
taskCount?: number;
projectedAt?: string;
startedAt?: string;
}
export interface ProjectNavigationResponse extends ProjectManagementEnvelope {
navigation?: ProjectNavigation;
projection?: ProjectProjectionSummary | null;
}
export interface ProjectRecord {
projectId: string;
name?: string;
title?: string;
sourceIds?: string[];
sourceOfTruth?: string;
status?: string;
}
export interface ProjectSource {
sourceId: string;
kind?: string;
displayName?: string;
projectId?: string;
status?: string;
updatedAt?: string;
valuesRedacted?: boolean;
}
export interface MdtodoFileRecord {
sourceId: string;
fileRef: string;
projectId?: string;
relativePath?: string;
title?: string;
fingerprint?: string;
taskCount?: number;
parseError?: string | null;
revision?: number;
lastIndexedAt?: string;
updatedAt?: string;
}
export interface MdtodoTaskRecord {
taskRef: string;
projectId?: string;
sourceId?: string;
fileRef?: string;
taskId?: string;
title?: string;
status?: string;
parentTaskRef?: string | null;
depth?: number;
lineNumber?: number;
ordinal?: number;
linkCount?: number;
updatedAt?: string;
}
export interface ProjectWorkbenchLinkRecord {
linkId: string;
projectId?: string;
taskRef?: string | null;
sessionId?: string | null;
traceId?: string | null;
createdBy?: string | null;
role?: string;
createdAt?: string;
updatedAt?: string;
}
export interface ProjectListResponse extends ProjectManagementEnvelope { projects?: ProjectRecord[] }
export interface ProjectSourceListResponse extends ProjectManagementEnvelope { sources?: ProjectSource[] }
export interface MdtodoFileListResponse extends ProjectManagementEnvelope { files?: MdtodoFileRecord[] }
export interface MdtodoTaskListResponse extends ProjectManagementEnvelope { tasks?: MdtodoTaskRecord[] }
export interface ProjectWorkbenchLinkListResponse extends ProjectManagementEnvelope { links?: ProjectWorkbenchLinkRecord[] }
export interface MdtodoTaskQuery {
sourceId?: string | null;
fileRef?: string | null;
projectId?: string | null;
}
export interface ProjectWorkbenchLinkQuery {
taskRef?: string | null;
projectId?: string | null;
sessionId?: string | null;
}
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" }),
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" })
};
function queryString(values: Record<string, string | null | undefined>): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(values)) {
if (value) params.set(key, value);
}
const text = params.toString();
return text ? `?${text}` : "";
}
@@ -1,3 +1,6 @@
<!-- SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo. -->
<!-- Responsibility: App shell navigation entry for project-management pages without Workbench embedding. -->
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
@@ -17,6 +20,7 @@ const diagnosticsOpen = ref(false);
const navSections = [
{ title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D" }] },
{ title: "项目", items: [{ name: "Projects", label: "项目", path: "/projects", icon: "P" }, { name: "ProjectMdtodo", label: "MDTODO", path: "/projects/mdtodo", icon: "M" }] },
{ title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys", icon: "K" }, { name: "Usage", label: "用量", path: "/usage", icon: "U" }, { name: "Billing", label: "充值", path: "/billing", icon: "$" }] },
{ title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "A" }, { name: "Users", label: "用户", path: "/admin/users", icon: "P" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "B" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "H" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles", icon: "R" }] },
{ title: "系统", items: [{ name: "Gate", label: "Gate", path: "/gate", icon: "G" }, { name: "Performance", label: "性能", path: "/performance", icon: "M" }, { name: "Skills", label: "Skills", path: "/skills", icon: "S" }, { name: "Settings", label: "设置", path: "/settings", icon: "T" }, { name: "Help", label: "帮助", path: "/help", icon: "?" }] }
+7
View File
@@ -1,7 +1,12 @@
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo.
// Responsibility: Cloud Web routes, including project-management pages under /projects.
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
import { installRouterGuards } from "./guards";
const CodeWorkbenchView = () => import("@/views/workbench/CodeWorkbenchView.vue");
const ProjectsView = () => import("@/views/projects/ProjectsView.vue");
const ProjectMdtodoView = () => import("@/views/projects/MdtodoView.vue");
const routes: RouteRecordRaw[] = [
{ path: "/", redirect: "/workbench" },
@@ -10,6 +15,8 @@ const routes: RouteRecordRaw[] = [
{ path: "/dashboard", name: "Dashboard", component: () => import("@/views/user/DashboardView.vue"), meta: { requiresAuth: true, title: "平台概览", section: "user" } },
{ path: "/workbench/sessions/:sessionId", alias: ["/workspace/sessions/:sessionId"], name: "CodeWorkbenchSession", component: CodeWorkbenchView, meta: { requiresAuth: true, title: "Code 工作台", section: "workbench" } },
{ path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: CodeWorkbenchView, meta: { requiresAuth: true, title: "Code 工作台", section: "workbench" } },
{ path: "/projects", name: "Projects", component: ProjectsView, meta: { requiresAuth: true, title: "项目", section: "project" } },
{ path: "/projects/mdtodo", name: "ProjectMdtodo", component: ProjectMdtodoView, meta: { requiresAuth: true, title: "MDTODO", section: "project" } },
{ path: "/api-keys", name: "ApiKeys", component: () => import("@/views/user/ApiKeysView.vue"), meta: { requiresAuth: true, title: "API Keys", section: "user" } },
{ path: "/usage", name: "Usage", component: () => import("@/views/user/UsageView.vue"), meta: { requiresAuth: true, title: "用量与性能", section: "user" } },
{ path: "/billing", name: "Billing", component: () => import("@/views/user/BillingView.vue"), meta: { requiresAuth: true, title: "充值与订阅", section: "user" } },
+4 -1
View File
@@ -1,3 +1,6 @@
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo.
// Responsibility: Router metadata sections for Cloud Web navigation, including project management.
import "vue-router";
declare module "vue-router" {
@@ -5,6 +8,6 @@ declare module "vue-router" {
requiresAuth?: boolean;
requiresAdmin?: boolean;
title?: string;
section?: "workbench" | "admin" | "user" | "system";
section?: "workbench" | "project" | "admin" | "user" | "system";
}
}
@@ -0,0 +1,298 @@
<!-- SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo. -->
<!-- Responsibility: MDTODO project page consuming public ProjectTask DTOs without Workbench or Markdown internals. -->
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource, 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 loading = ref(false);
const taskLoading = ref(false);
const linkLoading = ref(false);
const error = ref<string | null>(null);
const apiError = ref<ApiError | null>(null);
const diagnostic = ref<ErrorDiagnostic | null>(null);
const navigation = ref<ProjectNavigationResponse | null>(null);
const sources = ref<ProjectSource[]>([]);
const files = ref<MdtodoFileRecord[]>([]);
const tasks = ref<MdtodoTaskRecord[]>([]);
const links = ref<ProjectWorkbenchLinkRecord[]>([]);
const selectedSourceId = ref<string | null>(null);
const selectedFileRef = ref<string | null>(null);
const selectedTaskRef = ref<string | null>(null);
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 key = task.status || "unknown";
acc[key] = (acc[key] ?? 0) + 1;
return acc;
}, {} as Record<string, number>));
const workbenchLaunchEnabled = computed(() => navigation.value?.navigation?.capabilities?.workbenchLaunch === true);
onMounted(() => void loadPage());
watch(selectedSourceId, async (sourceId) => {
if (!sourceId || loading.value) return;
await loadFilesAndTasks(sourceId);
});
watch(selectedTaskRef, async (taskRef) => {
await loadLinks(taskRef);
});
async function loadPage(): Promise<void> {
loading.value = true;
error.value = null;
apiError.value = null;
diagnostic.value = null;
try {
const [navigationResponse, sourceResponse] = await Promise.all([projectManagementAPI.navigation(), projectManagementAPI.sources()]);
if (!navigationResponse.ok) throw navigationResponse;
if (!sourceResponse.ok) throw sourceResponse;
navigation.value = navigationResponse.data;
sources.value = sourceResponse.data?.sources ?? [];
selectedSourceId.value = sources.value[0]?.sourceId ?? null;
if (selectedSourceId.value) await loadFilesAndTasks(selectedSourceId.value);
else await loadLinks(null);
} catch (err) {
setError(err);
} finally {
loading.value = false;
}
}
async function loadFilesAndTasks(sourceId: string): Promise<void> {
taskLoading.value = true;
try {
const [fileResponse, taskResponse] = await Promise.all([
projectManagementAPI.files(sourceId),
projectManagementAPI.tasks({ sourceId })
]);
if (!fileResponse.ok) throw fileResponse;
if (!taskResponse.ok) throw taskResponse;
files.value = fileResponse.data?.files ?? [];
tasks.value = taskResponse.data?.tasks ?? [];
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;
if (!selectedTaskRef.value) await loadLinks(null);
} catch (err) {
setError(err);
} finally {
taskLoading.value = false;
}
}
async function loadLinks(taskRef: string | null): Promise<void> {
linkLoading.value = true;
try {
const response = await projectManagementAPI.workbenchLinks(taskRef ? { taskRef } : {});
if (!response.ok) throw response;
links.value = response.data?.links ?? [];
} catch (err) {
setError(err);
} finally {
linkLoading.value = false;
}
}
function selectFile(fileRef: string): void {
selectedFileRef.value = fileRef;
const nextTask = tasks.value.find((task) => task.fileRef === fileRef) ?? null;
selectedTaskRef.value = nextTask?.taskRef ?? null;
}
function selectTask(taskRef: string): void {
selectedTaskRef.value = taskRef;
}
function setError(err: unknown): void {
const context = projectApiError(err);
error.value = context.error;
apiError.value = context.apiError;
diagnostic.value = context.diagnostic;
}
function projectApiError(err: unknown): { error: string; apiError: ApiError | null; diagnostic: ErrorDiagnostic | null } {
if (err && typeof err === "object" && "error" in err) {
const result = err as { error?: string | null; apiError?: ApiError | null; diagnostic?: ErrorDiagnostic | null; status?: number };
return { error: result.error || `HTTP ${result.status ?? "unknown"}`, apiError: result.apiError ?? null, diagnostic: result.diagnostic ?? null };
}
return { error: err instanceof Error ? err.message : String(err || "MDTODO 加载失败"), apiError: null, diagnostic: null };
}
function statusLabel(value?: string | null): string {
if (value === "done") return "完成";
if (value === "blocked") return "阻塞";
if (value === "open") return "待办";
return value || "未知";
}
function sourceStatusTone(value?: string | null): string {
if (value === "active" || value === "ok" || value === "ready") return "ok";
if (value === "blocked" || value === "error" || value === "failed") return "error";
return "pending";
}
function taskIndent(task: MdtodoTaskRecord): Record<string, string> {
return { paddingInlineStart: `${10 + Math.max(0, task.depth ?? 0) * 16}px` };
}
function shortRef(value?: string | null): string {
if (!value) return "-";
return value.length > 42 ? `${value.slice(0, 24)}...${value.slice(-10)}` : value;
}
function displayDate(value?: string | null): string {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
</script>
<template>
<section class="route-stack mdtodo-page" data-testid="project-management-mdtodo">
<PageHeader eyebrow="Project Management" title="MDTODO" description="按 source、文件和任务查看当前项目上下文、任务状态和关联工作台摘要。" />
<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 waits for P3' }}</small></article>
</section>
<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 状态后再查看。" />
<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>
</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>
<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>Updated</dt><dd>{{ displayDate(selectedTask.updatedAt) }}</dd></div>
</dl>
<button class="btn btn-primary" type="button" data-testid="mdtodo-workbench-launch" :disabled="!workbenchLaunchEnabled" :aria-disabled="!workbenchLaunchEnabled">
Workbench 执行
</button>
<p class="launch-blocker" data-testid="mdtodo-workbench-launch-blocker">{{ workbenchLaunchEnabled ? '公共 Workbench Launch API 已可用。' : 'Workbench Launch 在 P3 接入;P2 只展示解耦入口和 capability。' }}</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>
</ul>
<p v-else>暂无关联 session</p>
</section>
</template>
</aside>
</section>
</template>
</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-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,
.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[data-status="done"] { border-color: #86efac; background: #dcfce7; color: #166534; }
.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-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,
.mdtodo-workspace { grid-template-columns: 1fr; }
.mdtodo-workspace { min-height: auto; }
}
</style>
@@ -0,0 +1,213 @@
<!-- SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo. -->
<!-- Responsibility: Project-management root page consuming only public project-management API DTOs. -->
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { projectManagementAPI, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, 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 loading = ref(false);
const error = ref<string | null>(null);
const apiError = ref<ApiError | null>(null);
const diagnostic = ref<ErrorDiagnostic | null>(null);
const navigation = ref<ProjectNavigationResponse | null>(null);
const projects = ref<ProjectRecord[]>([]);
const sources = ref<ProjectSource[]>([]);
const tasks = ref<MdtodoTaskRecord[]>([]);
const links = ref<ProjectWorkbenchLinkRecord[]>([]);
const sourceById = computed(() => new Map(sources.value.map((source) => [source.sourceId, source])));
const recentTasks = computed(() => tasks.value.slice(0, 6));
const activeSourceCount = computed(() => sources.value.filter((source) => source.status !== "blocked" && source.status !== "error").length);
const projection = computed(() => navigation.value?.projection ?? null);
onMounted(() => void loadProjects());
async function loadProjects(): Promise<void> {
loading.value = true;
error.value = null;
apiError.value = null;
diagnostic.value = null;
try {
const [navigationResponse, projectResponse, sourceResponse, taskResponse, linkResponse] = await Promise.all([
projectManagementAPI.navigation(),
projectManagementAPI.projects(),
projectManagementAPI.sources(),
projectManagementAPI.tasks(),
projectManagementAPI.workbenchLinks()
]);
for (const response of [navigationResponse, projectResponse, sourceResponse, taskResponse, linkResponse]) {
if (!response.ok) throw response;
}
navigation.value = navigationResponse.data;
projects.value = projectResponse.data?.projects ?? [];
sources.value = sourceResponse.data?.sources ?? [];
tasks.value = taskResponse.data?.tasks ?? [];
links.value = linkResponse.data?.links ?? [];
} catch (err) {
const context = projectApiError(err);
error.value = context.error;
apiError.value = context.apiError;
diagnostic.value = context.diagnostic;
} finally {
loading.value = false;
}
}
function projectTitle(project: ProjectRecord): string {
return project.name || project.title || project.projectId;
}
function projectSources(project: ProjectRecord): ProjectSource[] {
return (project.sourceIds ?? []).map((sourceId) => sourceById.value.get(sourceId)).filter((source): source is ProjectSource => Boolean(source));
}
function statusTone(value?: string | null): string {
if (value === "active" || value === "ok" || value === "ready") return "ok";
if (value === "blocked" || value === "error" || value === "failed") return "error";
return "pending";
}
function displayCount(value?: number | null): string {
return Number.isFinite(value) ? String(value) : "0";
}
function taskStatusLabel(value?: string | null): string {
if (value === "done") return "完成";
if (value === "blocked") return "阻塞";
if (value === "open") return "待办";
return value || "未知";
}
function projectApiError(err: unknown): { error: string; apiError: ApiError | null; diagnostic: ErrorDiagnostic | null } {
if (err && typeof err === "object" && "error" in err) {
const result = err as { error?: string | null; apiError?: ApiError | null; diagnostic?: ErrorDiagnostic | null; status?: number };
return { error: result.error || `HTTP ${result.status ?? "unknown"}`, apiError: result.apiError ?? null, diagnostic: result.diagnostic ?? null };
}
return { error: err instanceof Error ? err.message : String(err || "项目管理加载失败"), apiError: null, diagnostic: null };
}
</script>
<template>
<section class="route-stack projects-page" data-testid="project-management-root">
<PageHeader eyebrow="Project Management" title="项目" description="查看项目、任务来源、最近任务和关联工作台摘要。" />
<LoadingState v-if="loading && !projects.length" label="加载项目管理投影" />
<section v-else-if="error" class="data-panel projects-error" data-testid="project-management-error">
<EmptyState title="项目管理加载失败" :description="error" :api-error="apiError" :diagnostic="diagnostic" action-label="重试" @action="loadProjects" />
</section>
<template v-else>
<section class="project-summary-grid" aria-label="项目管理摘要" data-testid="project-management-summary">
<article class="metric-card"><span>Projects</span><strong>{{ projects.length }}</strong><small>{{ navigation?.serviceId || 'hwlab-project-management' }}</small></article>
<article class="metric-card"><span>Sources</span><strong>{{ activeSourceCount }} / {{ sources.length }}</strong><small>{{ navigation?.navigation?.capabilities?.sourceOfTruth || 'markdown-files' }}</small></article>
<article class="metric-card"><span>Tasks</span><strong>{{ displayCount(projection?.taskCount ?? tasks.length) }}</strong><small>documents {{ displayCount(projection?.documentCount) }}</small></article>
<article class="metric-card"><span>Workbench Links</span><strong>{{ links.length }}</strong><small>{{ navigation?.navigation?.capabilities?.workbenchLinks ? 'link projection ready' : 'link projection pending' }}</small></article>
</section>
<section class="project-root-grid">
<section class="data-panel project-list-panel" data-testid="project-list">
<header class="project-panel-header">
<div><h2>项目列表</h2><p> `/v1/project-management/projects` 返回</p></div>
<RouterLink class="btn btn-primary btn-sm" to="/projects/mdtodo" data-testid="open-mdtodo-page">打开 MDTODO</RouterLink>
</header>
<EmptyState v-if="!projects.length" title="暂无项目" description="项目管理服务已就绪,但当前 actor 没有可见项目。" />
<article v-for="project in projects" v-else :key="project.projectId" class="project-card" :data-project-id="project.projectId">
<div class="project-card-main">
<span class="pm-status" :data-tone="statusTone(project.status)">{{ project.status || 'unknown' }}</span>
<h3>{{ projectTitle(project) }}</h3>
<p>{{ project.projectId }}</p>
</div>
<div class="project-source-chips" aria-label="项目 source">
<span v-for="source in projectSources(project)" :key="source.sourceId" class="source-chip" :data-status="source.status || 'unknown'">{{ source.displayName || source.sourceId }}</span>
</div>
</article>
</section>
<section class="data-panel project-source-panel" data-testid="project-source-list">
<header class="project-panel-header"><div><h2>Source 状态</h2><p>显示当前可见 source 的摘要和投影状态</p></div></header>
<ul v-if="sources.length" class="source-list">
<li v-for="source in sources" :key="source.sourceId">
<span class="pm-status" :data-tone="statusTone(source.status)">{{ source.status || 'unknown' }}</span>
<strong>{{ source.displayName || source.sourceId }}</strong>
<small>{{ source.kind || 'mdtodo' }} / {{ source.projectId || '-' }}</small>
</li>
</ul>
<EmptyState v-else title="暂无 source" description="当前 ProjectSource registry 没有可见 source。" />
</section>
</section>
<section class="project-root-grid lower">
<section class="data-panel project-task-panel" data-testid="project-recent-tasks">
<header class="project-panel-header"><div><h2>最近任务</h2><p>只展示任务投影摘要和 opaque taskRef 片段</p></div></header>
<ul v-if="recentTasks.length" class="task-preview-list">
<li v-for="task in recentTasks" :key="task.taskRef">
<span class="task-status" :data-status="task.status || 'unknown'">{{ taskStatusLabel(task.status) }}</span>
<strong>{{ task.title || task.taskId || 'Untitled task' }}</strong>
<code>{{ task.taskRef }}</code>
</li>
</ul>
<EmptyState v-else title="暂无任务投影" description="MDTODO source 当前没有可展示任务,或尚未发现 Markdown TODO 文件。" />
</section>
<section class="data-panel project-link-panel" data-testid="project-workbench-links">
<header class="project-panel-header"><div><h2>Workbench Link</h2><p>P2 只展示 link 投影启动动作在 P3 接入</p></div></header>
<ul v-if="links.length" class="link-list">
<li v-for="link in links" :key="link.linkId"><strong>{{ link.sessionId || link.linkId }}</strong><small>{{ link.taskRef || link.projectId || '-' }}</small></li>
</ul>
<EmptyState v-else title="暂无 Workbench 关联" description="完成 P3 后,任务启动的 session 会在这里出现。" />
</section>
</section>
</template>
</section>
</template>
<style scoped>
.projects-page { min-width: 0; }
.project-summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.project-summary-grid .metric-card strong { color: #0f172a; font-size: 28px; line-height: 1.15; }
.project-summary-grid .metric-card small { min-width: 0; overflow: hidden; color: #64748b; text-overflow: ellipsis; white-space: nowrap; }
.project-root-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr); gap: 12px; }
.project-root-grid.lower { grid-template-columns: minmax(0, 1fr) minmax(320px, 0.72fr); }
.project-list-panel,
.project-source-panel,
.project-task-panel,
.project-link-panel,
.projects-error { display: grid; min-width: 0; align-content: start; gap: 12px; padding: 14px; }
.project-panel-header { display: flex; min-width: 0; align-items: flex-start; justify-content: space-between; gap: 12px; }
.project-panel-header h2 { margin: 0; color: #0f172a; font-size: 16px; line-height: 1.2; }
.project-panel-header p { margin: 4px 0 0; color: #64748b; font-size: 12px; line-height: 1.45; }
.project-card { display: grid; gap: 10px; border: 1px solid #d8e1eb; border-radius: 8px; background: #f8fafc; padding: 12px; }
.project-card-main { display: grid; min-width: 0; gap: 6px; }
.project-card h3 { margin: 0; color: #0f172a; font-size: 18px; line-height: 1.2; }
.project-card p { margin: 0; color: #64748b; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
.project-source-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.source-chip,
.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; }
.source-chip { background: white; color: #334155; }
.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; }
.source-list,
.task-preview-list,
.link-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.source-list li,
.task-preview-list li,
.link-list li { display: grid; min-width: 0; gap: 5px; border: 1px solid #e2e8f0; border-radius: 8px; background: #ffffff; padding: 10px; }
.source-list strong,
.task-preview-list strong,
.link-list strong { min-width: 0; overflow: hidden; color: #0f172a; text-overflow: ellipsis; white-space: nowrap; }
.source-list small,
.link-list small { min-width: 0; overflow: hidden; color: #64748b; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.task-preview-list code { min-width: 0; overflow: hidden; color: #475569; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.task-status[data-status="done"] { border-color: #86efac; background: #dcfce7; color: #166534; }
.task-status[data-status="blocked"] { border-color: #fed7aa; background: #fff7ed; color: #9a3412; }
.task-status { border-color: #bae6fd; background: #f0f9ff; color: #075985; }
@media (max-width: 1180px) {
.project-summary-grid,
.project-root-grid,
.project-root-grid.lower { grid-template-columns: 1fr; }
}
</style>
@@ -0,0 +1,27 @@
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo.
// Responsibility: Fake-server browser regression for project root and MDTODO pages.
import { expect, saveScreenshot, test } from "../fixtures/test";
test.describe("project management pages", () => {
test("renders project root and MDTODO DTOs without Workbench embedding", async ({ page }, testInfo) => {
await page.goto("/projects");
await expect(page.getByTestId("project-management-root")).toBeVisible();
await expect(page.getByTestId("project-management-summary")).toContainText("Projects");
await expect(page.getByTestId("project-list")).toContainText("HWLAB MDTODO");
await expect(page.getByTestId("project-source-list")).toContainText("mdtodo-file-tree");
await expect(page.getByText("[redacted]")).toHaveCount(0);
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-task-tree")).toContainText("实现项目根导航");
await expect(page.getByTestId("mdtodo-task-detail")).toContainText("mdtodo:hwlab-v03-mdtodo:file_plan:t1");
await expect(page.getByTestId("mdtodo-workbench-link-summary")).toContainText("ses_project_seed");
await expect(page.getByTestId("mdtodo-workbench-launch")).toBeDisabled();
await expect(page.locator("iframe")).toHaveCount(0);
await saveScreenshot(page, testInfo, "project-management-mdtodo");
});
});