diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 9b4758e4..c3ff3395 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -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 = [ diff --git a/web/hwlab-cloud-web/src/api/index.ts b/web/hwlab-cloud-web/src/api/index.ts index 987d7824..706c66df 100644 --- a/web/hwlab-cloud-web/src/api/index.ts +++ b/web/hwlab-cloud-web/src/api/index.ts @@ -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> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }), health: (): Promise> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }), diff --git a/web/hwlab-cloud-web/src/api/projectManagement.ts b/web/hwlab-cloud-web/src/api/projectManagement.ts new file mode 100644 index 00000000..46961487 --- /dev/null +++ b/web/hwlab-cloud-web/src/api/projectManagement.ts @@ -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> => fetchJson("/v1/project-management/navigation", { timeoutMs: 12000, timeoutName: "project navigation" }), + projects: (): Promise> => fetchJson("/v1/project-management/projects", { timeoutMs: 12000, timeoutName: "project list" }), + sources: (): Promise> => fetchJson("/v1/project-management/mdtodo/sources", { timeoutMs: 12000, timeoutName: "project mdtodo sources" }), + files: (sourceId?: string | null): Promise> => fetchJson(`/v1/project-management/mdtodo/files${queryString({ sourceId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo files" }), + tasks: (query: MdtodoTaskQuery = {}): Promise> => 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> => 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 { + 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}` : ""; +} diff --git a/web/hwlab-cloud-web/src/components/layout/AppShell.vue b/web/hwlab-cloud-web/src/components/layout/AppShell.vue index 47e1d7f2..4787c932 100644 --- a/web/hwlab-cloud-web/src/components/layout/AppShell.vue +++ b/web/hwlab-cloud-web/src/components/layout/AppShell.vue @@ -1,3 +1,6 @@ + + + + + + + diff --git a/web/hwlab-cloud-web/src/views/projects/ProjectsView.vue b/web/hwlab-cloud-web/src/views/projects/ProjectsView.vue new file mode 100644 index 00000000..e4ef6c81 --- /dev/null +++ b/web/hwlab-cloud-web/src/views/projects/ProjectsView.vue @@ -0,0 +1,213 @@ + + + + + + + + diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/project-management.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/project-management.spec.ts new file mode 100644 index 00000000..d9512b6f --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/project-management.spec.ts @@ -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"); + }); +});