feat: make mdtodo web tasks operable
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
import { mutateMdtodoDocument, parseMdtodoDocument } from "./mdtodo.ts";
|
||||
import { mutateMdtodoDocument, parseMdtodoDocument, readMdtodoTaskDetail } from "./mdtodo.ts";
|
||||
|
||||
test("parseMdtodoDocument projects Rxx hierarchy without raw body leakage", () => {
|
||||
const document = parseMdtodoDocument(`# Demo
|
||||
@@ -34,6 +34,36 @@ Root notes with [context](https://example.test/context).
|
||||
expect(document.document.valuesRedacted).toBe(true);
|
||||
});
|
||||
|
||||
test("readMdtodoTaskDetail exposes Rxx body and safe Markdown report links", () => {
|
||||
const detail = readMdtodoTaskDetail(`# Demo
|
||||
|
||||
## R1 Root task
|
||||
|
||||
Task details
|
||||
|
||||
- report: [R1.1](./20260110_LOG/R1.1_log_implementation.md)
|
||||
- external: [context](https://example.test/context)
|
||||
|
||||
### R1.1 Child
|
||||
|
||||
Child details
|
||||
`, {
|
||||
sourceId: "demo",
|
||||
relativePath: "20260609_feedback.md",
|
||||
taskRef: "mdtodo:demo:file_plan:R1",
|
||||
fileRef: "file_plan",
|
||||
projectId: "project_test"
|
||||
});
|
||||
|
||||
expect(detail?.body).toContain("Task details");
|
||||
expect(detail?.body).not.toContain("Child details");
|
||||
expect(detail?.links.find((link) => link.label === "R1.1")).toMatchObject({
|
||||
kind: "markdown-report",
|
||||
relativePath: "20260110_LOG/R1.1_log_implementation.md"
|
||||
});
|
||||
expect(detail?.links.find((link) => link.label === "context")).toMatchObject({ kind: "external", relativePath: null });
|
||||
});
|
||||
|
||||
test("parseMdtodoDocument keeps legacy checkbox parsing explicit", () => {
|
||||
const document = parseMdtodoDocument(`# Demo
|
||||
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
/**
|
||||
* SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source.
|
||||
* SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source;
|
||||
* draft-2026-06-26-p1-mdtodo-web-operable.
|
||||
* Projects MDTODO Markdown source-of-truth into stable Rxx task projections and mutation helpers.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const excludedDirectories = new Set([
|
||||
".git",
|
||||
".worktree",
|
||||
".state",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
"coverage",
|
||||
"tmp",
|
||||
"vendor"
|
||||
]);
|
||||
|
||||
const taskLinePattern = /^(\s*)(?:[-*+]|\d+[.)])\s+\[([ xX-])\]\s+(.*)$/u;
|
||||
const rxxHeadingPattern = /^(#{2,})\s+(R\d+(?:\.\d+)*)(?:\s+(.*?))?\s*$/iu;
|
||||
const rxxIdPattern = /^R\d+(?:\.\d+)*$/iu;
|
||||
@@ -46,8 +35,7 @@ export async function discoverMdtodoDocuments(options = {}) {
|
||||
const sourceId = safeToken(options.sourceId, "default-mdtodo");
|
||||
const projectId = safeToken(options.projectId, null);
|
||||
const maxFiles = positiveInteger(options.maxFiles, 300);
|
||||
const files = [];
|
||||
await walkMarkdown(root, root, files, maxFiles);
|
||||
const files = await listDirectMarkdownFiles(root, maxFiles);
|
||||
const documents = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
@@ -98,6 +86,41 @@ export function parseMdtodoDocument(markdown, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function readMdtodoTaskDetail(markdown, options = {}) {
|
||||
const sourceId = safeToken(options.sourceId, "default-mdtodo");
|
||||
const relativePath = normalizeRelativePath(options.relativePath || "MDTODO.md");
|
||||
const fileRef = safeToken(options.fileRef, documentFileRef(relativePath));
|
||||
const projectId = safeToken(options.projectId, projectIdForRelativePath(relativePath));
|
||||
const rxxId = normalizeOptionalRxxId(options.rxxId ?? taskRxxIdFromRef(options.taskRef));
|
||||
if (!rxxId) return null;
|
||||
|
||||
const lines = String(markdown ?? "").split(/\r?\n/u);
|
||||
const block = collectRxxBlocks(lines).find((candidate) => candidate.rxxId === rxxId);
|
||||
if (!block) return null;
|
||||
|
||||
const body = taskBodyFromLines(lines, block);
|
||||
const bodyPreview = bodyPreviewFromLines(lines, block.lineIndex + 1, block.endLine);
|
||||
const title = cleanTaskTitle(block.title || bodyPreview || block.rxxId);
|
||||
return {
|
||||
taskRef: taskRefForRxx(sourceId, fileRef, block.rxxId),
|
||||
taskId: block.rxxId,
|
||||
rxxId: block.rxxId,
|
||||
projectId,
|
||||
sourceId,
|
||||
fileRef,
|
||||
relativePath,
|
||||
title,
|
||||
status: block.status,
|
||||
lineNumber: block.lineNumber,
|
||||
headingLevel: block.hashes.length,
|
||||
statusMarker: block.statusMarker,
|
||||
body,
|
||||
bodyPreview,
|
||||
links: markdownLinksFromBody(body, relativePath),
|
||||
valuesRedacted: false
|
||||
};
|
||||
}
|
||||
|
||||
export function mutateMdtodoDocument(markdown, operation = {}, options = {}) {
|
||||
const originalMarkdown = String(markdown ?? "");
|
||||
const previousFingerprint = sha256(originalMarkdown);
|
||||
@@ -425,24 +448,16 @@ function isMdtodoCandidate(filePath, markdown) {
|
||||
return basename.includes("todo") || basename.includes("mdtodo") || String(markdown ?? "").split(/\r?\n/u).some((line) => taskLinePattern.test(line) || rxxHeadingPattern.test(line));
|
||||
}
|
||||
|
||||
async function walkMarkdown(root, current, files, maxFiles) {
|
||||
if (files.length >= maxFiles) return;
|
||||
let entries;
|
||||
async function listDirectMarkdownFiles(root, maxFiles) {
|
||||
try {
|
||||
entries = await fs.readdir(current, { withFileTypes: true });
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.slice(0, maxFiles)
|
||||
.map((entry) => path.join(root, entry.name));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const entry of entries) {
|
||||
if (files.length >= maxFiles) return;
|
||||
const absolute = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (excludedDirectories.has(entry.name)) continue;
|
||||
await walkMarkdown(root, absolute, files, maxFiles);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) files.push(absolute);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +508,11 @@ function normalizeOptionalRxxId(value) {
|
||||
return rxxIdPattern.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function taskRxxIdFromRef(taskRef) {
|
||||
const parts = String(taskRef ?? "").split(":");
|
||||
return parts.length >= 4 ? parts.at(-1) : null;
|
||||
}
|
||||
|
||||
function normalizeStatus(value) {
|
||||
const key = String(value ?? "open").trim().toLowerCase().replace(/[\s-]+/gu, "_");
|
||||
return statusAliases.get(key) ?? null;
|
||||
@@ -519,6 +539,69 @@ function countMarkdownLinks(value) {
|
||||
return [...String(value ?? "").matchAll(markdownLinkPattern)].length;
|
||||
}
|
||||
|
||||
function markdownLinksFromBody(body, relativePath) {
|
||||
const links = [];
|
||||
const markdownPattern = /!?\[([^\]]*)\]\(([^)]+)\)/gu;
|
||||
let ordinal = 0;
|
||||
for (const match of String(body ?? "").matchAll(markdownPattern)) {
|
||||
ordinal += 1;
|
||||
const label = String(match[1] ?? "").trim();
|
||||
const href = cleanMarkdownHref(match[2]);
|
||||
const target = markdownReportTarget(relativePath, href);
|
||||
links.push({
|
||||
linkId: `lnk_${shortHash(`${relativePath}\n${href}\n${ordinal}`, 12)}`,
|
||||
label: label || href,
|
||||
href,
|
||||
kind: target ? "markdown-report" : linkKind(href),
|
||||
relativePath: target,
|
||||
ordinal,
|
||||
valuesRedacted: true
|
||||
});
|
||||
}
|
||||
|
||||
for (const match of String(body ?? "").matchAll(/https?:\/\/\S+/giu)) {
|
||||
ordinal += 1;
|
||||
const href = String(match[0] ?? "").replace(/[),.;]+$/u, "");
|
||||
links.push({
|
||||
linkId: `lnk_${shortHash(`${relativePath}\n${href}\n${ordinal}`, 12)}`,
|
||||
label: href,
|
||||
href,
|
||||
kind: "external",
|
||||
relativePath: null,
|
||||
ordinal,
|
||||
valuesRedacted: true
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
function cleanMarkdownHref(value) {
|
||||
const text = String(value ?? "").trim().replace(/^<|>$/gu, "");
|
||||
return text.split(/\s+/u)[0] ?? "";
|
||||
}
|
||||
|
||||
function markdownReportTarget(relativePath, href) {
|
||||
const raw = String(href ?? "").trim();
|
||||
if (!raw || raw.startsWith("#") || /^[a-z][a-z0-9+.-]*:/iu.test(raw) || raw.startsWith("/") || /^[A-Za-z]:\//u.test(raw)) return null;
|
||||
const withoutFragment = raw.split("#")[0]?.trim() ?? "";
|
||||
if (!withoutFragment.toLowerCase().endsWith(".md")) return null;
|
||||
const directory = path.posix.dirname(normalizeRelativePath(relativePath));
|
||||
const normalized = path.posix.normalize(path.posix.join(directory === "." ? "" : directory, withoutFragment));
|
||||
if (!normalized || normalized === "." || normalized.startsWith("../") || normalized === ".." || normalized.includes("\0")) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function linkKind(href) {
|
||||
if (/^https?:\/\//iu.test(String(href ?? ""))) return "external";
|
||||
if (String(href ?? "").startsWith("#")) return "anchor";
|
||||
return "file";
|
||||
}
|
||||
|
||||
function taskBodyFromLines(lines, block) {
|
||||
return lines.slice(block.lineIndex + 1, block.endLine).join("\n").trim();
|
||||
}
|
||||
|
||||
function bodyPreviewFromLines(lines, start, end) {
|
||||
for (const line of lines.slice(start, end)) {
|
||||
const text = String(line ?? "").trim();
|
||||
|
||||
@@ -108,12 +108,16 @@ test("project management app configures HWPOD source and writes MDTODO content",
|
||||
const hwpodRoot = join(root, "hwpod-workspace");
|
||||
const mdtodoDir = join(hwpodRoot, "docs", "MDTODO");
|
||||
const samplePath = join(mdtodoDir, "hwlab-v03-mdtodo-web-sample.md");
|
||||
const reportDir = join(mdtodoDir, "20260110_LOG");
|
||||
const reportPath = join(reportDir, "R1.1_log_implementation.md");
|
||||
const store = createProjectManagementStore({ kind: "memory" });
|
||||
try {
|
||||
await mkdir(defaultRoot, { recursive: true });
|
||||
await mkdir(mdtodoDir, { recursive: true });
|
||||
await mkdir(reportDir, { recursive: true });
|
||||
await writeFile(join(defaultRoot, "MDTODO.md"), "# Default\n\n## R1 Default\n", "utf8");
|
||||
await writeFile(samplePath, "# Sample\n\n## R1 Root\n\n### R1.1 Child [in_progress]\n", "utf8");
|
||||
await writeFile(samplePath, "# Sample\n\n## R1 Root\n\nSee [R1.1](./20260110_LOG/R1.1_log_implementation.md).\n\n### R1.1 Child [in_progress]\n", "utf8");
|
||||
await writeFile(reportPath, "# R1.1 implementation report\n\nReport body.", "utf8");
|
||||
const app = createProjectManagementApp({
|
||||
env: {},
|
||||
store,
|
||||
@@ -148,12 +152,20 @@ test("project management app configures HWPOD source and writes MDTODO content",
|
||||
expect(reindex.projection.taskCount).toBe(2);
|
||||
|
||||
const files = await json(app, "/v1/project-management/mdtodo/files?sourceId=hwpod-mdtodo");
|
||||
expect(files.files).toHaveLength(1);
|
||||
expect(files.files[0].relativePath).toBe("hwlab-v03-mdtodo-web-sample.md");
|
||||
const fileRef = files.files[0].fileRef;
|
||||
|
||||
const content = await json(app, `/v1/project-management/mdtodo/files/${fileRef}/content?sourceId=hwpod-mdtodo`);
|
||||
expect(content.file.content).toContain("## R1 Root");
|
||||
|
||||
const rootTaskRef = `mdtodo:hwpod-mdtodo:${fileRef}:R1`;
|
||||
const detail = await json(app, `/v1/project-management/mdtodo/task-detail?taskRef=${encodeURIComponent(rootTaskRef)}`);
|
||||
expect(detail.detail.body).toContain("See [R1.1]");
|
||||
expect(detail.detail.links[0]).toMatchObject({ kind: "markdown-report", relativePath: "20260110_LOG/R1.1_log_implementation.md" });
|
||||
const report = await json(app, `/v1/project-management/mdtodo/report-preview?taskRef=${encodeURIComponent(rootTaskRef)}&linkId=${encodeURIComponent(detail.detail.links[0].linkId)}`);
|
||||
expect(report.report.content).toContain("Report body.");
|
||||
|
||||
const nextContent = "# Sample\n\n## R1 Edited root\n\n### R1.1 Child [completed]\n";
|
||||
const write = await app.fetch(jsonRequest(`/v1/project-management/mdtodo/files/${fileRef}/content?sourceId=hwpod-mdtodo`, {
|
||||
method: "PATCH",
|
||||
@@ -170,7 +182,6 @@ 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." }
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { mutateMdtodoDocument } from "./mdtodo.ts";
|
||||
import { mutateMdtodoDocument, readMdtodoTaskDetail } from "./mdtodo.ts";
|
||||
import { createMdtodoSourceAdapter, normalizeProjectSourceInput, sourceProjection } from "./source-adapter.ts";
|
||||
|
||||
const contractVersion = "project-management-v1";
|
||||
@@ -147,6 +147,12 @@ export function createProjectManagementApp(options = {}) {
|
||||
if (fileContent) {
|
||||
return await handleReadMdtodoFile(url, store, sourceAdapter, fileContent.fileRef);
|
||||
}
|
||||
if (url.pathname === "/v1/project-management/mdtodo/task-detail") {
|
||||
return await handleReadMdtodoTaskDetail(url, store, sourceAdapter);
|
||||
}
|
||||
if (url.pathname === "/v1/project-management/mdtodo/report-preview") {
|
||||
return await handleReadMdtodoReportPreview(url, store, sourceAdapter);
|
||||
}
|
||||
if (url.pathname === "/v1/project-management/mdtodo/tasks") {
|
||||
const taskFilters = {
|
||||
sourceId: queryToken(url, "sourceId"),
|
||||
@@ -377,6 +383,61 @@ async function handleReadMdtodoFile(url, store, sourceAdapter, fileRef) {
|
||||
return json(200, envelope({ file }));
|
||||
}
|
||||
|
||||
async function handleReadMdtodoTaskDetail(url, store, sourceAdapter) {
|
||||
const taskRef = queryOpaque(url, "taskRef");
|
||||
if (!taskRef) return jsonError(400, "task_ref_required", "taskRef is required");
|
||||
const detail = await loadTaskDetail({ store, sourceAdapter, taskRef });
|
||||
if (detail.error) return detail.error;
|
||||
return json(200, envelope({
|
||||
task: { ...detail.task, bodyPreview: detail.detail.bodyPreview, linkCount: detail.detail.links.length },
|
||||
detail: detail.detail,
|
||||
file: fileSummary(detail.file)
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleReadMdtodoReportPreview(url, store, sourceAdapter) {
|
||||
const taskRef = queryOpaque(url, "taskRef");
|
||||
const linkId = queryToken(url, "linkId");
|
||||
if (!taskRef) return jsonError(400, "task_ref_required", "taskRef is required");
|
||||
if (!linkId) return jsonError(400, "link_id_required", "linkId is required");
|
||||
const detail = await loadTaskDetail({ store, sourceAdapter, taskRef });
|
||||
if (detail.error) return detail.error;
|
||||
const link = detail.detail.links.find((item) => item.linkId === linkId);
|
||||
if (!link) return jsonError(404, "report_link_not_found", "MDTODO report link was not found");
|
||||
if (link.kind !== "markdown-report" || !link.relativePath) return jsonError(400, "unsupported_report_link", "Only safe relative Markdown report links can be previewed");
|
||||
const report = await sourceAdapter.readLinkedMarkdown(detail.source, detail.document.relativePath, link.href);
|
||||
return json(200, envelope({
|
||||
task: detail.task,
|
||||
link,
|
||||
report: {
|
||||
...report,
|
||||
linkId: link.linkId,
|
||||
label: link.label,
|
||||
href: link.href,
|
||||
valuesRedacted: false
|
||||
},
|
||||
file: fileSummary(detail.file)
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadTaskDetail({ store, sourceAdapter, taskRef }) {
|
||||
const task = await getTaskByRef(store, taskRef);
|
||||
if (!task) return { error: jsonError(404, "task_not_found", "MDTODO task was not found") };
|
||||
const { source, document, error } = await resolveMdtodoDocument(store, task.sourceId, task.fileRef);
|
||||
if (error) return { error };
|
||||
const file = await sourceAdapter.readFile(source, document.relativePath);
|
||||
const detail = readMdtodoTaskDetail(file.content, {
|
||||
sourceId: source.sourceId,
|
||||
fileRef: document.fileRef,
|
||||
relativePath: document.relativePath,
|
||||
projectId: document.projectId,
|
||||
taskRef: task.taskRef,
|
||||
rxxId: task.taskId
|
||||
});
|
||||
if (!detail) return { error: jsonError(404, "task_detail_not_found", "MDTODO task detail was not found in source Markdown") };
|
||||
return { source, document, file, task, detail };
|
||||
}
|
||||
|
||||
async function handleWriteMdtodoFile(request, url, store, sourceAdapter, fileRef, actor) {
|
||||
const body = await readJsonObject(request, 1024 * 1024 + 4096);
|
||||
if (!body.ok) return jsonError(body.code === "body_too_large" ? 413 : 400, body.code, body.message);
|
||||
@@ -584,7 +645,7 @@ function matchTaskAction(pathname) {
|
||||
|
||||
function safeOpaqueValue(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return /^[A-Za-z0-9_.:/#-]{1,360}$/u.test(text) ? text : null;
|
||||
return /^[A-Za-z0-9_.:/#_-]{1,360}$/u.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function safeWorkbenchUrl(value) {
|
||||
@@ -618,7 +679,7 @@ function queryToken(url, name) {
|
||||
|
||||
function queryOpaque(url, name) {
|
||||
const value = url.searchParams.get(name);
|
||||
return /^[A-Za-z0-9_.:/#-]{1,320}$/u.test(String(value ?? "")) ? value : null;
|
||||
return /^[A-Za-z0-9_.:/#_-]{1,320}$/u.test(String(value ?? "")) ? value : null;
|
||||
}
|
||||
|
||||
function querySearch(url, name) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source.
|
||||
* SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source;
|
||||
* draft-2026-06-26-p1-mdtodo-web-operable.
|
||||
* Owns MDTODO Source normalization and bounded file access for local and HWPOD workspace sources.
|
||||
*/
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
@@ -40,6 +41,14 @@ export function createMdtodoSourceAdapter(options = {}) {
|
||||
return fileContentPayload(source, safePath, String(content ?? ""));
|
||||
},
|
||||
|
||||
async readLinkedMarkdown(source, documentRelativePath, href) {
|
||||
const safePath = normalizeLinkedMarkdownPath(documentRelativePath, href);
|
||||
const content = source.sourceKind === "hwpod-workspace"
|
||||
? (await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.cat", { path: hwpodWorkspacePath(source, safePath), maxBytes: maxContentBytes })).content
|
||||
: await readBoundedLocalMarkdown(source, safePath);
|
||||
return linkedMarkdownPayload(source, safePath, String(content ?? ""));
|
||||
},
|
||||
|
||||
async writeFile(source, relativePath, content, expectedFingerprint) {
|
||||
const safePath = normalizeMdtodoRelativePath(relativePath);
|
||||
const nextContent = String(content ?? "");
|
||||
@@ -145,22 +154,15 @@ async function discoverHwpodDocuments(hwpodNodeOpsHandler, source) {
|
||||
async function listHwpodMarkdownFiles(hwpodNodeOpsHandler, source) {
|
||||
const root = normalizeDirectoryRef(source.mdtodoRootRef ?? ".");
|
||||
const files = [];
|
||||
async function walk(relativeDir) {
|
||||
if (files.length >= source.maxFiles) return;
|
||||
const workspacePath = relativeDir ? joinRelative(root, relativeDir, { allowDirectory: true }) : root;
|
||||
const output = await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.ls", { path: workspacePath });
|
||||
const entries = Array.isArray(output.entries) ? output.entries : [];
|
||||
entries.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? "")));
|
||||
for (const entry of entries) {
|
||||
const name = String(entry.name ?? "");
|
||||
if (!name || isHiddenOrSecretSegment(name)) continue;
|
||||
const child = relativeDir ? `${relativeDir}/${name}` : name;
|
||||
if (entry.type === "dir") await walk(child);
|
||||
else if (entry.type === "file" && name.toLowerCase().endsWith(".md")) files.push(normalizeMdtodoRelativePath(child));
|
||||
if (files.length >= source.maxFiles) return;
|
||||
}
|
||||
const output = await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.ls", { path: root });
|
||||
const entries = Array.isArray(output.entries) ? output.entries : [];
|
||||
entries.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? "")));
|
||||
for (const entry of entries) {
|
||||
const name = String(entry.name ?? "");
|
||||
if (!name || isHiddenOrSecretSegment(name)) continue;
|
||||
if (entry.type === "file" && name.toLowerCase().endsWith(".md")) files.push(normalizeMdtodoRelativePath(name));
|
||||
if (files.length >= source.maxFiles) break;
|
||||
}
|
||||
await walk("");
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -199,6 +201,20 @@ function fileContentPayload(source, relativePath, content) {
|
||||
};
|
||||
}
|
||||
|
||||
function linkedMarkdownPayload(source, relativePath, content) {
|
||||
const fingerprint = sha256(content);
|
||||
return {
|
||||
sourceId: source.sourceId,
|
||||
relativePath,
|
||||
content,
|
||||
fingerprint,
|
||||
revision: fingerprint,
|
||||
byteCount: Buffer.byteLength(content, "utf8"),
|
||||
render: "markdown",
|
||||
valuesRedacted: false
|
||||
};
|
||||
}
|
||||
|
||||
function localRootForSource(source) {
|
||||
return path.resolve(String(source.rootRef ?? process.cwd()));
|
||||
}
|
||||
@@ -210,6 +226,13 @@ function localFilePathForSource(source, relativePath) {
|
||||
return target;
|
||||
}
|
||||
|
||||
async function readBoundedLocalMarkdown(source, relativePath) {
|
||||
const target = localFilePathForSource(source, relativePath);
|
||||
const stats = await fs.stat(target);
|
||||
if (stats.size > maxContentBytes) throw sourceError("mdtodo_content_too_large", "Markdown report content is too large", 413);
|
||||
return fs.readFile(target, "utf8");
|
||||
}
|
||||
|
||||
function hwpodWorkspacePath(source, relativePath) {
|
||||
return joinRelative(normalizeDirectoryRef(source.mdtodoRootRef ?? "."), normalizeMdtodoRelativePath(relativePath));
|
||||
}
|
||||
@@ -220,6 +243,15 @@ function normalizeMdtodoRelativePath(value) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeLinkedMarkdownPath(documentRelativePath, href) {
|
||||
const raw = String(href ?? "").trim();
|
||||
if (!raw || raw.startsWith("#") || /^[a-z][a-z0-9+.-]*:/iu.test(raw) || raw.startsWith("/") || /^[A-Za-z]:\//u.test(raw)) failPath();
|
||||
const withoutFragment = raw.split("#")[0]?.trim() ?? "";
|
||||
const directory = path.posix.dirname(normalizeMdtodoRelativePath(documentRelativePath));
|
||||
const normalized = path.posix.normalize(path.posix.join(directory === "." ? "" : directory, withoutFragment));
|
||||
return normalizeMdtodoRelativePath(normalized);
|
||||
}
|
||||
|
||||
function normalizeDirectoryRef(value) {
|
||||
return normalizeRelativeSegments(value || ".", { allowDirectory: true });
|
||||
}
|
||||
|
||||
@@ -347,6 +347,8 @@ async function projectManagementPayload(request: IncomingMessage, response: Serv
|
||||
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/task-detail") return projectManagementTaskDetail(response, url);
|
||||
if (path === "/v1/project-management/mdtodo/report-preview") return projectManagementReportPreview(response, url);
|
||||
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 });
|
||||
@@ -396,13 +398,80 @@ function projectManagementSeedTasks(links: JsonRecord[] = []): JsonRecord[] {
|
||||
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), 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" }
|
||||
{ taskRef: r1, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "R1", title: "实现项目根导航", status: "done", parentTaskRef: null, depth: 0, lineNumber: 4, ordinal: 1, linkCount: linkCount(r1), sourceFingerprint: "sha256:e2e-plan", bodyPreview: "Root notes", body: "Root notes\n\n参考报告 [R1.1](./20260110_LOG/R1.1_log_implementation.md)", updatedAt: "2026-06-25T09:00:00.000Z" },
|
||||
{ taskRef: r11, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "R1.1", title: "展示 MDTODO 任务详情", status: "open", parentTaskRef: r1, depth: 1, lineNumber: 8, ordinal: 2, linkCount: linkCount(r11), sourceFingerprint: "sha256:e2e-plan", bodyPreview: "Detail body", body: "Detail body", updatedAt: "2026-06-25T09:00:00.000Z" },
|
||||
{ taskRef: r111, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "R1.1.1", title: "Source 顶部控制", status: "in_progress", parentTaskRef: r11, depth: 2, lineNumber: 12, ordinal: 3, linkCount: linkCount(r111), sourceFingerprint: "sha256:e2e-plan", bodyPreview: "Toolbar body", body: "Toolbar body", updatedAt: "2026-06-25T09:00:00.000Z" },
|
||||
{ taskRef: r2, projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_rollout", taskId: "R2", title: "D601 v03 页面验收", status: "blocked", parentTaskRef: null, depth: 0, lineNumber: 3, ordinal: 4, linkCount: linkCount(r2), sourceFingerprint: "sha256:e2e-rollout", bodyPreview: "Rollout body", body: "Rollout body", updatedAt: "2026-06-25T09:00:00.000Z" }
|
||||
];
|
||||
}
|
||||
|
||||
function projectManagementTaskDetail(response: ServerResponse, url: URL): void {
|
||||
const taskRef = url.searchParams.get("taskRef");
|
||||
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 body = String(task.body ?? task.bodyPreview ?? "");
|
||||
const links = projectManagementTaskLinks(task, body);
|
||||
return json(response, 200, projectManagementEnvelope({
|
||||
task: { ...task, bodyPreview: body.slice(0, 240), linkCount: links.length },
|
||||
detail: {
|
||||
taskRef: task.taskRef,
|
||||
taskId: task.taskId,
|
||||
rxxId: task.taskId,
|
||||
projectId: task.projectId,
|
||||
sourceId: task.sourceId,
|
||||
fileRef: task.fileRef,
|
||||
relativePath: projectFileForTask(task).relativePath,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
body,
|
||||
bodyPreview: body.slice(0, 240),
|
||||
links,
|
||||
valuesRedacted: false
|
||||
},
|
||||
file: projectFileForTask(task)
|
||||
}));
|
||||
}
|
||||
|
||||
function projectManagementReportPreview(response: ServerResponse, url: URL): void {
|
||||
const taskRef = url.searchParams.get("taskRef");
|
||||
const linkId = url.searchParams.get("linkId");
|
||||
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 link = projectManagementTaskLinks(task, String(task.body ?? task.bodyPreview ?? "")).find((item) => item.linkId === linkId);
|
||||
if (!link) return json(response, 404, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "report_link_not_found" }, valuesRedacted: true });
|
||||
return json(response, 200, projectManagementEnvelope({
|
||||
task,
|
||||
link,
|
||||
report: {
|
||||
sourceId: task.sourceId,
|
||||
relativePath: link.relativePath,
|
||||
content: "# R1.1 implementation report\n\nReport body from fake server.",
|
||||
fingerprint: "sha256:e2e-report",
|
||||
revision: "sha256:e2e-report",
|
||||
byteCount: 56,
|
||||
render: "markdown",
|
||||
linkId: link.linkId,
|
||||
label: link.label,
|
||||
href: link.href,
|
||||
valuesRedacted: false
|
||||
},
|
||||
file: projectFileForTask(task)
|
||||
}));
|
||||
}
|
||||
|
||||
function projectManagementTaskLinks(task: JsonRecord, body: string): JsonRecord[] {
|
||||
if (String(task.taskId) !== "R1" || !body.includes("R1.1_log_implementation.md")) return [];
|
||||
return [{
|
||||
linkId: "lnk_r11_report",
|
||||
label: "R1.1",
|
||||
href: "./20260110_LOG/R1.1_log_implementation.md",
|
||||
kind: "markdown-report",
|
||||
relativePath: "20260110_LOG/R1.1_log_implementation.md",
|
||||
ordinal: 1,
|
||||
valuesRedacted: true
|
||||
}];
|
||||
}
|
||||
|
||||
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 });
|
||||
@@ -410,7 +479,10 @@ function projectManagementUpdateTask(response: ServerResponse, taskRef: string,
|
||||
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);
|
||||
if (typeof body.body === "string") {
|
||||
task.body = body.body;
|
||||
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] }));
|
||||
@@ -429,7 +501,7 @@ function projectManagementCreateTask(response: ServerResponse, body: JsonRecord)
|
||||
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() };
|
||||
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, body: typeof body.body === "string" ? body.body : "", 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] }));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo.
|
||||
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo; draft-2026-06-26-p1-mdtodo-web-operable.
|
||||
// Responsibility: Cloud Web API barrel, including project-management public API exports.
|
||||
|
||||
export { fetchJson, fetchText, type ActivityRef, type ActivityRefSource, type ApiRequestOptions } from "./client";
|
||||
@@ -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 MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement";
|
||||
export { projectManagementAPI, type MdtodoFileRecord, type MdtodoReportPreviewRecord, type MdtodoTaskDetailRecord, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement";
|
||||
|
||||
import { fetchJson } from "./client";
|
||||
import { accessAPI } from "./access";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo.
|
||||
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo; draft-2026-06-26-p1-mdtodo-web-operable.
|
||||
// Responsibility: Cloud Web client for the public project-management same-origin API.
|
||||
|
||||
import { fetchJson } from "./client";
|
||||
@@ -117,6 +117,49 @@ export interface MdtodoTaskRecord {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface MdtodoTaskLinkRecord {
|
||||
linkId: string;
|
||||
label?: string;
|
||||
href?: string;
|
||||
kind?: string;
|
||||
relativePath?: string | null;
|
||||
ordinal?: number;
|
||||
valuesRedacted?: boolean;
|
||||
}
|
||||
|
||||
export interface MdtodoTaskDetailRecord {
|
||||
taskRef: string;
|
||||
taskId?: string;
|
||||
rxxId?: string;
|
||||
projectId?: string;
|
||||
sourceId?: string;
|
||||
fileRef?: string;
|
||||
relativePath?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
lineNumber?: number;
|
||||
headingLevel?: number;
|
||||
statusMarker?: string | null;
|
||||
body?: string;
|
||||
bodyPreview?: string;
|
||||
links?: MdtodoTaskLinkRecord[];
|
||||
valuesRedacted?: boolean;
|
||||
}
|
||||
|
||||
export interface MdtodoReportPreviewRecord {
|
||||
sourceId?: string;
|
||||
relativePath?: string;
|
||||
content?: string;
|
||||
fingerprint?: string;
|
||||
revision?: string;
|
||||
byteCount?: number;
|
||||
render?: string;
|
||||
linkId?: string;
|
||||
label?: string;
|
||||
href?: string;
|
||||
valuesRedacted?: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectWorkbenchLinkRecord {
|
||||
linkId: string;
|
||||
projectId?: string;
|
||||
@@ -143,6 +186,8 @@ export interface MdtodoTaskPage {
|
||||
hasMore?: boolean;
|
||||
}
|
||||
export interface MdtodoTaskListResponse extends ProjectManagementEnvelope { tasks?: MdtodoTaskRecord[]; page?: MdtodoTaskPage }
|
||||
export interface MdtodoTaskDetailResponse extends ProjectManagementEnvelope { task?: MdtodoTaskRecord; detail?: MdtodoTaskDetailRecord; file?: MdtodoFileRecord }
|
||||
export interface MdtodoReportPreviewResponse extends ProjectManagementEnvelope { task?: MdtodoTaskRecord; link?: MdtodoTaskLinkRecord; report?: MdtodoReportPreviewRecord; file?: MdtodoFileRecord }
|
||||
export interface MdtodoTaskMutationInput {
|
||||
sourceId?: string;
|
||||
fileRef?: string;
|
||||
@@ -190,6 +235,8 @@ 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, parentTaskRef: query.parentTaskRef, status: query.status, search: query.search, limit: query.limit, offset: query.offset })}`, { timeoutMs: 12000, timeoutName: "project mdtodo tasks" }),
|
||||
taskDetail: (taskRef: string): Promise<ApiResult<MdtodoTaskDetailResponse>> => fetchJson(`/v1/project-management/mdtodo/task-detail${queryString({ taskRef })}`, { timeoutMs: 12000, timeoutName: "project mdtodo task detail" }),
|
||||
reportPreview: (taskRef: string, linkId: string): Promise<ApiResult<MdtodoReportPreviewResponse>> => fetchJson(`/v1/project-management/mdtodo/report-preview${queryString({ taskRef, linkId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo report preview" }),
|
||||
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" }),
|
||||
|
||||
@@ -49,9 +49,12 @@ export interface WorkbenchLaunchContext {
|
||||
taskRef: string;
|
||||
sourceId?: string | null;
|
||||
fileRef?: string | null;
|
||||
fileName?: string | null;
|
||||
taskId?: string | null;
|
||||
title?: string | null;
|
||||
status?: string | null;
|
||||
bodyPreview?: string | null;
|
||||
reportLinks?: Array<{ linkId?: string; label?: string; href?: string; relativePath?: string | null }>;
|
||||
}
|
||||
|
||||
export interface WorkbenchLaunchRequest {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<!-- SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo. -->
|
||||
<!-- SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo; draft-2026-06-26-p1-mdtodo-web-operable. -->
|
||||
<!-- Responsibility: MDTODO project page consuming public ProjectTask DTOs and Workbench launch DTOs without Workbench or Markdown internals. -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { projectManagementAPI, workbenchAPI, type MdtodoFileRecord, type MdtodoTaskMutationInput, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "@/api";
|
||||
import { agentAPI, projectManagementAPI, workbenchAPI, type MdtodoFileRecord, type MdtodoReportPreviewRecord, type MdtodoTaskDetailRecord, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "@/api";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import LoadingState from "@/components/common/LoadingState.vue";
|
||||
import PageHeader from "@/components/common/PageHeader.vue";
|
||||
@@ -13,7 +15,9 @@ import type { ApiError, ErrorDiagnostic } from "@/types";
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const taskLoading = ref(false);
|
||||
const taskDetailLoading = ref(false);
|
||||
const linkLoading = ref(false);
|
||||
const reportLoading = ref(false);
|
||||
const launchLoading = ref(false);
|
||||
const taskMutationLoading = ref(false);
|
||||
const sourceSaving = ref(false);
|
||||
@@ -21,9 +25,12 @@ const sourceProbeLoading = ref(false);
|
||||
const sourceReindexLoading = ref(false);
|
||||
const showInfo = ref(false);
|
||||
const showSourceConfig = ref(false);
|
||||
const showReportFullscreen = ref(false);
|
||||
const launchError = ref<string | null>(null);
|
||||
const taskMutationError = ref<string | null>(null);
|
||||
const taskMutationMessage = ref<string | null>(null);
|
||||
const taskDetailError = ref<string | null>(null);
|
||||
const reportError = ref<string | null>(null);
|
||||
const sourceMessage = ref<string | null>(null);
|
||||
const sourceError = ref<string | null>(null);
|
||||
const error = ref<string | null>(null);
|
||||
@@ -35,12 +42,17 @@ const files = ref<MdtodoFileRecord[]>([]);
|
||||
const tasks = ref<MdtodoTaskRecord[]>([]);
|
||||
const taskPage = ref<MdtodoTaskPage | null>(null);
|
||||
const links = ref<ProjectWorkbenchLinkRecord[]>([]);
|
||||
const taskDetail = ref<MdtodoTaskDetailRecord | null>(null);
|
||||
const reportPreview = ref<MdtodoReportPreviewRecord | null>(null);
|
||||
const activeReportLink = ref<MdtodoTaskLinkRecord | null>(null);
|
||||
const selectedSourceId = ref<string | null>(null);
|
||||
const selectedFileRef = ref<string | null>(null);
|
||||
const selectedTaskRef = ref<string | null>(null);
|
||||
const taskSearch = ref("");
|
||||
const taskStatusFilter = ref("all");
|
||||
const collapsedTaskRefs = ref<Set<string>>(new Set());
|
||||
const editingTitle = ref(false);
|
||||
const editingBody = ref(false);
|
||||
const editTitle = ref("");
|
||||
const editStatus = ref("open");
|
||||
const editBody = ref("");
|
||||
@@ -105,6 +117,11 @@ const visibleTaskRows = computed(() => {
|
||||
const workbenchLaunchEnabled = computed(() => navigation.value?.navigation?.capabilities?.workbenchLaunch === true);
|
||||
const launchButtonDisabled = computed(() => !selectedTask.value || !workbenchLaunchEnabled.value || launchLoading.value);
|
||||
const selectedSourceCanReindex = computed(() => Boolean(selectedSourceId.value && !taskLoading.value && !sourceReindexLoading.value));
|
||||
const selectedTaskBody = computed(() => taskDetail.value?.body ?? selectedTask.value?.bodyPreview ?? "");
|
||||
const selectedTaskLinks = computed(() => taskDetail.value?.links ?? []);
|
||||
const selectedTaskBodyHtml = computed(() => markdownToHtml(selectedTaskBody.value));
|
||||
const reportPreviewHtml = computed(() => markdownToHtml(reportPreview.value?.content ?? ""));
|
||||
const selectedFileName = computed(() => selectedFile.value ? fileDisplayName(selectedFile.value) : selectedTask.value?.fileRef || "-");
|
||||
|
||||
onMounted(() => void loadPage());
|
||||
|
||||
@@ -114,11 +131,15 @@ watch(selectedSourceId, async (sourceId) => {
|
||||
});
|
||||
|
||||
watch(selectedTaskRef, async (taskRef) => {
|
||||
await loadLinks(taskRef);
|
||||
await Promise.all([loadLinks(taskRef), loadTaskDetail(taskRef)]);
|
||||
});
|
||||
|
||||
watch(selectedTask, (task) => resetTaskEditor(task), { immediate: true });
|
||||
|
||||
watch(taskDetail, (detail) => {
|
||||
if (detail?.taskRef === selectedTaskRef.value && !editingBody.value) editBody.value = detail.body || "";
|
||||
});
|
||||
|
||||
async function loadPage(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
@@ -168,6 +189,28 @@ async function loadLinks(taskRef: string | null): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTaskDetail(taskRef: string | null): Promise<void> {
|
||||
taskDetailError.value = null;
|
||||
reportError.value = null;
|
||||
reportPreview.value = null;
|
||||
activeReportLink.value = null;
|
||||
if (!taskRef) {
|
||||
taskDetail.value = null;
|
||||
return;
|
||||
}
|
||||
taskDetailLoading.value = true;
|
||||
try {
|
||||
const response = await projectManagementAPI.taskDetail(taskRef);
|
||||
if (!response.ok) throw response;
|
||||
taskDetail.value = response.data?.detail ?? null;
|
||||
} catch (err) {
|
||||
taskDetail.value = null;
|
||||
taskDetailError.value = projectApiError(err).error;
|
||||
} finally {
|
||||
taskDetailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSourceSelect(event: Event): void {
|
||||
selectedSourceId.value = (event.target as HTMLSelectElement).value || null;
|
||||
}
|
||||
@@ -288,12 +331,14 @@ async function saveTaskBasics(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
await runTaskMutation(() => projectManagementAPI.updateTask(task.taskRef, input), task.taskRef, "任务已保存");
|
||||
if (!taskMutationError.value) editingTitle.value = false;
|
||||
}
|
||||
|
||||
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, "正文已保存");
|
||||
if (!taskMutationError.value) editingBody.value = false;
|
||||
}
|
||||
|
||||
async function createTask(kind: "root" | "subtask" | "continue"): Promise<void> {
|
||||
@@ -333,6 +378,45 @@ async function deleteSelectedTask(): Promise<void> {
|
||||
deleteConfirm.value = false;
|
||||
}
|
||||
|
||||
function beginTitleEdit(): void {
|
||||
if (!selectedTask.value || taskMutationLoading.value) return;
|
||||
editTitle.value = selectedTask.value.title || "";
|
||||
editingTitle.value = true;
|
||||
}
|
||||
|
||||
function cancelTitleEdit(): void {
|
||||
editTitle.value = selectedTask.value?.title || "";
|
||||
editingTitle.value = false;
|
||||
}
|
||||
|
||||
function beginBodyEdit(): void {
|
||||
if (!selectedTask.value || taskMutationLoading.value) return;
|
||||
editBody.value = selectedTaskBody.value;
|
||||
editingBody.value = true;
|
||||
}
|
||||
|
||||
function cancelBodyEdit(): void {
|
||||
editBody.value = selectedTaskBody.value;
|
||||
editingBody.value = false;
|
||||
}
|
||||
|
||||
async function openReportPreview(link: MdtodoTaskLinkRecord): Promise<void> {
|
||||
const taskRef = selectedTaskRef.value;
|
||||
if (!taskRef || !link.linkId || link.kind !== "markdown-report") return;
|
||||
reportLoading.value = true;
|
||||
reportError.value = null;
|
||||
try {
|
||||
const response = await projectManagementAPI.reportPreview(taskRef, link.linkId);
|
||||
if (!response.ok) throw response;
|
||||
reportPreview.value = response.data?.report ?? null;
|
||||
activeReportLink.value = response.data?.link ?? link;
|
||||
} catch (err) {
|
||||
reportError.value = projectApiError(err).error;
|
||||
} finally {
|
||||
reportLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runTaskMutation(action: () => ReturnType<typeof projectManagementAPI.updateTask>, preferredTaskRef: string | undefined, message: string): Promise<void> {
|
||||
const sourceId = selectedSourceId.value;
|
||||
if (!sourceId) return;
|
||||
@@ -351,6 +435,7 @@ async function runTaskMutation(action: () => ReturnType<typeof projectManagement
|
||||
const nextTaskRef = response.data?.task?.taskRef || preferredTaskRef;
|
||||
await loadFilesAndTasks(sourceId);
|
||||
if (nextTaskRef && tasks.value.some((task) => task.taskRef === nextTaskRef)) selectedTaskRef.value = nextTaskRef;
|
||||
if (selectedTaskRef.value) await Promise.all([loadLinks(selectedTaskRef.value), loadTaskDetail(selectedTaskRef.value)]);
|
||||
taskMutationMessage.value = message;
|
||||
} catch (err) {
|
||||
taskMutationError.value = projectApiError(err).error;
|
||||
@@ -367,7 +452,7 @@ async function loadTaskWindow(sourceId: string, fileRef: string | null, preferre
|
||||
const preferredTask = preferredTaskRef ? tasks.value.find((task) => task.taskRef === preferredTaskRef) : null;
|
||||
selectedTaskRef.value = preferredTask?.taskRef ?? tasks.value[0]?.taskRef ?? null;
|
||||
collapsedTaskRefs.value = new Set();
|
||||
if (!selectedTaskRef.value) await loadLinks(null);
|
||||
if (!selectedTaskRef.value) await Promise.all([loadLinks(null), loadTaskDetail(null)]);
|
||||
}
|
||||
|
||||
function taskFingerprint(): string | undefined {
|
||||
@@ -375,9 +460,11 @@ function taskFingerprint(): string | undefined {
|
||||
}
|
||||
|
||||
function resetTaskEditor(task: MdtodoTaskRecord | null): void {
|
||||
editingTitle.value = false;
|
||||
editingBody.value = false;
|
||||
editTitle.value = task?.title || "";
|
||||
editStatus.value = task?.status || "open";
|
||||
editBody.value = "";
|
||||
editBody.value = taskDetail.value?.taskRef === task?.taskRef ? taskDetail.value?.body || "" : "";
|
||||
deleteConfirm.value = false;
|
||||
}
|
||||
|
||||
@@ -387,21 +474,39 @@ async function launchSelectedTask(): Promise<void> {
|
||||
launchLoading.value = true;
|
||||
launchError.value = null;
|
||||
try {
|
||||
const prompt = buildWorkbenchPrompt(task);
|
||||
const launchContext = {
|
||||
source: "mdtodo-page",
|
||||
projectId: task.projectId,
|
||||
taskRef: task.taskRef,
|
||||
sourceId: task.sourceId,
|
||||
fileRef: task.fileRef,
|
||||
fileName: selectedFileName.value,
|
||||
taskId: task.taskId,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
bodyPreview: selectedTaskBody.value.slice(0, 800),
|
||||
reportLinks: selectedTaskLinks.value.filter((link) => link.kind === "markdown-report").map((link) => ({ linkId: link.linkId, label: link.label, href: link.href, relativePath: link.relativePath }))
|
||||
};
|
||||
const response = await workbenchAPI.launch({
|
||||
projectId: task.projectId,
|
||||
taskRef: task.taskRef,
|
||||
launchContext: {
|
||||
source: "mdtodo-page",
|
||||
projectId: task.projectId,
|
||||
taskRef: task.taskRef,
|
||||
sourceId: task.sourceId,
|
||||
fileRef: task.fileRef,
|
||||
taskId: task.taskId,
|
||||
title: task.title,
|
||||
status: task.status
|
||||
}
|
||||
providerProfile: "codex",
|
||||
launchContext
|
||||
});
|
||||
if (!response.ok) throw response;
|
||||
if (response.data?.sessionId) {
|
||||
const chat = await agentAPI.sendAgentMessage({
|
||||
message: prompt,
|
||||
prompt,
|
||||
sessionId: response.data.sessionId,
|
||||
providerProfile: "codex",
|
||||
projectId: task.projectId,
|
||||
taskRef: task.taskRef,
|
||||
launchContext
|
||||
}, 120000);
|
||||
if (!chat.ok) throw chat;
|
||||
}
|
||||
await loadLinks(task.taskRef);
|
||||
const target = response.data?.workbenchUrl || (response.data?.sessionId ? `/workbench/sessions/${encodeURIComponent(response.data.sessionId)}` : null);
|
||||
if (target) await router.push(target);
|
||||
@@ -427,6 +532,38 @@ function projectApiError(err: unknown): { error: string; apiError: ApiError | nu
|
||||
return { error: err instanceof Error ? err.message : String(err || "MDTODO 加载失败"), apiError: null, diagnostic: null };
|
||||
}
|
||||
|
||||
function buildWorkbenchPrompt(task: MdtodoTaskRecord): string {
|
||||
const reportLines = selectedTaskLinks.value
|
||||
.filter((link) => link.kind === "markdown-report")
|
||||
.map((link) => `- ${link.label || link.href}: ${link.relativePath || link.href}`)
|
||||
.join("\n");
|
||||
return [
|
||||
"请基于以下 MDTODO 任务开始工作,先读取相关文件和报告,再给出下一步执行结果。",
|
||||
"",
|
||||
`Task: ${task.taskId || task.taskRef} ${task.title || ""}`.trim(),
|
||||
`TaskRef: ${task.taskRef}`,
|
||||
`File: ${selectedFile.value?.relativePath || task.fileRef || "-"}`,
|
||||
`Status: ${task.status || "open"}`,
|
||||
"",
|
||||
"任务正文:",
|
||||
selectedTaskBody.value.trim() || "(无正文)",
|
||||
"",
|
||||
"相关报告链接:",
|
||||
reportLines || "(无)"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function markdownToHtml(value?: string | null): string {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return "";
|
||||
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string);
|
||||
}
|
||||
|
||||
function fileDisplayName(file: MdtodoFileRecord): string {
|
||||
const path = file.relativePath || file.fileRef || "-";
|
||||
return path.split("/").filter(Boolean).at(-1) || path;
|
||||
}
|
||||
|
||||
function statusLabel(value?: string | null): string {
|
||||
if (value === "done") return "完成";
|
||||
if (value === "blocked") return "阻塞";
|
||||
@@ -476,7 +613,7 @@ function displayDate(value?: string | null): string {
|
||||
<label class="toolbar-field" data-testid="mdtodo-file-control">
|
||||
<span>File</span>
|
||||
<select data-testid="mdtodo-file-select" :value="selectedFileRef || ''" :disabled="taskLoading || !files.length" @change="onFileSelect">
|
||||
<option v-for="file in files" :key="file.fileRef" :value="file.fileRef">{{ file.title || file.relativePath || file.fileRef }}</option>
|
||||
<option v-for="file in files" :key="file.fileRef" :value="file.fileRef">{{ fileDisplayName(file) }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="toolbar-actions">
|
||||
@@ -523,24 +660,65 @@ function displayDate(value?: string | null): string {
|
||||
<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>Title</dt>
|
||||
<dd v-if="editingTitle" class="inline-editor">
|
||||
<input v-model="editTitle" data-testid="mdtodo-edit-title" :disabled="taskMutationLoading" @keyup.enter="saveTaskBasics" @keyup.esc="cancelTitleEdit" />
|
||||
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-save" :disabled="taskMutationLoading" @click="saveTaskBasics">保存</button>
|
||||
<button class="btn btn-secondary" type="button" :disabled="taskMutationLoading" @click="cancelTitleEdit">取消</button>
|
||||
</dd>
|
||||
<dd v-else class="editable-value" data-testid="mdtodo-title-read" @dblclick="beginTitleEdit">{{ selectedTask.title || selectedTask.taskId }}</dd>
|
||||
</div>
|
||||
<div><dt>TaskRef</dt><dd><code>{{ selectedTask.taskRef }}</code></dd></div>
|
||||
<div><dt>File</dt><dd><code>{{ selectedFile?.relativePath || selectedTask.fileRef || '-' }}</code></dd></div>
|
||||
<div><dt>File</dt><dd><code>{{ selectedFileName }}</code></dd></div>
|
||||
<div><dt>Updated</dt><dd>{{ displayDate(selectedTask.updatedAt) }}</dd></div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd class="status-inline">
|
||||
<select v-model="editStatus" data-testid="mdtodo-edit-status" :disabled="taskMutationLoading">
|
||||
<option value="open">待办</option>
|
||||
<option value="in_progress">进行中</option>
|
||||
<option value="done">完成</option>
|
||||
<option value="blocked">阻塞</option>
|
||||
</select>
|
||||
<button class="btn btn-secondary" type="button" data-testid="mdtodo-status-save" :disabled="taskMutationLoading" @click="saveTaskBasics">保存状态</button>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<section class="task-editor" data-testid="mdtodo-task-editor">
|
||||
<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>
|
||||
<LoadingState v-if="taskDetailLoading" compact label="同步任务详情" />
|
||||
<p v-if="taskDetailError" class="source-error" data-testid="mdtodo-task-detail-error">{{ taskDetailError }}</p>
|
||||
<section class="task-body-section" data-testid="mdtodo-task-body">
|
||||
<header><strong>正文</strong></header>
|
||||
<div v-if="editingBody" class="inline-body-editor">
|
||||
<textarea v-model="editBody" data-testid="mdtodo-edit-body" :disabled="taskMutationLoading" rows="8" />
|
||||
<div class="editor-actions">
|
||||
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-body-save" :disabled="taskMutationLoading" @click="saveTaskBody">保存正文</button>
|
||||
<button class="btn btn-secondary" type="button" :disabled="taskMutationLoading" @click="cancelBodyEdit">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<article v-else class="markdown-body task-body-rendered" data-testid="mdtodo-body-rendered" @dblclick="beginBodyEdit">
|
||||
<div v-if="selectedTaskBodyHtml" v-html="selectedTaskBodyHtml" />
|
||||
<p v-else class="empty-inline">暂无正文</p>
|
||||
</article>
|
||||
</section>
|
||||
<section v-if="selectedTaskLinks.length || reportPreview || reportError" class="report-section" data-testid="mdtodo-report-section">
|
||||
<header><strong>报告</strong><span v-if="reportLoading">加载中</span></header>
|
||||
<div v-if="selectedTaskLinks.length" class="report-link-list">
|
||||
<button v-for="link in selectedTaskLinks" :key="link.linkId" class="report-link-button" type="button" data-testid="mdtodo-report-link" :disabled="link.kind !== 'markdown-report' || reportLoading" @click="openReportPreview(link)">
|
||||
<span>{{ link.label || link.href }}</span>
|
||||
<small>{{ link.relativePath || link.kind }}</small>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="reportError" class="source-error" data-testid="mdtodo-report-error">{{ reportError }}</p>
|
||||
<article v-if="reportPreview" class="report-preview" data-testid="mdtodo-report-preview">
|
||||
<header>
|
||||
<strong>{{ activeReportLink?.label || reportPreview.relativePath || 'Report' }}</strong>
|
||||
<button class="btn btn-secondary" type="button" data-testid="mdtodo-report-fullscreen" @click="showReportFullscreen = true">全屏</button>
|
||||
</header>
|
||||
<div class="markdown-body" v-html="reportPreviewHtml" />
|
||||
</article>
|
||||
</section>
|
||||
<div class="task-create-box">
|
||||
<label><span>New task</span><input v-model="newTaskTitle" data-testid="mdtodo-new-title" :disabled="taskMutationLoading" placeholder="新任务标题" /></label>
|
||||
<textarea v-model="newTaskBody" data-testid="mdtodo-new-body" :disabled="taskMutationLoading" rows="3" placeholder="新任务正文,可留空。" />
|
||||
@@ -586,6 +764,13 @@ function displayDate(value?: string | null): string {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="showReportFullscreen && reportPreview" class="modal-backdrop" data-testid="mdtodo-report-fullscreen-dialog" role="dialog" aria-modal="true" aria-label="报告预览" @click.self="showReportFullscreen = false">
|
||||
<section class="mdtodo-dialog report-fullscreen-dialog">
|
||||
<header><h2>{{ activeReportLink?.label || reportPreview.relativePath || 'Report' }}</h2><button class="icon-button" type="button" aria-label="关闭报告" @click="showReportFullscreen = false">×</button></header>
|
||||
<article class="markdown-body report-fullscreen-body" v-html="reportPreviewHtml" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="showSourceConfig" class="modal-backdrop" data-testid="mdtodo-source-config-dialog" role="dialog" aria-modal="true" aria-label="配置 MDTODO Source" @click.self="showSourceConfig = false">
|
||||
<section class="mdtodo-dialog source-config-dialog">
|
||||
<header><h2>Source 配置</h2><button class="icon-button" type="button" aria-label="关闭配置" @click="showSourceConfig = false">×</button></header>
|
||||
@@ -618,6 +803,7 @@ function displayDate(value?: string | null): string {
|
||||
.toolbar-field select,
|
||||
.task-tools input,
|
||||
.task-tools select,
|
||||
.status-inline select,
|
||||
.source-form input { width: 100%; min-width: 0; height: 36px; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; padding: 0 10px; font: inherit; }
|
||||
.toolbar-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.icon-button { display: inline-grid; width: 36px; height: 36px; place-items: center; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; font-weight: 850; }
|
||||
@@ -655,6 +841,11 @@ 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; }
|
||||
.editable-value { cursor: text; border-radius: 6px; padding: 3px 0; }
|
||||
.editable-value:hover { background: #f4fbf8; }
|
||||
.inline-editor { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; align-items: center; }
|
||||
.inline-editor input { width: 100%; min-width: 0; border: 1px solid #cbd8d4; border-radius: 6px; color: #14211d; font: inherit; padding: 8px 9px; }
|
||||
.status-inline { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; }
|
||||
.task-editor { display: grid; gap: 10px; border: 1px solid #d8e2df; border-radius: 8px; background: #fbfefd; padding: 12px; }
|
||||
.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; }
|
||||
@@ -663,6 +854,30 @@ function displayDate(value?: string | null): string {
|
||||
.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-body-section,
|
||||
.report-section { display: grid; gap: 8px; border-bottom: 1px solid #e2ece8; padding-bottom: 10px; }
|
||||
.task-body-section header,
|
||||
.report-section header,
|
||||
.report-preview header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 10px; color: #14211d; font-size: 13px; }
|
||||
.inline-body-editor { display: grid; gap: 8px; }
|
||||
.markdown-body { min-width: 0; color: #14211d; font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.markdown-body :deep(p),
|
||||
.markdown-body :deep(ul),
|
||||
.markdown-body :deep(ol),
|
||||
.markdown-body :deep(pre),
|
||||
.markdown-body :deep(blockquote) { margin: 0 0 8px; }
|
||||
.markdown-body :deep(pre) { overflow: auto; border-radius: 6px; background: #0f172a; color: #e5e7eb; padding: 10px; }
|
||||
.markdown-body :deep(code) { border-radius: 4px; background: #eef6f2; padding: 1px 4px; color: #0f766e; }
|
||||
.markdown-body :deep(a) { color: #0f766e; font-weight: 750; }
|
||||
.task-body-rendered { min-height: 92px; max-height: 240px; overflow: auto; border: 1px solid #d8e2df; border-radius: 6px; background: #fff; padding: 10px; }
|
||||
.empty-inline { margin: 0; color: #64706b; }
|
||||
.report-link-list { display: grid; gap: 6px; }
|
||||
.report-link-button { display: grid; width: 100%; min-width: 0; gap: 3px; border: 1px solid #d8e2df; border-radius: 6px; background: #fff; color: #14211d; padding: 8px 10px; text-align: left; }
|
||||
.report-link-button:disabled { opacity: 0.55; }
|
||||
.report-link-button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 800; }
|
||||
.report-link-button small { color: #64706b; overflow-wrap: anywhere; }
|
||||
.report-preview { display: grid; gap: 8px; border: 1px solid #d8e2df; border-radius: 6px; background: #fff; padding: 10px; }
|
||||
.report-preview > .markdown-body { max-height: 300px; overflow: auto; }
|
||||
.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; }
|
||||
@@ -677,6 +892,8 @@ function displayDate(value?: string | null): string {
|
||||
.mdtodo-dialog { display: grid; width: min(720px, calc(100vw - 32px)); gap: 14px; border: 1px solid #cbd8d2; border-radius: 8px; background: #fff; color: #14211d; padding: 16px; box-shadow: 0 24px 60px rgba(15, 23, 42, 0.22); }
|
||||
.mdtodo-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.mdtodo-dialog h2 { margin: 0; font-size: 18px; }
|
||||
.report-fullscreen-dialog { width: min(1120px, calc(100vw - 32px)); min-height: min(780px, calc(100dvh - 80px)); align-content: start; }
|
||||
.report-fullscreen-body { max-height: calc(100dvh - 170px); overflow: auto; border: 1px solid #e2e8f0; border-radius: 6px; padding: 14px; }
|
||||
.source-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.source-form label { display: grid; min-width: 0; gap: 5px; color: #52615c; font-size: 12px; font-weight: 800; }
|
||||
.source-form footer { display: flex; grid-column: 1 / -1; flex-wrap: wrap; gap: 8px; }
|
||||
@@ -692,6 +909,8 @@ function displayDate(value?: string | null): string {
|
||||
@media (max-width: 640px) {
|
||||
.source-form { grid-template-columns: 1fr; }
|
||||
.editor-grid { grid-template-columns: 1fr; }
|
||||
.inline-editor,
|
||||
.status-inline { grid-template-columns: 1fr; }
|
||||
.task-row { grid-template-columns: minmax(48px, auto) minmax(0, 1fr); }
|
||||
.task-status { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo.
|
||||
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo; draft-2026-06-26-p1-mdtodo-web-operable.
|
||||
// Responsibility: Fake-server browser regression for project root and MDTODO pages.
|
||||
|
||||
import { expect, saveScreenshot, test } from "../fixtures/test";
|
||||
@@ -18,6 +18,7 @@ test.describe("project management pages", () => {
|
||||
await expect(page.getByTestId("mdtodo-toolbar")).toBeVisible();
|
||||
await expect(page.getByTestId("mdtodo-source-select")).toHaveValue("hwlab-v03-mdtodo");
|
||||
await expect(page.getByTestId("mdtodo-file-select")).toHaveValue("file_plan");
|
||||
await expect(page.getByTestId("mdtodo-file-select")).toContainText("hwlab-v03-mdtodo-web-sample.md");
|
||||
await expect(page.locator(".mdtodo-summary-grid .metric-card")).toHaveCount(0);
|
||||
await expect(page.locator(".mdtodo-source-panel")).toHaveCount(0);
|
||||
await expect(page.locator(".mdtodo-file-panel")).toHaveCount(0);
|
||||
@@ -26,6 +27,12 @@ test.describe("project management pages", () => {
|
||||
await expect(page.locator('[data-task-id="R1.1"]')).toBeVisible();
|
||||
await expect(page.locator('[data-task-id="R1.1.1"]')).toBeVisible();
|
||||
await expect(page.getByTestId("mdtodo-task-detail")).toContainText("mdtodo:hwlab-v03-mdtodo:file_plan:R1");
|
||||
await expect(page.getByTestId("mdtodo-body-rendered")).toContainText("Root notes");
|
||||
await page.getByTestId("mdtodo-report-link").click();
|
||||
await expect(page.getByTestId("mdtodo-report-preview")).toContainText("Report body from fake server");
|
||||
await page.getByTestId("mdtodo-report-fullscreen").click();
|
||||
await expect(page.getByTestId("mdtodo-report-fullscreen-dialog")).toContainText("R1.1 implementation report");
|
||||
await page.getByLabel("关闭报告").click();
|
||||
await page.getByTestId("mdtodo-info-open").click();
|
||||
await expect(page.getByTestId("mdtodo-info-popover")).toContainText("Source");
|
||||
await expect(page.getByTestId("mdtodo-info-popover")).toContainText("Files");
|
||||
@@ -40,12 +47,14 @@ 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-title-read").dblclick();
|
||||
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-body-rendered").dblclick();
|
||||
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("正文已保存");
|
||||
@@ -78,6 +87,8 @@ test.describe("project management pages", () => {
|
||||
const sessionPayload = await sessionDetail.json();
|
||||
expect(sessionPayload.session?.launchContext?.projectId ?? sessionPayload.session?.metadata?.launchContext?.projectId).toBe("project_hwlab_v03");
|
||||
expect(sessionPayload.session?.launchContext?.taskRef ?? sessionPayload.session?.metadata?.launchContext?.taskRef).toBe("mdtodo:hwlab-v03-mdtodo:file_plan:R1");
|
||||
expect(sessionPayload.session?.messageCount ?? sessionPayload.session?.messages?.length ?? 0).toBeGreaterThan(0);
|
||||
expect(sessionPayload.session?.firstUserMessagePreview ?? "").toContain("MDTODO");
|
||||
|
||||
const links = await page.request.get("/v1/project-management/workbench-links?taskRef=mdtodo:hwlab-v03-mdtodo:file_plan:R1");
|
||||
expect(links.ok()).toBeTruthy();
|
||||
|
||||
Reference in New Issue
Block a user