Merge pull request #2164 from pikasTech/fix/2163-mdtodo-active-edit
PJ2026-01040401-P4 MDTODO 主动编辑闭环
This commit is contained in:
@@ -55,7 +55,7 @@ test("project management app exposes MDTODO read model and Workbench link writes
|
||||
const taskLinks = await json(app, `/v1/project-management/workbench-links?taskRef=${encodeURIComponent(tasks.tasks[0].taskRef)}`);
|
||||
expect(taskLinks.links[0].sessionId).toBe("ses_project_test");
|
||||
|
||||
const rejected = await app.fetch(new Request("http://service/v1/project-management/mdtodo/tasks", { method: "POST" }));
|
||||
const rejected = await app.fetch(new Request("http://service/v1/project-management/mdtodo/tasks", { method: "PUT" }));
|
||||
expect(rejected.status).toBe(405);
|
||||
|
||||
await app.close();
|
||||
@@ -132,6 +132,43 @@ test("project management app configures HWPOD source and writes MDTODO content",
|
||||
["R1.1", "Child", "done"]
|
||||
]);
|
||||
|
||||
const rootTaskRef = `mdtodo:hwpod-mdtodo:${fileRef}:R1`;
|
||||
const updateTask = await app.fetch(jsonRequest(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(rootTaskRef)}`, {
|
||||
method: "PATCH",
|
||||
body: { expectedFingerprint: written.file.fingerprint, title: "Root from task API", status: "in_progress", body: "Task API body." }
|
||||
}));
|
||||
expect(updateTask.status).toBe(200);
|
||||
const updatedTask = await updateTask.json();
|
||||
expect(updatedTask.task.title).toBe("Root from task API");
|
||||
expect(updatedTask.task.status).toBe("in_progress");
|
||||
expect(await readFile(samplePath, "utf8")).toContain("## R1 Root from task API [in_progress]\n\nTask API body.");
|
||||
|
||||
const addSubtask = await app.fetch(jsonRequest("/v1/project-management/mdtodo/tasks", {
|
||||
method: "POST",
|
||||
body: { parentTaskRef: rootTaskRef, expectedFingerprint: updatedTask.file.fingerprint, title: "Added child", body: "Child body." }
|
||||
}));
|
||||
expect(addSubtask.status).toBe(201);
|
||||
const added = await addSubtask.json();
|
||||
expect(added.changedTaskId).toBe("R1.2");
|
||||
expect(added.task.taskRef).toBe(`mdtodo:hwpod-mdtodo:${fileRef}:R1.2`);
|
||||
|
||||
const continueTask = await app.fetch(jsonRequest("/v1/project-management/mdtodo/tasks", {
|
||||
method: "POST",
|
||||
body: { afterTaskRef: `mdtodo:hwpod-mdtodo:${fileRef}:R1.1`, expectedFingerprint: added.file.fingerprint, title: "Continued child" }
|
||||
}));
|
||||
expect(continueTask.status).toBe(201);
|
||||
const continued = await continueTask.json();
|
||||
expect(continued.changedTaskId).toBe("R1.3");
|
||||
|
||||
const deleteTask = await app.fetch(jsonRequest(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(added.task.taskRef)}`, {
|
||||
method: "DELETE",
|
||||
body: { expectedFingerprint: continued.file.fingerprint }
|
||||
}));
|
||||
expect(deleteTask.status).toBe(200);
|
||||
const deleted = await deleteTask.json();
|
||||
expect(deleted.affectedTaskIds).toEqual(["R1.2"]);
|
||||
expect(await readFile(samplePath, "utf8")).not.toContain("R1.2 Added child");
|
||||
|
||||
const conflict = await app.fetch(jsonRequest(`/v1/project-management/mdtodo/files/${fileRef}/content?sourceId=hwpod-mdtodo`, {
|
||||
method: "PATCH",
|
||||
body: { sourceId: "hwpod-mdtodo", expectedFingerprint: content.file.fingerprint, content: nextContent }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
import { mutateMdtodoDocument } from "./mdtodo.ts";
|
||||
import { createMdtodoSourceAdapter, normalizeProjectSourceInput, sourceProjection } from "./source-adapter.ts";
|
||||
|
||||
const contractVersion = "project-management-v1";
|
||||
@@ -98,6 +99,19 @@ export function createProjectManagementApp(options = {}) {
|
||||
await initialize();
|
||||
return await handleWriteMdtodoFile(request, url, store, sourceAdapter, fileContent.fileRef, actorFromRequest(request));
|
||||
}
|
||||
const taskAction = matchTaskAction(url.pathname);
|
||||
if (url.pathname === "/v1/project-management/mdtodo/tasks" && request.method === "POST") {
|
||||
await initialize();
|
||||
return await handleCreateMdtodoTask(request, store, sourceAdapter, actorFromRequest(request));
|
||||
}
|
||||
if (taskAction && request.method === "PATCH") {
|
||||
await initialize();
|
||||
return await handlePatchMdtodoTask(request, store, sourceAdapter, taskAction.taskRef, actorFromRequest(request));
|
||||
}
|
||||
if (taskAction && request.method === "DELETE") {
|
||||
await initialize();
|
||||
return await handleDeleteMdtodoTask(request, store, sourceAdapter, taskAction.taskRef, actorFromRequest(request));
|
||||
}
|
||||
if (request.method !== "GET" && request.method !== "HEAD") return jsonError(405, "method_not_allowed", "Project management API only accepts read requests plus Workbench link writes");
|
||||
|
||||
await initialize();
|
||||
@@ -305,6 +319,97 @@ async function handleWriteMdtodoFile(request, url, store, sourceAdapter, fileRef
|
||||
return json(200, envelope({ file, projection }));
|
||||
}
|
||||
|
||||
async function handlePatchMdtodoTask(request, store, sourceAdapter, taskRef, actor) {
|
||||
const body = await readJsonObject(request, 256 * 1024);
|
||||
if (!body.ok) return jsonError(body.code === "body_too_large" ? 413 : 400, body.code, body.message);
|
||||
const task = await getTaskByRef(store, taskRef);
|
||||
if (!task) return jsonError(404, "task_not_found", "MDTODO task was not found");
|
||||
const expectedFingerprint = safeOpaqueValue(body.value.expectedFingerprint ?? body.value.revision ?? body.value.expectedRevision);
|
||||
if (!expectedFingerprint) return jsonError(400, "expected_fingerprint_required", "expectedFingerprint is required for MDTODO task writes");
|
||||
const operations = [];
|
||||
if (typeof body.value.title === "string") operations.push({ type: "updateTitle", rxxId: task.taskId, title: body.value.title });
|
||||
if (typeof body.value.status === "string") operations.push({ type: "updateStatus", rxxId: task.taskId, status: body.value.status });
|
||||
if (typeof body.value.body === "string") operations.push({ type: "updateBody", rxxId: task.taskId, body: body.value.body });
|
||||
if (operations.length === 0) return jsonError(400, "mutation_required", "At least one MDTODO task field is required");
|
||||
return mutateTaskDocument({ store, sourceAdapter, sourceId: task.sourceId, fileRef: task.fileRef, expectedFingerprint, operations, auditAction: "mdtodo.task.update", targetTaskRef: taskRef, actor, status: 200 });
|
||||
}
|
||||
|
||||
async function handleCreateMdtodoTask(request, store, sourceAdapter, actor) {
|
||||
const body = await readJsonObject(request, 256 * 1024);
|
||||
if (!body.ok) return jsonError(body.code === "body_too_large" ? 413 : 400, body.code, body.message);
|
||||
const expectedFingerprint = safeOpaqueValue(body.value.expectedFingerprint ?? body.value.revision ?? body.value.expectedRevision);
|
||||
if (!expectedFingerprint) return jsonError(400, "expected_fingerprint_required", "expectedFingerprint is required for MDTODO task writes");
|
||||
const title = shortText(body.value.title, 500);
|
||||
if (!title) return jsonError(400, "title_required", "title is required for MDTODO task creation");
|
||||
const parentTask = body.value.parentTaskRef ? await getTaskByRef(store, safeOpaqueValue(body.value.parentTaskRef)) : null;
|
||||
const afterTask = body.value.afterTaskRef ? await getTaskByRef(store, safeOpaqueValue(body.value.afterTaskRef)) : null;
|
||||
if (body.value.parentTaskRef && !parentTask) return jsonError(404, "parent_task_not_found", "Parent MDTODO task was not found");
|
||||
if (body.value.afterTaskRef && !afterTask) return jsonError(404, "after_task_not_found", "Continuation MDTODO task was not found");
|
||||
const sourceId = parentTask?.sourceId ?? afterTask?.sourceId ?? safeToken(body.value.sourceId, null);
|
||||
const fileRef = parentTask?.fileRef ?? afterTask?.fileRef ?? safeToken(body.value.fileRef, null);
|
||||
if (!sourceId || !fileRef) return jsonError(400, "task_target_required", "sourceId and fileRef are required for root MDTODO task creation");
|
||||
const operation = {
|
||||
type: "addTask",
|
||||
title,
|
||||
status: body.value.status ?? "open",
|
||||
body: typeof body.value.body === "string" ? body.value.body : "",
|
||||
parentRxxId: parentTask?.taskId ?? null,
|
||||
afterRxxId: afterTask?.taskId ?? null
|
||||
};
|
||||
return mutateTaskDocument({ store, sourceAdapter, sourceId, fileRef, expectedFingerprint, operations: [operation], auditAction: "mdtodo.task.create", targetTaskRef: parentTask?.taskRef ?? afterTask?.taskRef ?? `${sourceId}:${fileRef}`, actor, status: 201 });
|
||||
}
|
||||
|
||||
async function handleDeleteMdtodoTask(request, store, sourceAdapter, taskRef, actor) {
|
||||
const body = await readJsonObject(request, 32768);
|
||||
if (!body.ok) return jsonError(400, body.code, body.message);
|
||||
const task = await getTaskByRef(store, taskRef);
|
||||
if (!task) return jsonError(404, "task_not_found", "MDTODO task was not found");
|
||||
const expectedFingerprint = safeOpaqueValue(body.value.expectedFingerprint ?? body.value.revision ?? body.value.expectedRevision);
|
||||
if (!expectedFingerprint) return jsonError(400, "expected_fingerprint_required", "expectedFingerprint is required for MDTODO task writes");
|
||||
return mutateTaskDocument({ store, sourceAdapter, sourceId: task.sourceId, fileRef: task.fileRef, expectedFingerprint, operations: [{ type: "deleteTask", rxxId: task.taskId }], auditAction: "mdtodo.task.delete", targetTaskRef: taskRef, actor, status: 200 });
|
||||
}
|
||||
|
||||
async function mutateTaskDocument({ store, sourceAdapter, sourceId, fileRef, expectedFingerprint, operations, auditAction, targetTaskRef, actor, status }) {
|
||||
const { source, document, error } = await resolveMdtodoDocument(store, sourceId, fileRef);
|
||||
if (error) return error;
|
||||
const current = await sourceAdapter.readFile(source, document.relativePath);
|
||||
let markdown = current.content;
|
||||
let mutation = null;
|
||||
for (const operation of operations) {
|
||||
mutation = mutateMdtodoDocument(markdown, { ...operation, expectedFingerprint: mutation ? null : expectedFingerprint }, { sourceId: source.sourceId, relativePath: document.relativePath, projectId: document.projectId });
|
||||
if (!mutation.ok) return jsonError(mutation.conflict ? 409 : 400, mutation.code ?? "mdtodo_mutation_failed", mutation.message ?? "MDTODO task mutation failed");
|
||||
markdown = mutation.markdown;
|
||||
}
|
||||
const file = await sourceAdapter.writeFile(source, document.relativePath, markdown, expectedFingerprint);
|
||||
const documents = await sourceAdapter.discover(source);
|
||||
await store.replaceProjection(source, documents);
|
||||
const projection = sourceProjection(source, documents);
|
||||
const tasks = await store.listTasks({ sourceId: source.sourceId, fileRef });
|
||||
const changedTaskRef = mutation?.changedTaskId ? `mdtodo:${source.sourceId}:${fileRef}:${mutation.changedTaskId}` : null;
|
||||
const task = changedTaskRef ? tasks.find((item) => item.taskRef === changedTaskRef) ?? null : null;
|
||||
await recordAuditEvent(store, { actor }, { action: auditAction, targetType: "mdtodo_task", targetId: targetTaskRef, outcome: "succeeded", sourceKind: source.sourceKind, fileRef, revision: file.revision, changedTaskId: mutation?.changedTaskId ?? null, affectedTaskIds: mutation?.affectedTaskIds ?? [] });
|
||||
return json(status, envelope({ task, affectedTaskIds: mutation?.affectedTaskIds ?? [], changedTaskId: mutation?.changedTaskId ?? null, file: fileSummary(file), projection }));
|
||||
}
|
||||
|
||||
async function getTaskByRef(store, taskRef) {
|
||||
const safeTaskRef = safeOpaqueValue(taskRef);
|
||||
if (!safeTaskRef) return null;
|
||||
return (await store.listTasks({})).find((task) => task.taskRef === safeTaskRef) ?? null;
|
||||
}
|
||||
|
||||
function fileSummary(file) {
|
||||
return {
|
||||
sourceId: file.sourceId,
|
||||
fileRef: file.fileRef,
|
||||
relativePath: file.relativePath,
|
||||
fingerprint: file.fingerprint,
|
||||
revision: file.revision,
|
||||
parserMode: file.parserMode,
|
||||
taskCount: file.taskCount,
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveMdtodoDocument(store, sourceId, fileRef) {
|
||||
const source = await getSource(store, sourceId);
|
||||
if (!source) return { error: jsonError(404, "source_not_found", "MDTODO source was not found") };
|
||||
@@ -395,6 +500,11 @@ function matchFileContent(pathname) {
|
||||
return match ? { fileRef: match[1] } : null;
|
||||
}
|
||||
|
||||
function matchTaskAction(pathname) {
|
||||
const match = /^\/v1\/project-management\/mdtodo\/tasks\/(.+)$/u.exec(pathname);
|
||||
return match ? { taskRef: decodeURIComponent(match[1]) } : null;
|
||||
}
|
||||
|
||||
function safeOpaqueValue(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return /^[A-Za-z0-9_.:/#-]{1,360}$/u.test(text) ? text : null;
|
||||
|
||||
@@ -32,6 +32,9 @@ interface ScenarioState {
|
||||
legacyRequestLedger: JsonRecord[];
|
||||
chatRequests: JsonRecord[];
|
||||
projectLinks: JsonRecord[];
|
||||
projectFiles: JsonRecord[];
|
||||
projectTasks: JsonRecord[];
|
||||
projectRevision: number;
|
||||
listOmitSelected: boolean;
|
||||
sessionDelayMs: number;
|
||||
sessionDetailDelayMs: number;
|
||||
@@ -290,7 +293,7 @@ function hwpodNodeOpsPayload(): JsonRecord {
|
||||
async function projectManagementPayload(request: IncomingMessage, response: ServerResponse, url: URL, method: string): Promise<void> {
|
||||
const path = url.pathname;
|
||||
const source = projectManagementSource();
|
||||
const files = projectManagementFiles();
|
||||
const files = state.projectFiles;
|
||||
const links = state.projectLinks;
|
||||
const tasks = projectManagementTasks(links);
|
||||
|
||||
@@ -320,6 +323,10 @@ async function projectManagementPayload(request: IncomingMessage, response: Serv
|
||||
state.projectLinks.unshift(link);
|
||||
return json(response, 201, projectManagementEnvelope({ link }));
|
||||
}
|
||||
const taskMutationMatch = path.match(/^\/v1\/project-management\/mdtodo\/tasks\/(.+)$/u);
|
||||
if (path === "/v1/project-management/mdtodo/tasks" && method === "POST") return projectManagementCreateTask(response, await readJson(request));
|
||||
if (taskMutationMatch && method === "PATCH") return projectManagementUpdateTask(response, decodeURIComponent(taskMutationMatch[1] ?? ""), await readJson(request));
|
||||
if (taskMutationMatch && method === "DELETE") return projectManagementDeleteTask(response, decodeURIComponent(taskMutationMatch[1] ?? ""), await readJson(request));
|
||||
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 });
|
||||
|
||||
if (path === "/v1/project-management" || path === "/v1/project-management/navigation") {
|
||||
@@ -378,19 +385,114 @@ function projectManagementFiles(): JsonRecord[] {
|
||||
}
|
||||
|
||||
function projectManagementTasks(links: JsonRecord[] = []): JsonRecord[] {
|
||||
const linkCount = (taskRef: string) => links.filter((link) => link.taskRef === taskRef).length;
|
||||
return state.projectTasks.map((task) => ({ ...task, linkCount: linkCount(String(task.taskRef ?? "")) }));
|
||||
}
|
||||
|
||||
function projectManagementSeedTasks(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: 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" }
|
||||
{ taskRef: r1, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "R1", title: "实现项目根导航", status: "done", parentTaskRef: null, depth: 0, lineNumber: 4, ordinal: 1, linkCount: linkCount(r1), sourceFingerprint: "sha256:e2e-plan", bodyPreview: "Root notes", updatedAt: "2026-06-25T09:00:00.000Z" },
|
||||
{ taskRef: r11, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "R1.1", title: "展示 MDTODO 任务详情", status: "open", parentTaskRef: r1, depth: 1, lineNumber: 8, ordinal: 2, linkCount: linkCount(r11), sourceFingerprint: "sha256:e2e-plan", bodyPreview: "Detail body", updatedAt: "2026-06-25T09:00:00.000Z" },
|
||||
{ taskRef: r111, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "R1.1.1", title: "Source 顶部控制", status: "in_progress", parentTaskRef: r11, depth: 2, lineNumber: 12, ordinal: 3, linkCount: linkCount(r111), sourceFingerprint: "sha256:e2e-plan", bodyPreview: "Toolbar body", updatedAt: "2026-06-25T09:00:00.000Z" },
|
||||
{ taskRef: r2, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_rollout", taskId: "R2", title: "D601 v03 页面验收", status: "blocked", parentTaskRef: null, depth: 0, lineNumber: 3, ordinal: 4, linkCount: linkCount(r2), sourceFingerprint: "sha256:e2e-rollout", bodyPreview: "Rollout body", updatedAt: "2026-06-25T09:00:00.000Z" }
|
||||
];
|
||||
}
|
||||
|
||||
function projectManagementUpdateTask(response: ServerResponse, taskRef: string, body: JsonRecord): void {
|
||||
const task = state.projectTasks.find((item) => item.taskRef === taskRef);
|
||||
if (!task) return json(response, 404, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "task_not_found" }, valuesRedacted: true });
|
||||
const file = projectFileForTask(task);
|
||||
if (!projectManagementExpectedFingerprintOk(response, file, body)) return;
|
||||
if (typeof body.title === "string" && body.title.trim()) task.title = body.title.trim();
|
||||
if (typeof body.status === "string" && body.status.trim()) task.status = body.status.trim();
|
||||
if (typeof body.body === "string") task.bodyPreview = body.body.slice(0, 240);
|
||||
task.updatedAt = new Date().toISOString();
|
||||
touchProjectFile(String(task.fileRef));
|
||||
return json(response, 200, projectManagementEnvelope({ task, file: projectFileForTask(task), changedTaskId: task.taskId, affectedTaskIds: [task.taskId] }));
|
||||
}
|
||||
|
||||
function projectManagementCreateTask(response: ServerResponse, body: JsonRecord): void {
|
||||
const parent = typeof body.parentTaskRef === "string" ? state.projectTasks.find((item) => item.taskRef === body.parentTaskRef) : null;
|
||||
const after = typeof body.afterTaskRef === "string" ? state.projectTasks.find((item) => item.taskRef === body.afterTaskRef) : null;
|
||||
const sourceId = String(parent?.sourceId ?? after?.sourceId ?? body.sourceId ?? "hwlab-v03-mdtodo");
|
||||
const fileRef = String(parent?.fileRef ?? after?.fileRef ?? body.fileRef ?? "file_plan");
|
||||
const file = state.projectFiles.find((item) => item.sourceId === sourceId && item.fileRef === fileRef);
|
||||
if (!file) return json(response, 404, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "file_not_found" }, valuesRedacted: true });
|
||||
if (!projectManagementExpectedFingerprintOk(response, file, body)) return;
|
||||
const title = String(body.title ?? "").trim();
|
||||
if (!title) return json(response, 400, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "title_required" }, valuesRedacted: true });
|
||||
const taskId = parent ? nextProjectChildTaskId(fileRef, String(parent.taskId)) : after ? nextProjectSiblingTaskId(fileRef, String(after.taskId)) : nextProjectRootTaskId(fileRef);
|
||||
const taskRef = `mdtodo:${sourceId}:${fileRef}:${taskId}`;
|
||||
const parentTaskRef = parent?.taskRef ?? after?.parentTaskRef ?? null;
|
||||
const task = { taskRef, projectId: "project_hwlab_v03", sourceId, fileRef, taskId, title, status: String(body.status ?? "open"), parentTaskRef, depth: taskId.split(".").length - 1, lineNumber: 20 + state.projectTasks.length, ordinal: state.projectTasks.length + 1, linkCount: 0, sourceFingerprint: file.fingerprint, bodyPreview: typeof body.body === "string" ? body.body.slice(0, 240) : "", updatedAt: new Date().toISOString() };
|
||||
state.projectTasks.push(task);
|
||||
touchProjectFile(fileRef);
|
||||
return json(response, 201, projectManagementEnvelope({ task, file: projectFileForTask(task), changedTaskId: taskId, affectedTaskIds: [taskId] }));
|
||||
}
|
||||
|
||||
function projectManagementDeleteTask(response: ServerResponse, taskRef: string, body: JsonRecord): void {
|
||||
const task = state.projectTasks.find((item) => item.taskRef === taskRef);
|
||||
if (!task) return json(response, 404, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "task_not_found" }, valuesRedacted: true });
|
||||
const file = projectFileForTask(task);
|
||||
if (!projectManagementExpectedFingerprintOk(response, file, body)) return;
|
||||
const prefix = `${task.taskId}.`;
|
||||
const affected = state.projectTasks.filter((item) => item.taskRef === taskRef || String(item.taskId).startsWith(prefix)).map((item) => String(item.taskId));
|
||||
state.projectTasks = state.projectTasks.filter((item) => !(item.taskRef === taskRef || String(item.taskId).startsWith(prefix)));
|
||||
touchProjectFile(String(task.fileRef));
|
||||
return json(response, 200, projectManagementEnvelope({ task: null, file: projectFileForTask(task), changedTaskId: task.taskId, affectedTaskIds: affected }));
|
||||
}
|
||||
|
||||
function projectManagementExpectedFingerprintOk(response: ServerResponse, file: JsonRecord, body: JsonRecord): boolean {
|
||||
const expected = String(body.expectedFingerprint ?? body.revision ?? "");
|
||||
if (!expected) {
|
||||
json(response, 400, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "expected_fingerprint_required" }, valuesRedacted: true });
|
||||
return false;
|
||||
}
|
||||
if (expected !== file.fingerprint) {
|
||||
json(response, 409, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "revision_conflict" }, valuesRedacted: true });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function projectFileForTask(task: JsonRecord): JsonRecord {
|
||||
const file = state.projectFiles.find((item) => item.sourceId === task.sourceId && item.fileRef === task.fileRef) ?? state.projectFiles[0];
|
||||
if (!file) throw new Error("project management fake-server file fixture is missing");
|
||||
return file;
|
||||
}
|
||||
|
||||
function touchProjectFile(fileRef: string): void {
|
||||
state.projectRevision += 1;
|
||||
const file = state.projectFiles.find((item) => item.fileRef === fileRef);
|
||||
if (!file) return;
|
||||
file.revision = Number(file.revision ?? 1) + 1;
|
||||
file.fingerprint = `sha256:e2e-${fileRef}-${state.projectRevision}`;
|
||||
file.taskCount = state.projectTasks.filter((task) => task.fileRef === fileRef).length;
|
||||
file.updatedAt = new Date().toISOString();
|
||||
for (const task of state.projectTasks.filter((item) => item.fileRef === fileRef)) task.sourceFingerprint = file.fingerprint;
|
||||
}
|
||||
|
||||
function nextProjectRootTaskId(fileRef: string): string {
|
||||
const next = Math.max(0, ...state.projectTasks.filter((task) => task.fileRef === fileRef && !String(task.taskId).includes(".")).map((task) => Number(String(task.taskId).replace(/^R/u, "")) || 0)) + 1;
|
||||
return `R${next}`;
|
||||
}
|
||||
|
||||
function nextProjectChildTaskId(fileRef: string, parentTaskId: string): string {
|
||||
const prefix = `${parentTaskId}.`;
|
||||
const next = Math.max(0, ...state.projectTasks.filter((task) => task.fileRef === fileRef && String(task.taskId).startsWith(prefix)).map((task) => Number(String(task.taskId).slice(prefix.length).split(".")[0]) || 0)) + 1;
|
||||
return `${parentTaskId}.${next}`;
|
||||
}
|
||||
|
||||
function nextProjectSiblingTaskId(fileRef: string, afterTaskId: string): string {
|
||||
const parent = afterTaskId.includes(".") ? afterTaskId.split(".").slice(0, -1).join(".") : "";
|
||||
return parent ? nextProjectChildTaskId(fileRef, parent) : nextProjectRootTaskId(fileRef);
|
||||
}
|
||||
|
||||
function projectManagementSeedLinks(): JsonRecord[] {
|
||||
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" }];
|
||||
}
|
||||
@@ -911,6 +1013,7 @@ function createScenarioState(scenarioId: string): ScenarioState {
|
||||
? "ses_running"
|
||||
: base.selectedSessionId;
|
||||
const staleTraceId = id === "stale-nested-trace" || id === "stale-submit-restore" ? "trc_stale_502" : null;
|
||||
const projectLinks = projectManagementSeedLinks();
|
||||
return {
|
||||
scenarioId: id,
|
||||
providerProfile: base.providerProfile,
|
||||
@@ -920,7 +1023,10 @@ function createScenarioState(scenarioId: string): ScenarioState {
|
||||
requestLedger: [],
|
||||
legacyRequestLedger: [],
|
||||
chatRequests: [],
|
||||
projectLinks: projectManagementSeedLinks(),
|
||||
projectLinks,
|
||||
projectFiles: projectManagementFiles(),
|
||||
projectTasks: projectManagementSeedTasks(projectLinks),
|
||||
projectRevision: 1,
|
||||
listOmitSelected: id === "selected-missing-from-list",
|
||||
sessionDelayMs: id === "loading" ? 2_500 : 0,
|
||||
sessionDetailDelayMs: id === "legacy-cnv-deeplink-canonical" ? 1_500 : id === "session-switch-delayed-detail-frame" ? 900 : id === "submit-authority-race" ? 1_000 : 0,
|
||||
|
||||
@@ -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 ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement";
|
||||
export { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement";
|
||||
|
||||
import { fetchJson } from "./client";
|
||||
import { accessAPI } from "./access";
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface MdtodoTaskRecord {
|
||||
sourceId?: string;
|
||||
fileRef?: string;
|
||||
taskId?: string;
|
||||
rxxId?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
parentTaskRef?: string | null;
|
||||
@@ -110,6 +111,9 @@ export interface MdtodoTaskRecord {
|
||||
lineNumber?: number;
|
||||
ordinal?: number;
|
||||
linkCount?: number;
|
||||
sourceFingerprint?: string;
|
||||
bodyPreview?: string;
|
||||
parserMode?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
@@ -132,6 +136,24 @@ export interface ProjectSourceProbeResponse extends ProjectManagementEnvelope {
|
||||
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 MdtodoTaskMutationInput {
|
||||
sourceId?: string;
|
||||
fileRef?: string;
|
||||
parentTaskRef?: string;
|
||||
afterTaskRef?: string;
|
||||
expectedFingerprint?: string;
|
||||
revision?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
body?: string;
|
||||
}
|
||||
export interface MdtodoTaskMutationResponse extends ProjectManagementEnvelope {
|
||||
task?: MdtodoTaskRecord | null;
|
||||
changedTaskId?: string | null;
|
||||
affectedTaskIds?: string[];
|
||||
file?: MdtodoFileRecord & { parserMode?: string; valuesRedacted?: boolean };
|
||||
projection?: ProjectProjectionSummary;
|
||||
}
|
||||
export interface ProjectWorkbenchLinkListResponse extends ProjectManagementEnvelope { links?: ProjectWorkbenchLinkRecord[] }
|
||||
|
||||
export interface MdtodoTaskQuery {
|
||||
@@ -156,6 +178,9 @@ export const projectManagementAPI = {
|
||||
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" }),
|
||||
updateTask: (taskRef: string, input: MdtodoTaskMutationInput): Promise<ApiResult<MdtodoTaskMutationResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(taskRef)}`, { method: "PATCH", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task update" }),
|
||||
createTask: (input: MdtodoTaskMutationInput): Promise<ApiResult<MdtodoTaskMutationResponse>> => fetchJson("/v1/project-management/mdtodo/tasks", { method: "POST", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task create" }),
|
||||
deleteTask: (taskRef: string, input: MdtodoTaskMutationInput): Promise<ApiResult<MdtodoTaskMutationResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(taskRef)}`, { method: "DELETE", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task delete" }),
|
||||
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" })
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<script setup lang="ts">
|
||||
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 ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "@/api";
|
||||
import { projectManagementAPI, workbenchAPI, type MdtodoFileRecord, type MdtodoTaskMutationInput, 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,12 +15,15 @@ const loading = ref(false);
|
||||
const taskLoading = ref(false);
|
||||
const linkLoading = ref(false);
|
||||
const launchLoading = ref(false);
|
||||
const taskMutationLoading = 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 taskMutationError = ref<string | null>(null);
|
||||
const taskMutationMessage = ref<string | null>(null);
|
||||
const sourceMessage = ref<string | null>(null);
|
||||
const sourceError = ref<string | null>(null);
|
||||
const error = ref<string | null>(null);
|
||||
@@ -37,6 +40,12 @@ const selectedTaskRef = ref<string | null>(null);
|
||||
const taskSearch = ref("");
|
||||
const taskStatusFilter = ref("all");
|
||||
const collapsedTaskRefs = ref<Set<string>>(new Set());
|
||||
const editTitle = ref("");
|
||||
const editStatus = ref("open");
|
||||
const editBody = ref("");
|
||||
const newTaskTitle = ref("");
|
||||
const newTaskBody = ref("");
|
||||
const deleteConfirm = ref(false);
|
||||
|
||||
const sourceForm = reactive<ProjectSourceInput>({
|
||||
sourceId: "d601-f103-v2-mdtodo",
|
||||
@@ -105,6 +114,8 @@ watch(selectedTaskRef, async (taskRef) => {
|
||||
await loadLinks(taskRef);
|
||||
});
|
||||
|
||||
watch(selectedTask, (task) => resetTaskEditor(task), { immediate: true });
|
||||
|
||||
async function loadPage(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
@@ -263,6 +274,100 @@ async function reindexSelectedSource(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTaskBasics(): Promise<void> {
|
||||
const task = selectedTask.value;
|
||||
if (!task) return;
|
||||
const input: MdtodoTaskMutationInput = { expectedFingerprint: taskFingerprint() };
|
||||
if (editTitle.value.trim() && editTitle.value.trim() !== (task.title || "")) input.title = editTitle.value.trim();
|
||||
if (editStatus.value && editStatus.value !== (task.status || "open")) input.status = editStatus.value;
|
||||
if (!input.title && !input.status) {
|
||||
taskMutationMessage.value = "任务标题和状态没有变化";
|
||||
taskMutationError.value = null;
|
||||
return;
|
||||
}
|
||||
await runTaskMutation(() => projectManagementAPI.updateTask(task.taskRef, input), task.taskRef, "任务已保存");
|
||||
}
|
||||
|
||||
async function saveTaskBody(): Promise<void> {
|
||||
const task = selectedTask.value;
|
||||
if (!task) return;
|
||||
await runTaskMutation(() => projectManagementAPI.updateTask(task.taskRef, { expectedFingerprint: taskFingerprint(), body: editBody.value }), task.taskRef, "正文已保存");
|
||||
}
|
||||
|
||||
async function createTask(kind: "root" | "subtask" | "continue"): Promise<void> {
|
||||
const title = newTaskTitle.value.trim();
|
||||
if (!title) {
|
||||
taskMutationError.value = "新任务标题不能为空";
|
||||
taskMutationMessage.value = null;
|
||||
return;
|
||||
}
|
||||
const task = selectedTask.value;
|
||||
const input: MdtodoTaskMutationInput = {
|
||||
sourceId: selectedSourceId.value || undefined,
|
||||
fileRef: selectedFileRef.value || undefined,
|
||||
expectedFingerprint: taskFingerprint(),
|
||||
title,
|
||||
body: newTaskBody.value
|
||||
};
|
||||
if (kind === "subtask" && task) input.parentTaskRef = task.taskRef;
|
||||
if (kind === "continue" && task) input.afterTaskRef = task.taskRef;
|
||||
await runTaskMutation(() => projectManagementAPI.createTask(input), undefined, "任务已创建");
|
||||
if (!taskMutationError.value) {
|
||||
newTaskTitle.value = "";
|
||||
newTaskBody.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelectedTask(): Promise<void> {
|
||||
const task = selectedTask.value;
|
||||
if (!task) return;
|
||||
if (!deleteConfirm.value) {
|
||||
deleteConfirm.value = true;
|
||||
taskMutationMessage.value = "再次点击确认删除当前任务及子任务";
|
||||
taskMutationError.value = null;
|
||||
return;
|
||||
}
|
||||
await runTaskMutation(() => projectManagementAPI.deleteTask(task.taskRef, { expectedFingerprint: taskFingerprint() }), undefined, "任务已删除");
|
||||
deleteConfirm.value = false;
|
||||
}
|
||||
|
||||
async function runTaskMutation(action: () => ReturnType<typeof projectManagementAPI.updateTask>, preferredTaskRef: string | undefined, message: string): Promise<void> {
|
||||
const sourceId = selectedSourceId.value;
|
||||
if (!sourceId) return;
|
||||
const expectedFingerprint = taskFingerprint();
|
||||
if (!expectedFingerprint) {
|
||||
taskMutationError.value = "当前文件缺少 revision/fingerprint,不能写入";
|
||||
taskMutationMessage.value = null;
|
||||
return;
|
||||
}
|
||||
taskMutationLoading.value = true;
|
||||
taskMutationError.value = null;
|
||||
taskMutationMessage.value = null;
|
||||
try {
|
||||
const response = await action();
|
||||
if (!response.ok) throw response;
|
||||
const nextTaskRef = response.data?.task?.taskRef || preferredTaskRef;
|
||||
await loadFilesAndTasks(sourceId);
|
||||
if (nextTaskRef && tasks.value.some((task) => task.taskRef === nextTaskRef)) selectedTaskRef.value = nextTaskRef;
|
||||
taskMutationMessage.value = message;
|
||||
} catch (err) {
|
||||
taskMutationError.value = projectApiError(err).error;
|
||||
} finally {
|
||||
taskMutationLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function taskFingerprint(): string | undefined {
|
||||
return selectedTask.value?.sourceFingerprint || selectedFile.value?.fingerprint || undefined;
|
||||
}
|
||||
|
||||
function resetTaskEditor(task: MdtodoTaskRecord | null): void {
|
||||
editTitle.value = task?.title || "";
|
||||
editStatus.value = task?.status || "open";
|
||||
editBody.value = "";
|
||||
deleteConfirm.value = false;
|
||||
}
|
||||
|
||||
async function launchSelectedTask(): Promise<void> {
|
||||
const task = selectedTask.value;
|
||||
if (!task || !task.taskRef || !task.projectId || launchButtonDisabled.value) return;
|
||||
@@ -410,6 +515,35 @@ function displayDate(value?: string | null): string {
|
||||
<div><dt>File</dt><dd><code>{{ selectedFile?.relativePath || selectedTask.fileRef || '-' }}</code></dd></div>
|
||||
<div><dt>Updated</dt><dd>{{ displayDate(selectedTask.updatedAt) }}</dd></div>
|
||||
</dl>
|
||||
<section class="task-editor" data-testid="mdtodo-task-editor">
|
||||
<div class="editor-grid">
|
||||
<label><span>Title</span><input v-model="editTitle" data-testid="mdtodo-edit-title" :disabled="taskMutationLoading" /></label>
|
||||
<label><span>Status</span><select v-model="editStatus" data-testid="mdtodo-edit-status" :disabled="taskMutationLoading">
|
||||
<option value="open">待办</option>
|
||||
<option value="in_progress">进行中</option>
|
||||
<option value="done">完成</option>
|
||||
<option value="blocked">阻塞</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-save" :disabled="taskMutationLoading" @click="saveTaskBasics">保存标题/状态</button>
|
||||
<label class="editor-block"><span>Replace body</span><textarea v-model="editBody" data-testid="mdtodo-edit-body" :disabled="taskMutationLoading" rows="4" placeholder="输入新的正文块;留空并保存会清空当前任务正文。" /></label>
|
||||
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-body-save" :disabled="taskMutationLoading" @click="saveTaskBody">保存正文</button>
|
||||
<div class="task-create-box">
|
||||
<label><span>New task</span><input v-model="newTaskTitle" data-testid="mdtodo-new-title" :disabled="taskMutationLoading" placeholder="新任务标题" /></label>
|
||||
<textarea v-model="newTaskBody" data-testid="mdtodo-new-body" :disabled="taskMutationLoading" rows="3" placeholder="新任务正文,可留空。" />
|
||||
<div class="editor-actions">
|
||||
<button class="btn btn-secondary" type="button" data-testid="mdtodo-add-root" :disabled="taskMutationLoading" @click="createTask('root')">新增根任务</button>
|
||||
<button class="btn btn-secondary" type="button" data-testid="mdtodo-add-subtask" :disabled="taskMutationLoading" @click="createTask('subtask')">新增子任务</button>
|
||||
<button class="btn btn-secondary" type="button" data-testid="mdtodo-continue-task" :disabled="taskMutationLoading" @click="createTask('continue')">延续同级</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-actions danger-actions">
|
||||
<button class="btn btn-secondary" type="button" data-testid="mdtodo-delete-task" :disabled="taskMutationLoading" @click="deleteSelectedTask">{{ deleteConfirm ? '确认删除' : '删除任务' }}</button>
|
||||
<button v-if="deleteConfirm" class="btn btn-secondary" type="button" data-testid="mdtodo-delete-cancel" :disabled="taskMutationLoading" @click="deleteConfirm = false">取消</button>
|
||||
</div>
|
||||
<p v-if="taskMutationMessage" class="source-message" data-testid="mdtodo-task-mutation-message">{{ taskMutationMessage }}</p>
|
||||
<p v-if="taskMutationError" class="source-error" data-testid="mdtodo-task-mutation-error">{{ taskMutationError }}</p>
|
||||
</section>
|
||||
<button class="btn btn-primary" type="button" data-testid="mdtodo-workbench-launch" :disabled="launchButtonDisabled" :aria-disabled="launchButtonDisabled" @click="launchSelectedTask">
|
||||
{{ launchLoading ? '启动中' : '在 Workbench 执行' }}
|
||||
</button>
|
||||
@@ -508,6 +642,18 @@ function displayDate(value?: string | null): string {
|
||||
.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; }
|
||||
.task-editor { display: grid; gap: 10px; border: 1px solid #d8e2df; border-radius: 8px; background: #fbfefd; padding: 12px; }
|
||||
.editor-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(132px, 0.32fr); gap: 10px; }
|
||||
.task-editor label { display: grid; min-width: 0; gap: 5px; color: #52615c; font-size: 12px; font-weight: 800; }
|
||||
.task-editor input,
|
||||
.task-editor select,
|
||||
.task-editor textarea { width: 100%; min-width: 0; border: 1px solid #cbd8d4; border-radius: 6px; background: #fff; color: #14211d; font: inherit; padding: 8px 9px; }
|
||||
.task-editor textarea { min-height: 72px; resize: vertical; line-height: 1.4; }
|
||||
.editor-block { display: grid; gap: 5px; }
|
||||
.task-create-box { display: grid; gap: 8px; border-top: 1px solid #e2ece8; padding-top: 10px; }
|
||||
.editor-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.danger-actions { border-top: 1px solid #e2ece8; padding-top: 10px; }
|
||||
.danger-actions .btn { border-color: #fecaca; color: #991b1b; }
|
||||
.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; }
|
||||
@@ -532,6 +678,7 @@ function displayDate(value?: string | null): string {
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.source-form { grid-template-columns: 1fr; }
|
||||
.editor-grid { grid-template-columns: 1fr; }
|
||||
.task-row { grid-template-columns: minmax(48px, auto) minmax(0, 1fr); }
|
||||
.task-status { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@@ -40,6 +40,28 @@ test.describe("project management pages", () => {
|
||||
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 page.getByTestId("mdtodo-edit-title").fill("实现项目根导航 - edited");
|
||||
await page.getByTestId("mdtodo-edit-status").selectOption("in_progress");
|
||||
await page.getByTestId("mdtodo-edit-save").click();
|
||||
await expect(page.getByTestId("mdtodo-task-mutation-message")).toContainText("任务已保存");
|
||||
await expect(page.getByTestId("mdtodo-task-detail")).toContainText("实现项目根导航 - edited");
|
||||
await expect(page.locator('[data-task-id="R1"]')).toContainText("进行中");
|
||||
await page.getByTestId("mdtodo-edit-body").fill("Body saved from Playwright.");
|
||||
await page.getByTestId("mdtodo-edit-body-save").click();
|
||||
await expect(page.getByTestId("mdtodo-task-mutation-message")).toContainText("正文已保存");
|
||||
await page.getByTestId("mdtodo-new-title").fill("Playwright child");
|
||||
await page.getByTestId("mdtodo-new-body").fill("Child from E2E.");
|
||||
await page.getByTestId("mdtodo-add-subtask").click();
|
||||
await expect(page.getByTestId("mdtodo-task-mutation-message")).toContainText("任务已创建");
|
||||
await expect(page.locator('[data-task-id="R1.2"]')).toContainText("Playwright child");
|
||||
await page.getByTestId("mdtodo-new-title").fill("Playwright sibling");
|
||||
await page.getByTestId("mdtodo-continue-task").click();
|
||||
await expect(page.locator('[data-task-id="R1.3"]')).toContainText("Playwright sibling");
|
||||
await page.getByTestId("mdtodo-delete-task").click();
|
||||
await expect(page.getByTestId("mdtodo-task-mutation-message")).toContainText("再次点击确认删除");
|
||||
await page.getByTestId("mdtodo-delete-task").click();
|
||||
await expect(page.getByTestId("mdtodo-task-mutation-message")).toContainText("任务已删除");
|
||||
await expect(page.locator('[data-task-id="R1.3"]')).toHaveCount(0);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user