Merge pull request #2631 from pikasTech/feat/tasktree-native-followup
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success

feat(tasktree): 完善 MDTODO 导入与甘特详情体验
This commit is contained in:
Lyon
2026-07-17 16:13:56 +08:00
committed by GitHub
12 changed files with 720 additions and 169 deletions
+13 -2
View File
@@ -1,5 +1,5 @@
export type TaskStatus = "pending" | "in_progress" | "completed" | "blocked"; export type TaskStatus = "pending" | "in_progress" | "completed" | "blocked";
export type TaskKind = "task" | "subtask"; export type TaskKind = "task" | "subtask" | "subsubtask";
export type TaskGroup = { export type TaskGroup = {
id: string; id: string;
@@ -49,9 +49,20 @@ export type Timeline = {
reports: ExecutionReport[]; reports: ExecutionReport[];
}; };
export type TaskGroupOverview = {
group: TaskGroup;
taskCount: number;
subtaskCount: number;
subsubtaskCount: number;
reportCount: number;
startAt: string | null;
dueAt: string | null;
};
export type TaskTreeCommand = export type TaskTreeCommand =
| { operation: "health" } | { operation: "health" }
| { operation: "group.list" } | { operation: "group.list" }
| { operation: "group.overview" }
| { operation: "group.get"; groupId: string } | { operation: "group.get"; groupId: string }
| { operation: "group.create"; name: string; description?: string } | { operation: "group.create"; name: string; description?: string }
| { operation: "group.delete"; groupId: string } | { operation: "group.delete"; groupId: string }
@@ -60,7 +71,7 @@ export type TaskTreeCommand =
| { operation: "task.delete"; taskId: string } | { operation: "task.delete"; taskId: string }
| { operation: "milestone.create"; groupId: string; title: string; occursAt: string; taskId?: string } | { operation: "milestone.create"; groupId: string; title: string; occursAt: string; taskId?: string }
| { operation: "report.create"; taskId: string; title: string; body: string; status?: string } | { operation: "report.create"; taskId: string; title: string; body: string; status?: string }
| { operation: "mdtodo.import"; sourcePath: string; markdown: string; reports: Array<{ href: string; sourcePath: string; body: string }>; groupName?: string; dryRun?: boolean } | { operation: "mdtodo.import"; sourcePath: string; sourceModifiedAt: string; markdown: string; reports: Array<{ href: string; sourcePath: string; body: string; modifiedAt: string }>; groupName?: string; dryRun?: boolean }
| { operation: "timeline.get"; groupId: string } | { operation: "timeline.get"; groupId: string }
| { operation: "workflow.start"; taskId: string }; | { operation: "workflow.start"; taskId: string };
+1
View File
@@ -19,6 +19,7 @@ export function createTaskTreeDispatcher(options: TaskTreeDispatcherOptions) {
let data: unknown; let data: unknown;
if (operation === "health") data = await store.health(); if (operation === "health") data = await store.health();
else if (operation === "group.list") data = { groups: await store.listGroups() }; else if (operation === "group.list") data = { groups: await store.listGroups() };
else if (operation === "group.overview") data = { groups: await store.groupOverview() };
else if (operation === "group.get") data = required(await store.getGroup(command.groupId), "group_not_found", "taskgroup was not found"); else if (operation === "group.get") data = required(await store.getGroup(command.groupId), "group_not_found", "taskgroup was not found");
else if (operation === "group.create") data = await store.createGroup(requiredText(command.name, "name"), command.description); else if (operation === "group.create") data = await store.createGroup(requiredText(command.name, "name"), command.description);
else if (operation === "group.delete") data = { deleted: await store.deleteGroup(command.groupId) }; else if (operation === "group.delete") data = { deleted: await store.deleteGroup(command.groupId) };
+1
View File
@@ -7,6 +7,7 @@ export function createTaskTreeHttpApp(options: { dispatch: (command: TaskTreeCom
if (url.pathname === "/health/live") return json(200, { ok: true, serviceId: "hwlab-tasktree-api", status: "live" }); if (url.pathname === "/health/live") return json(200, { ok: true, serviceId: "hwlab-tasktree-api", status: "live" });
if (url.pathname === "/health/ready") return resultResponse(await options.dispatch({ operation: "health" })); if (url.pathname === "/health/ready") return resultResponse(await options.dispatch({ operation: "health" }));
if (url.pathname === "/v1/tasktree/groups" && request.method === "GET") return resultResponse(await options.dispatch({ operation: "group.list" })); if (url.pathname === "/v1/tasktree/groups" && request.method === "GET") return resultResponse(await options.dispatch({ operation: "group.list" }));
if (url.pathname === "/v1/tasktree/overview" && request.method === "GET") return resultResponse(await options.dispatch({ operation: "group.overview" }));
if (url.pathname === "/v1/tasktree/groups" && request.method === "POST") { if (url.pathname === "/v1/tasktree/groups" && request.method === "POST") {
const body = await bodyObject(request); const body = await bodyObject(request);
return resultResponse(await options.dispatch({ operation: "group.create", name: text(body.name), description: text(body.description) }), 201); return resultResponse(await options.dispatch({ operation: "group.create", name: text(body.name), description: text(body.description) }), 201);
+46 -4
View File
@@ -3,28 +3,70 @@ import { test } from "bun:test";
import { parseMdtodoImport } from "./mdtodo-import.ts"; import { parseMdtodoImport } from "./mdtodo-import.ts";
test("MDTODO import preserves status, flattens deep Rxx tasks, and attaches reports", () => { test("MDTODO import preserves three Rxx levels and attaches reports", () => {
const plan = parseMdtodoImport({ const plan = parseMdtodoImport({
sourcePath: "/tmp/demo/MDTODO.md", sourcePath: "/tmp/demo/MDTODO.md",
sourceModifiedAt: "2026-07-15T12:00:00.000Z",
markdown: `# Demo\n\n## R1 Root [in_progress]\n\nSee [任务报告](./details/R1_Task_Report.md).\n\n### R1.1 Child [completed]\n\n#### R1.1.1 Deep child [blocked]\n`, markdown: `# Demo\n\n## R1 Root [in_progress]\n\nSee [任务报告](./details/R1_Task_Report.md).\n\n### R1.1 Child [completed]\n\n#### R1.1.1 Deep child [blocked]\n`,
reports: [{ href: "./details/R1_Task_Report.md", sourcePath: "/tmp/demo/details/R1_Task_Report.md", body: "# R1 report\n\nPassed." }] reports: [{ href: "./details/R1_Task_Report.md", sourcePath: "/tmp/demo/details/R1_Task_Report.md", body: "# R1 report\n\nPassed.", modifiedAt: "2026-07-17T09:30:00.000Z" }]
}); });
assert.equal(plan.groupName, "Demo"); assert.equal(plan.groupName, "Demo");
assert.deepEqual(plan.summary, { taskCount: 1, subtaskCount: 2, reportCount: 1, missingReportCount: 0, flattenedTaskCount: 1, warningCount: 1 }); assert.deepEqual(plan.summary, { taskCount: 1, subtaskCount: 1, subsubtaskCount: 1, reportCount: 1, missingReportCount: 0, flattenedTaskCount: 0, warningCount: 0 });
assert.equal(plan.tasks[0]?.status, "in_progress"); assert.equal(plan.tasks[0]?.status, "in_progress");
assert.equal(plan.tasks[1]?.status, "completed"); assert.equal(plan.tasks[1]?.status, "completed");
assert.equal(plan.tasks[2]?.status, "blocked"); assert.equal(plan.tasks[2]?.status, "blocked");
assert.equal(plan.tasks[2]?.parentSourceId, "R1"); assert.equal(plan.tasks[2]?.parentSourceId, "R1.1");
assert.equal(plan.tasks[0]?.reports[0]?.body, "# R1 report\n\nPassed."); assert.equal(plan.tasks[0]?.reports[0]?.body, "# R1 report\n\nPassed.");
assert.equal(plan.tasks[0]?.startAt, "2026-07-14T12:00:00.000Z");
assert.equal(plan.tasks[0]?.dueAt, "2026-07-17T09:30:00.000Z");
assert.equal(plan.tasks[1]?.startAt, "2026-07-14T12:00:00.000Z");
assert.equal(plan.tasks[1]?.dueAt, "2026-07-15T12:00:00.000Z");
});
test("MDTODO import flattens only levels deeper than subsubtask", () => {
const plan = parseMdtodoImport({
sourcePath: "/tmp/demo/MDTODO.md",
sourceModifiedAt: "2026-07-15T12:00:00.000Z",
markdown: "# Demo\n\n## R2 Root\n\n### R2.9 Child\n\n#### R2.9.1 Grandchild\n\n##### R2.9.1.1 Deeper\n",
reports: []
});
assert.equal(plan.tasks[2]?.parentSourceId, "R2.9");
assert.equal(plan.tasks[3]?.parentSourceId, "R2.9");
assert.equal(plan.summary.subsubtaskCount, 2);
assert.equal(plan.summary.flattenedTaskCount, 1);
}); });
test("MDTODO import reports missing linked report files", () => { test("MDTODO import reports missing linked report files", () => {
const plan = parseMdtodoImport({ const plan = parseMdtodoImport({
sourcePath: "/tmp/demo/MDTODO.md", sourcePath: "/tmp/demo/MDTODO.md",
sourceModifiedAt: "2026-07-15T12:00:00.000Z",
markdown: "# Demo\n\n## R1 Root\n\n[Task report](./R1_Task_Report.md)\n", markdown: "# Demo\n\n## R1 Root\n\n[Task report](./R1_Task_Report.md)\n",
reports: [] reports: []
}); });
assert.equal(plan.summary.missingReportCount, 1); assert.equal(plan.summary.missingReportCount, 1);
assert.equal(plan.warnings[0]?.code, "report_file_missing"); assert.equal(plan.warnings[0]?.code, "report_file_missing");
}); });
test("MDTODO import expands a parent range to contain report-dated children", () => {
const plan = parseMdtodoImport({
sourcePath: "/tmp/demo/MDTODO.md",
sourceModifiedAt: "2026-07-15T12:00:00.000Z",
markdown: `# Demo\n\n## R1 Root\n\n### R1.1 Child\n\nSee [Task report](./R1.1_Task_Report.md).\n`,
reports: [{ href: "./R1.1_Task_Report.md", sourcePath: "/tmp/demo/R1.1_Task_Report.md", body: "# Child report", modifiedAt: "2026-07-17T09:30:00.000Z" }]
});
assert.equal(plan.tasks[1]?.startAt, "2026-07-16T09:30:00.000Z");
assert.equal(plan.tasks[1]?.dueAt, "2026-07-17T09:30:00.000Z");
assert.equal(plan.tasks[0]?.startAt, "2026-07-14T12:00:00.000Z");
assert.equal(plan.tasks[0]?.dueAt, "2026-07-17T09:30:00.000Z");
});
test("MDTODO import never truncates title content at URL colons", () => {
const plan = parseMdtodoImport({
sourcePath: "/tmp/demo/MDTODO.md",
sourceModifiedAt: "2026-07-15T12:00:00.000Z",
markdown: "# Demo\n\n## R1\n\n完成 [UniDesk #2361](https://github.com/pikasTech/unidesk/issues/2361) 并保留后续文本。\n",
reports: []
});
assert.equal(plan.tasks[0]?.title, "R1 完成 [UniDesk #2361](https://github.com/pikasTech/unidesk/issues/2361) 并保留后续文本。");
});
+75 -21
View File
@@ -1,11 +1,12 @@
import { readFile } from "node:fs/promises"; import { readFile, stat } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { TaskStatus } from "./contracts.ts"; import type { TaskStatus } from "./contracts.ts";
export type MdtodoReportSource = { href: string; sourcePath: string; body: string }; export type MdtodoReportSource = { href: string; sourcePath: string; body: string; modifiedAt: string };
export type MdtodoImportPayload = { export type MdtodoImportPayload = {
sourcePath: string; sourcePath: string;
sourceModifiedAt: string;
markdown: string; markdown: string;
reports: MdtodoReportSource[]; reports: MdtodoReportSource[];
groupName?: string; groupName?: string;
@@ -18,7 +19,9 @@ export type MdtodoImportTask = {
title: string; title: string;
description: string; description: string;
status: TaskStatus; status: TaskStatus;
reports: Array<{ title: string; body: string; status: string }>; startAt: string;
dueAt: string;
reports: Array<{ title: string; body: string; status: string; createdAt: string }>;
}; };
export type MdtodoImportPlan = { export type MdtodoImportPlan = {
@@ -30,6 +33,7 @@ export type MdtodoImportPlan = {
summary: { summary: {
taskCount: number; taskCount: number;
subtaskCount: number; subtaskCount: number;
subsubtaskCount: number;
reportCount: number; reportCount: number;
missingReportCount: number; missingReportCount: number;
flattenedTaskCount: number; flattenedTaskCount: number;
@@ -44,7 +48,7 @@ const markdownLinkPattern = /\[([^\]]*)\]\(([^)]+)\)/gu;
export async function loadMdtodoImportPayload(filePath: string, options: { groupName?: string; dryRun?: boolean } = {}): Promise<MdtodoImportPayload> { export async function loadMdtodoImportPayload(filePath: string, options: { groupName?: string; dryRun?: boolean } = {}): Promise<MdtodoImportPayload> {
const sourcePath = path.resolve(filePath); const sourcePath = path.resolve(filePath);
const markdown = await readFile(sourcePath, "utf8"); const [markdown, sourceInfo] = await Promise.all([readFile(sourcePath, "utf8"), stat(sourcePath)]);
const reports: MdtodoReportSource[] = []; const reports: MdtodoReportSource[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const link of reportLinks(markdown)) { for (const link of reportLinks(markdown)) {
@@ -52,12 +56,13 @@ export async function loadMdtodoImportPayload(filePath: string, options: { group
if (seen.has(reportPath)) continue; if (seen.has(reportPath)) continue;
seen.add(reportPath); seen.add(reportPath);
try { try {
reports.push({ href: link.href, sourcePath: reportPath, body: await readFile(reportPath, "utf8") }); const [body, reportInfo] = await Promise.all([readFile(reportPath, "utf8"), stat(reportPath)]);
reports.push({ href: link.href, sourcePath: reportPath, body, modifiedAt: reportInfo.mtime.toISOString() });
} catch { } catch {
// Missing report files are reported by the pure parser so dry-run and import agree. // Missing report files are reported by the pure parser so dry-run and import agree.
} }
} }
return { sourcePath, markdown, reports, groupName: options.groupName, dryRun: options.dryRun }; return { sourcePath, sourceModifiedAt: sourceInfo.mtime.toISOString(), markdown, reports, groupName: options.groupName, dryRun: options.dryRun };
} }
export function parseMdtodoImport(payload: MdtodoImportPayload): MdtodoImportPlan { export function parseMdtodoImport(payload: MdtodoImportPayload): MdtodoImportPlan {
@@ -68,6 +73,7 @@ export function parseMdtodoImport(payload: MdtodoImportPayload): MdtodoImportPla
const reportCount = parsed.tasks.reduce((count, task) => count + task.reports.length, 0); const reportCount = parsed.tasks.reduce((count, task) => count + task.reports.length, 0);
const missingReportCount = parsed.warnings.filter((warning) => warning.code === "report_file_missing").length; const missingReportCount = parsed.warnings.filter((warning) => warning.code === "report_file_missing").length;
const flattenedTaskCount = parsed.warnings.filter((warning) => warning.code === "nested_task_flattened").length; const flattenedTaskCount = parsed.warnings.filter((warning) => warning.code === "nested_task_flattened").length;
const tasksBySourceId = new Map(parsed.tasks.map((task) => [task.sourceId, task]));
return { return {
sourcePath: payload.sourcePath, sourcePath: payload.sourcePath,
groupName: cleanTitle(payload.groupName || documentTitle(lines, payload.sourcePath)), groupName: cleanTitle(payload.groupName || documentTitle(lines, payload.sourcePath)),
@@ -76,7 +82,8 @@ export function parseMdtodoImport(payload: MdtodoImportPayload): MdtodoImportPla
tasks: parsed.tasks, tasks: parsed.tasks,
summary: { summary: {
taskCount: parsed.tasks.filter((task) => !task.parentSourceId).length, taskCount: parsed.tasks.filter((task) => !task.parentSourceId).length,
subtaskCount: parsed.tasks.filter((task) => task.parentSourceId).length, subtaskCount: parsed.tasks.filter((task) => taskHierarchyDepth(task, tasksBySourceId) === 1).length,
subsubtaskCount: parsed.tasks.filter((task) => taskHierarchyDepth(task, tasksBySourceId) === 2).length,
reportCount, reportCount,
missingReportCount, missingReportCount,
flattenedTaskCount, flattenedTaskCount,
@@ -89,47 +96,54 @@ export function parseMdtodoImport(payload: MdtodoImportPayload): MdtodoImportPla
function parseRxx(lines: string[], blocks: RxxBlock[], payload: MdtodoImportPayload) { function parseRxx(lines: string[], blocks: RxxBlock[], payload: MdtodoImportPayload) {
const tasks: MdtodoImportTask[] = []; const tasks: MdtodoImportTask[] = [];
const warnings: MdtodoImportPlan["warnings"] = []; const warnings: MdtodoImportPlan["warnings"] = [];
const roots = new Set(blocks.filter((block) => rxxDepth(block.sourceId) === 0).map((block) => block.sourceId)); const sourceIds = new Set(blocks.map((block) => block.sourceId));
for (const block of blocks) { for (const block of blocks) {
const depth = rxxDepth(block.sourceId); const depth = rxxDepth(block.sourceId);
const rootId = `R${block.sourceId.slice(1).split(".")[0]}`; const supportedSourceId = depth > 2 ? block.sourceId.split(".").slice(0, 3).join(".") : block.sourceId;
const parentSourceId = depth > 0 && roots.has(rootId) ? rootId : null; const parentSourceId = nearestRxxParent(supportedSourceId, sourceIds);
if (depth > 1) warnings.push({ code: "nested_task_flattened", sourceId: block.sourceId, message: `${block.sourceId} was flattened to a subtask of ${rootId}` }); if (depth > 2) warnings.push({ code: "nested_task_flattened", sourceId: block.sourceId, message: `${block.sourceId} was flattened to the supported third level under ${parentSourceId ?? "the taskgroup"}` });
if (depth > 0 && !parentSourceId) warnings.push({ code: "parent_task_missing", sourceId: block.sourceId, message: `${block.sourceId} has no importable root task and was imported as a task` }); if (depth > 0 && !parentSourceId) warnings.push({ code: "parent_task_missing", sourceId: block.sourceId, message: `${block.sourceId} has no importable parent task and was imported as a task` });
const description = lines.slice(block.lineIndex + 1, block.endLine).join("\n").trim(); const description = lines.slice(block.lineIndex + 1, block.endLine).join("\n").trim();
const reports = reportsForTask(description, payload, block.sourceId, warnings);
tasks.push({ tasks.push({
sourceId: block.sourceId, sourceId: block.sourceId,
parentSourceId, parentSourceId,
title: cleanTitle(`${block.sourceId} ${block.title || conciseBodyTitle(description)}`), title: cleanTitle(`${block.sourceId} ${block.title || conciseBodyTitle(description)}`),
description, description,
status: block.status, status: block.status,
reports: reportsForTask(description, payload, block.sourceId, warnings) ...importedTaskRange(payload.sourceModifiedAt, reports),
reports
}); });
} }
expandParentRanges(tasks);
return { tasks, warnings }; return { tasks, warnings };
} }
function parseCheckboxes(lines: string[], payload: MdtodoImportPayload) { function parseCheckboxes(lines: string[], payload: MdtodoImportPayload) {
const tasks: MdtodoImportTask[] = []; const tasks: MdtodoImportTask[] = [];
const warnings: MdtodoImportPlan["warnings"] = []; const warnings: MdtodoImportPlan["warnings"] = [];
const roots: string[] = []; const parentsByDepth: string[] = [];
for (let index = 0; index < lines.length; index += 1) { for (let index = 0; index < lines.length; index += 1) {
const match = checkboxPattern.exec(lines[index]); const match = checkboxPattern.exec(lines[index]);
if (!match) continue; if (!match) continue;
const depth = Math.max(0, Math.floor(match[1].replace(/\t/gu, " ").length / 2)); const depth = Math.max(0, Math.floor(match[1].replace(/\t/gu, " ").length / 2));
const sourceId = `L${index + 1}`; const sourceId = `L${index + 1}`;
if (depth === 0) roots.push(sourceId); const supportedDepth = Math.min(depth, 2);
const parentSourceId = depth > 0 ? roots.at(-1) ?? null : null; const parentSourceId = supportedDepth > 0 ? parentsByDepth[supportedDepth - 1] ?? null : null;
if (depth > 1) warnings.push({ code: "nested_task_flattened", sourceId, message: `${sourceId} was flattened to the nearest root checkbox task` }); if (depth > 2) warnings.push({ code: "nested_task_flattened", sourceId, message: `${sourceId} was flattened to the supported third checkbox level` });
tasks.push({ tasks.push({
sourceId, sourceId,
parentSourceId, parentSourceId,
title: cleanTitle(match[3]), title: cleanTitle(match[3]),
description: "", description: "",
status: match[2].toLowerCase() === "x" ? "completed" : match[2] === "-" ? "blocked" : "pending", status: match[2].toLowerCase() === "x" ? "completed" : match[2] === "-" ? "blocked" : "pending",
...importedTaskRange(payload.sourceModifiedAt, []),
reports: [] reports: []
}); });
parentsByDepth[supportedDepth] = sourceId;
parentsByDepth.length = supportedDepth + 1;
} }
expandParentRanges(tasks);
return { tasks, warnings }; return { tasks, warnings };
} }
@@ -142,11 +156,52 @@ function reportsForTask(body: string, payload: MdtodoImportPayload, sourceId: st
warnings.push({ code: "report_file_missing", sourceId, href: link.href, message: `Report file could not be read: ${link.href}` }); warnings.push({ code: "report_file_missing", sourceId, href: link.href, message: `Report file could not be read: ${link.href}` });
continue; continue;
} }
reports.push({ title: documentTitle(report.body.split(/\r?\n/u), report.sourcePath), body: report.body, status: "imported" }); reports.push({ title: documentTitle(report.body.split(/\r?\n/u), report.sourcePath), body: report.body, status: "imported", createdAt: requiredIsoDate(report.modifiedAt, "report.modifiedAt") });
} }
return reports; return reports;
} }
function importedTaskRange(sourceModifiedAt: string, reports: MdtodoImportTask["reports"]) {
const sourceTime = new Date(requiredIsoDate(sourceModifiedAt, "sourceModifiedAt")).getTime();
const reportTimes = reports.map((report) => new Date(report.createdAt).getTime());
const dueTime = reportTimes.length ? Math.max(...reportTimes) : sourceTime;
return { startAt: new Date(dueTime - 86400000).toISOString(), dueAt: new Date(dueTime).toISOString() };
}
function expandParentRanges(tasks: MdtodoImportTask[]) {
const tasksBySourceId = new Map(tasks.map((task) => [task.sourceId, task]));
const deepestFirst = [...tasks].sort((left, right) => taskHierarchyDepth(right, tasksBySourceId) - taskHierarchyDepth(left, tasksBySourceId));
for (const child of deepestFirst) {
if (!child.parentSourceId) continue;
const parent = tasksBySourceId.get(child.parentSourceId);
if (!parent) continue;
const startAt = Math.min(new Date(parent.startAt).getTime(), new Date(child.startAt).getTime());
const dueAt = Math.max(new Date(parent.dueAt).getTime(), new Date(child.dueAt).getTime());
parent.startAt = new Date(startAt).toISOString();
parent.dueAt = new Date(dueAt).toISOString();
}
}
function nearestRxxParent(sourceId: string, sourceIds: Set<string>) {
const parts = sourceId.split(".");
while (parts.length > 1) {
parts.pop();
const candidate = parts.join(".");
if (sourceIds.has(candidate)) return candidate;
}
return null;
}
function taskHierarchyDepth(task: MdtodoImportTask, tasksBySourceId: Map<string, MdtodoImportTask>) {
let depth = 0;
let parentSourceId = task.parentSourceId;
while (parentSourceId && depth < 3) {
depth += 1;
parentSourceId = tasksBySourceId.get(parentSourceId)?.parentSourceId ?? null;
}
return depth;
}
function reportLinks(markdown: string) { function reportLinks(markdown: string) {
const links: Array<{ label: string; href: string }> = []; const links: Array<{ label: string; href: string }> = [];
for (const match of String(markdown ?? "").matchAll(markdownLinkPattern)) { for (const match of String(markdown ?? "").matchAll(markdownLinkPattern)) {
@@ -183,9 +238,8 @@ function rxxDepth(sourceId: string) { return Math.max(0, sourceId.split(".").len
function documentTitle(lines: string[], sourcePath: string) { return cleanTitle(lines.find((line) => /^#{1,6}\s+\S/u.test(line))?.replace(/^#{1,6}\s+/u, "") || path.basename(sourcePath, path.extname(sourcePath))); } function documentTitle(lines: string[], sourcePath: string) { return cleanTitle(lines.find((line) => /^#{1,6}\s+\S/u.test(line))?.replace(/^#{1,6}\s+/u, "") || path.basename(sourcePath, path.extname(sourcePath))); }
function firstBodyLine(body: string) { return body.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || "Untitled task"; } function firstBodyLine(body: string) { return body.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || "Untitled task"; }
function conciseBodyTitle(body: string) { function conciseBodyTitle(body: string) {
const line = firstBodyLine(body); return firstBodyLine(body);
const separator = line.search(/[:]/u);
return separator >= 6 && separator <= 120 ? line.slice(0, separator) : line;
} }
function cleanTitle(value: string) { return String(value ?? "").replace(/\s+<!--.*?-->\s*$/u, "").trim().slice(0, 500) || "Untitled task"; } function cleanTitle(value: string) { return String(value ?? "").replace(/\s+<!--.*?-->\s*$/u, "").trim().slice(0, 500) || "Untitled task"; }
function requiredIsoDate(value: unknown, field: string) { const date = new Date(String(value ?? "")); if (Number.isNaN(date.valueOf())) throw codedError("invalid_mdtodo_file_time", `${field} must be an ISO date`); return date.toISOString(); }
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); } function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
+35 -12
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import pg from "pg"; import pg from "pg";
import type { ExecutionReport, Milestone, TaskGroup, TaskItem, TaskStatus, Timeline } from "./contracts.ts"; import type { ExecutionReport, Milestone, TaskGroup, TaskGroupOverview, TaskItem, TaskStatus, Timeline } from "./contracts.ts";
import type { MdtodoImportPlan } from "./mdtodo-import.ts"; import type { MdtodoImportPlan } from "./mdtodo-import.ts";
const { Pool } = pg; const { Pool } = pg;
@@ -13,7 +13,7 @@ const schema = [
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
`CREATE TABLE IF NOT EXISTS tasktree_tasks ( `CREATE TABLE IF NOT EXISTS tasktree_tasks (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES tasktree_groups(id) ON DELETE CASCADE, id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES tasktree_groups(id) ON DELETE CASCADE,
parent_id TEXT REFERENCES tasktree_tasks(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('task','subtask')), parent_id TEXT REFERENCES tasktree_tasks(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('task','subtask','subsubtask')),
title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending' title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','in_progress','completed','blocked')), CHECK (status IN ('pending','in_progress','completed','blocked')),
start_at TIMESTAMPTZ, due_at TIMESTAMPTZ, sort_order INTEGER NOT NULL DEFAULT 0, start_at TIMESTAMPTZ, due_at TIMESTAMPTZ, sort_order INTEGER NOT NULL DEFAULT 0,
@@ -30,6 +30,8 @@ const schema = [
`CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_group_order ON tasktree_tasks(group_id, sort_order, created_at)`, `CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_group_order ON tasktree_tasks(group_id, sort_order, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_parent ON tasktree_tasks(parent_id)`, `CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_parent ON tasktree_tasks(parent_id)`,
`CREATE INDEX IF NOT EXISTS idx_tasktree_reports_task ON tasktree_execution_reports(task_id, created_at DESC)` `CREATE INDEX IF NOT EXISTS idx_tasktree_reports_task ON tasktree_execution_reports(task_id, created_at DESC)`
,`ALTER TABLE tasktree_tasks DROP CONSTRAINT IF EXISTS tasktree_tasks_kind_check`
,`ALTER TABLE tasktree_tasks ADD CONSTRAINT tasktree_tasks_kind_check CHECK (kind IN ('task','subtask','subsubtask'))`
]; ];
export class TaskTreeStore { export class TaskTreeStore {
@@ -47,7 +49,7 @@ export class TaskTreeStore {
try { try {
await client.query("BEGIN"); await client.query("BEGIN");
for (const statement of schema) await client.query(statement); for (const statement of schema) await client.query(statement);
await client.query("INSERT INTO tasktree_schema_migrations (migration_id) VALUES ($1) ON CONFLICT DO NOTHING", ["tasktree-20260716-v1"]); await client.query("INSERT INTO tasktree_schema_migrations (migration_id) VALUES ($1),($2) ON CONFLICT DO NOTHING", ["tasktree-20260716-v1", "tasktree-20260717-v2-subsubtask"]);
await client.query("COMMIT"); await client.query("COMMIT");
this.ready = true; this.ready = true;
} catch (error) { } catch (error) {
@@ -70,6 +72,25 @@ export class TaskTreeStore {
return result.rows.map(groupRow); return result.rows.map(groupRow);
} }
async groupOverview(): Promise<TaskGroupOverview[]> {
await this.ensureSchema();
const result = await this.pool.query(`
SELECT g.*,
COUNT(DISTINCT t.id) FILTER (WHERE t.kind='task')::int AS task_count,
COUNT(DISTINCT t.id) FILTER (WHERE t.kind='subtask')::int AS subtask_count,
COUNT(DISTINCT t.id) FILTER (WHERE t.kind='subsubtask')::int AS subsubtask_count,
COUNT(DISTINCT r.id)::int AS report_count,
MIN(t.start_at) AS start_at,
MAX(t.due_at) AS due_at
FROM tasktree_groups g
LEFT JOIN tasktree_tasks t ON t.group_id=g.id
LEFT JOIN tasktree_execution_reports r ON r.task_id=t.id
GROUP BY g.id
ORDER BY g.updated_at DESC, g.name
`);
return result.rows.map(overviewRow);
}
async getGroup(id: string): Promise<TaskGroup | null> { async getGroup(id: string): Promise<TaskGroup | null> {
await this.ensureSchema(); await this.ensureSchema();
const result = await this.pool.query("SELECT * FROM tasktree_groups WHERE id=$1", [id]); const result = await this.pool.query("SELECT * FROM tasktree_groups WHERE id=$1", [id]);
@@ -91,11 +112,11 @@ export class TaskTreeStore {
await this.ensureSchema(); await this.ensureSchema();
let kind = "task"; let kind = "task";
if (input.parentId) { if (input.parentId) {
const parent = await this.pool.query("SELECT group_id,parent_id FROM tasktree_tasks WHERE id=$1", [input.parentId]); const parent = await this.pool.query("SELECT group_id,kind FROM tasktree_tasks WHERE id=$1", [input.parentId]);
if (!parent.rows[0]) throw domainError("parent_not_found", "parent task was not found"); if (!parent.rows[0]) throw domainError("parent_not_found", "parent task was not found");
if (parent.rows[0].group_id !== input.groupId) throw domainError("parent_group_mismatch", "parent task belongs to another taskgroup"); if (parent.rows[0].group_id !== input.groupId) throw domainError("parent_group_mismatch", "parent task belongs to another taskgroup");
if (parent.rows[0].parent_id) throw domainError("maximum_depth_exceeded", "TaskTree supports task and subtask only"); if (parent.rows[0].kind === "subsubtask") throw domainError("maximum_depth_exceeded", "TaskTree supports task, subtask, and subsubtask only");
kind = "subtask"; kind = parent.rows[0].kind === "task" ? "subtask" : "subsubtask";
} }
const result = await this.pool.query( const result = await this.pool.query(
`INSERT INTO tasktree_tasks (id,group_id,parent_id,kind,title,description,start_at,due_at,sort_order) `INSERT INTO tasktree_tasks (id,group_id,parent_id,kind,title,description,start_at,due_at,sort_order)
@@ -157,16 +178,17 @@ export class TaskTreeStore {
const taskId = `tt_${randomUUID()}`; const taskId = `tt_${randomUUID()}`;
taskIds.set(task.sourceId, taskId); taskIds.set(task.sourceId, taskId);
const parentId = task.parentSourceId ? taskIds.get(task.parentSourceId) ?? null : null; const parentId = task.parentSourceId ? taskIds.get(task.parentSourceId) ?? null : null;
const kind = parentId ? "subtask" : "task"; const parentTask = parentId ? plan.tasks.find((candidate) => candidate.sourceId === task.parentSourceId) : null;
const kind = !parentId ? "task" : parentTask?.parentSourceId ? "subsubtask" : "subtask";
await client.query( await client.query(
`INSERT INTO tasktree_tasks (id,group_id,parent_id,kind,title,description,status,sort_order) `INSERT INTO tasktree_tasks (id,group_id,parent_id,kind,title,description,status,start_at,due_at,sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[taskId, groupId, parentId, kind, task.title, task.description, task.status, index] [taskId, groupId, parentId, kind, task.title, task.description, task.status, task.startAt, task.dueAt, index]
); );
for (const report of task.reports) { for (const report of task.reports) {
await client.query( await client.query(
"INSERT INTO tasktree_execution_reports (id,task_id,title,body,status) VALUES ($1,$2,$3,$4,$5)", "INSERT INTO tasktree_execution_reports (id,task_id,title,body,status,created_at) VALUES ($1,$2,$3,$4,$5,$6)",
[`tr_${randomUUID()}`, taskId, report.title, report.body, report.status] [`tr_${randomUUID()}`, taskId, report.title, report.body, report.status, report.createdAt]
); );
} }
} }
@@ -196,6 +218,7 @@ export class TaskTreeStore {
} }
function groupRow(row: any): TaskGroup { return { id: row.id, name: row.name, description: row.description, createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) }; } function groupRow(row: any): TaskGroup { return { id: row.id, name: row.name, description: row.description, createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) }; }
function overviewRow(row: any): TaskGroupOverview { return { group: groupRow(row), taskCount: Number(row.task_count), subtaskCount: Number(row.subtask_count), subsubtaskCount: Number(row.subsubtask_count), reportCount: Number(row.report_count), startAt: nullableIso(row.start_at), dueAt: nullableIso(row.due_at) }; }
function taskRow(row: any): TaskItem { return { id: row.id, groupId: row.group_id, parentId: row.parent_id, kind: row.kind, title: row.title, description: row.description, status: row.status, startAt: nullableIso(row.start_at), dueAt: nullableIso(row.due_at), sortOrder: row.sort_order, createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) }; } function taskRow(row: any): TaskItem { return { id: row.id, groupId: row.group_id, parentId: row.parent_id, kind: row.kind, title: row.title, description: row.description, status: row.status, startAt: nullableIso(row.start_at), dueAt: nullableIso(row.due_at), sortOrder: row.sort_order, createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) }; }
function milestoneRow(row: any): Milestone { return { id: row.id, groupId: row.group_id, taskId: row.task_id, title: row.title, occursAt: iso(row.occurs_at), createdAt: iso(row.created_at) }; } function milestoneRow(row: any): Milestone { return { id: row.id, groupId: row.group_id, taskId: row.task_id, title: row.title, occursAt: iso(row.occurs_at), createdAt: iso(row.created_at) }; }
function reportRow(row: any): ExecutionReport { return { id: row.id, taskId: row.task_id, title: row.title, body: row.body, status: row.status, createdAt: iso(row.created_at) }; } function reportRow(row: any): ExecutionReport { return { id: row.id, taskId: row.task_id, title: row.title, body: row.body, status: row.status, createdAt: iso(row.created_at) }; }
+12 -3
View File
@@ -29,7 +29,7 @@ test("HTTP command endpoint returns the same dispatcher DTO", async () => {
assert.deepEqual(await response.json(), expected); assert.deepEqual(await response.json(), expected);
}); });
test("native PostgreSQL store exposes timeline DTO and rejects a third task level", async () => { test("native PostgreSQL store exposes overview and three task levels", async () => {
const databaseUrl = process.env.TASKTREE_DATABASE_URL; const databaseUrl = process.env.TASKTREE_DATABASE_URL;
assert.ok(databaseUrl, "TASKTREE_DATABASE_URL is required for the TaskTree store integration test"); assert.ok(databaseUrl, "TASKTREE_DATABASE_URL is required for the TaskTree store integration test");
const store = new TaskTreeStore(databaseUrl); const store = new TaskTreeStore(databaseUrl);
@@ -42,19 +42,28 @@ test("native PostgreSQL store exposes timeline DTO and rejects a third task leve
dueAt: "2026-07-18T00:00:00.000Z" dueAt: "2026-07-18T00:00:00.000Z"
}); });
const subtask = await store.createTask({ groupId: group.id, parentId: task.id, title: "Subtask" }); const subtask = await store.createTask({ groupId: group.id, parentId: task.id, title: "Subtask" });
const subsubtask = await store.createTask({ groupId: group.id, parentId: subtask.id, title: "Subsubtask" });
await store.createMilestone({ groupId: group.id, taskId: task.id, title: "Review", occursAt: "2026-07-17T00:00:00.000Z" }); await store.createMilestone({ groupId: group.id, taskId: task.id, title: "Review", occursAt: "2026-07-17T00:00:00.000Z" });
await store.createReport({ taskId: subtask.id, title: "Execution", body: "passed" }); await store.createReport({ taskId: subtask.id, title: "Execution", body: "passed" });
await assert.rejects( await assert.rejects(
store.createTask({ groupId: group.id, parentId: subtask.id, title: "Unsupported third level" }), store.createTask({ groupId: group.id, parentId: subsubtask.id, title: "Unsupported fourth level" }),
(error: any) => error?.code === "maximum_depth_exceeded" (error: any) => error?.code === "maximum_depth_exceeded"
); );
const timeline = await store.timeline(group.id); const timeline = await store.timeline(group.id);
assert.equal(timeline?.tasks.length, 2); assert.equal(timeline?.tasks.length, 3);
assert.equal(timeline?.tasks[0]?.kind, "task"); assert.equal(timeline?.tasks[0]?.kind, "task");
assert.equal(timeline?.tasks[1]?.kind, "subtask"); assert.equal(timeline?.tasks[1]?.kind, "subtask");
assert.equal(timeline?.tasks[2]?.kind, "subsubtask");
assert.equal(timeline?.milestones.length, 1); assert.equal(timeline?.milestones.length, 1);
assert.equal(timeline?.reports.length, 1); assert.equal(timeline?.reports.length, 1);
const overview = (await store.groupOverview()).find((item) => item.group.id === group.id);
assert.equal(overview?.taskCount, 1);
assert.equal(overview?.subtaskCount, 1);
assert.equal(overview?.subsubtaskCount, 1);
assert.equal(overview?.reportCount, 1);
assert.equal(overview?.startAt, "2026-07-16T00:00:00.000Z");
assert.equal(overview?.dueAt, "2026-07-18T00:00:00.000Z");
} finally { } finally {
await store.deleteGroup(group.id); await store.deleteGroup(group.id);
await store.close(); await store.close();
+2 -1
View File
@@ -25,7 +25,7 @@ function help() {
mode: "local-dispatcher-default", mode: "local-dispatcher-default",
usage: [ usage: [
"hwlab-cli tasktree health [--overapi]", "hwlab-cli tasktree health [--overapi]",
"hwlab-cli tasktree group list|get|create|delete [--group ID] [--name TEXT]", "hwlab-cli tasktree group list|overview|get|create|delete [--group ID] [--name TEXT]",
"hwlab-cli tasktree task create|update|delete --group ID|--task ID [--parent ID] --title TEXT --start ISO --due ISO --status STATUS", "hwlab-cli tasktree task create|update|delete --group ID|--task ID [--parent ID] --title TEXT --start ISO --due ISO --status STATUS",
"hwlab-cli tasktree milestone create --group ID [--task ID] --title TEXT --at ISO", "hwlab-cli tasktree milestone create --group ID [--task ID] --title TEXT --at ISO",
"hwlab-cli tasktree report create --task ID --title TEXT (--body TEXT|--body-file PATH)", "hwlab-cli tasktree report create --task ID --title TEXT (--body TEXT|--body-file PATH)",
@@ -42,6 +42,7 @@ async function commandFrom(parsed: Parsed): Promise<TaskTreeCommand> {
if (group === "health") return { operation: "health" }; if (group === "health") return { operation: "health" };
if (group === "timeline") return { operation: "timeline.get", groupId: required(parsed, "group") }; if (group === "timeline") return { operation: "timeline.get", groupId: required(parsed, "group") };
if (group === "group" && action === "list") return { operation: "group.list" }; if (group === "group" && action === "list") return { operation: "group.list" };
if (group === "group" && action === "overview") return { operation: "group.overview" };
if (group === "group" && action === "get") return { operation: "group.get", groupId: required(parsed, "group") }; if (group === "group" && action === "get") return { operation: "group.get", groupId: required(parsed, "group") };
if (group === "group" && action === "create") return { operation: "group.create", name: required(parsed, "name"), description: parsed.values.description }; if (group === "group" && action === "create") return { operation: "group.create", name: required(parsed, "name"), description: parsed.values.description };
if (group === "group" && action === "delete") return { operation: "group.delete", groupId: required(parsed, "group") }; if (group === "group" && action === "delete") return { operation: "group.delete", groupId: required(parsed, "group") };
+1 -1
View File
@@ -17,7 +17,7 @@ export { usageAPI } from "./usage";
export { billingAPI } from "./billing"; export { billingAPI } from "./billing";
export { systemAPI, type SkillUploadFileInput } from "./system"; export { systemAPI, type SkillUploadFileInput } from "./system";
export { projectManagementAPI, type MdtodoExecutionContext, type MdtodoFileRecord, type MdtodoLaunchContextResponse, type MdtodoReportPreviewRecord, type MdtodoTaskDetailAuthority, type MdtodoTaskDetailRecord, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskPage, type MdtodoTaskRecord, type MdtodoTaskStatus, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement"; export { projectManagementAPI, type MdtodoExecutionContext, type MdtodoFileRecord, type MdtodoLaunchContextResponse, type MdtodoReportPreviewRecord, type MdtodoTaskDetailAuthority, type MdtodoTaskDetailRecord, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskPage, type MdtodoTaskRecord, type MdtodoTaskStatus, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement";
export { tasktreeAPI, type TaskTreeGroup, type TaskTreeMilestone, type TaskTreeReport, type TaskTreeTask, type TaskTreeTimeline } from "./tasktree"; export { tasktreeAPI, type TaskTreeGroup, type TaskTreeGroupOverview, type TaskTreeMilestone, type TaskTreeReport, type TaskTreeTask, type TaskTreeTimeline } from "./tasktree";
import { fetchJson } from "./client"; import { fetchJson } from "./client";
import { accessAPI } from "./access"; import { accessAPI } from "./access";
+4 -2
View File
@@ -1,14 +1,16 @@
import { fetchJson } from "./client"; import { fetchJson } from "@/api/client";
import type { ApiResult } from "@/types"; import type { ApiResult } from "@/types";
export type TaskTreeGroup = { id: string; name: string; description: string; createdAt: string; updatedAt: string }; export type TaskTreeGroup = { id: string; name: string; description: string; createdAt: string; updatedAt: string };
export type TaskTreeTask = { id: string; groupId: string; parentId: string | null; kind: "task" | "subtask"; title: string; description: string; status: string; startAt: string | null; dueAt: string | null; sortOrder: number; createdAt: string; updatedAt: string }; export type TaskTreeTask = { id: string; groupId: string; parentId: string | null; kind: "task" | "subtask" | "subsubtask"; title: string; description: string; status: string; startAt: string | null; dueAt: string | null; sortOrder: number; createdAt: string; updatedAt: string };
export type TaskTreeMilestone = { id: string; groupId: string; taskId: string | null; title: string; occursAt: string; createdAt: string }; export type TaskTreeMilestone = { id: string; groupId: string; taskId: string | null; title: string; occursAt: string; createdAt: string };
export type TaskTreeReport = { id: string; taskId: string; title: string; body: string; status: string; createdAt: string }; export type TaskTreeReport = { id: string; taskId: string; title: string; body: string; status: string; createdAt: string };
export type TaskTreeTimeline = { group: TaskTreeGroup; tasks: TaskTreeTask[]; milestones: TaskTreeMilestone[]; reports: TaskTreeReport[] }; export type TaskTreeTimeline = { group: TaskTreeGroup; tasks: TaskTreeTask[]; milestones: TaskTreeMilestone[]; reports: TaskTreeReport[] };
export type TaskTreeGroupOverview = { group: TaskTreeGroup; taskCount: number; subtaskCount: number; subsubtaskCount: number; reportCount: number; startAt: string | null; dueAt: string | null };
type Result<T> = { ok?: boolean; data?: T; error?: { code?: string; message?: string } }; type Result<T> = { ok?: boolean; data?: T; error?: { code?: string; message?: string } };
export const tasktreeAPI = { export const tasktreeAPI = {
groups: (): Promise<ApiResult<Result<{ groups: TaskTreeGroup[] }>>> => fetchJson("/v1/tasktree/groups", { timeoutMs: 12000, timeoutName: "TaskTree groups" }), groups: (): Promise<ApiResult<Result<{ groups: TaskTreeGroup[] }>>> => fetchJson("/v1/tasktree/groups", { timeoutMs: 12000, timeoutName: "TaskTree groups" }),
overview: (): Promise<ApiResult<Result<{ groups: TaskTreeGroupOverview[] }>>> => fetchJson("/v1/tasktree/overview", { timeoutMs: 12000, timeoutName: "TaskTree overview" }),
timeline: (groupId: string): Promise<ApiResult<Result<TaskTreeTimeline>>> => fetchJson(`/v1/tasktree/groups/${encodeURIComponent(groupId)}/timeline`, { timeoutMs: 12000, timeoutName: "TaskTree timeline" }) timeline: (groupId: string): Promise<ApiResult<Result<TaskTreeTimeline>>> => fetchJson(`/v1/tasktree/groups/${encodeURIComponent(groupId)}/timeline`, { timeoutMs: 12000, timeoutName: "TaskTree timeline" })
}; };
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { computed } from "vue";
import { ChevronLeft, ChevronRight, Maximize2, PanelRight, X } from "lucide-vue-next";
import type { TaskTreeReport, TaskTreeTask } from "@/api/tasktree";
import ContentViewer from "@/components/common/ContentViewer.vue";
const props = defineProps<{
task: TaskTreeTask;
tasks: TaskTreeTask[];
reports: TaskTreeReport[];
mode: "dialog" | "docked";
}>();
const emit = defineEmits<{
select: [task: TaskTreeTask];
close: [];
mode: [mode: "dialog" | "docked"];
}>();
const taskIndex = computed(() => props.tasks.findIndex((task) => task.id === props.task.id));
const previousTask = computed(() => taskIndex.value > 0 ? props.tasks[taskIndex.value - 1] ?? null : null);
const nextTask = computed(() => taskIndex.value >= 0 ? props.tasks[taskIndex.value + 1] ?? null : null);
const parent = computed(() => props.task.parentId ? props.tasks.find((task) => task.id === props.task.parentId) ?? null : null);
const siblings = computed(() => props.tasks.filter((task) => task.parentId === props.task.parentId));
const children = computed(() => props.tasks.filter((task) => task.parentId === props.task.id));
const taskReports = computed(() => props.reports.filter((report) => report.taskId === props.task.id));
const combinedMarkdown = computed(() => {
const description = props.task.description.trim() || "未填写任务说明。";
const reports = taskReports.value.length
? taskReports.value.map((report) => [
`### ${plainReportTitle(report)}`,
`_${dateLabel(report.createdAt)}_`,
reportMarkdown(report)
].join("\n\n")).join("\n\n---\n\n")
: "尚无执行报告。";
return ["## 任务说明", description, "## 执行报告", reports].join("\n\n");
});
function plainTitle(task: TaskTreeTask) {
return task.title.replace(/\[([^\]]+)\]\([^)]+\)/gu, "$1").replace(/`([^`]+)`/gu, "$1").trim();
}
function plainReportTitle(report: TaskTreeReport) {
return report.title.replace(/\[([^\]]+)\]\([^)]+\)/gu, "$1").replace(/`([^`]+)`/gu, "$1").trim();
}
function kindLabel(kind: TaskTreeTask["kind"]) { return ({ task: "Task", subtask: "Subtask", subsubtask: "Subsubtask" })[kind]; }
function dateLabel(value: string | null) { return value ? new Date(value).toLocaleString() : "未设置"; }
function reportMarkdown(report: TaskTreeReport) {
const lines = report.body.replace(/\r\n?/gu, "\n").split("\n");
const firstContent = lines.findIndex((line) => line.trim().length > 0);
if (firstContent < 0) return report.body;
const heading = lines[firstContent].match(/^#{1,6}\s+(.+?)\s*#*\s*$/u);
if (!heading || normalizeHeading(heading[1]) !== normalizeHeading(report.title)) return report.body;
lines.splice(firstContent, 1);
return lines.join("\n").trimStart();
}
function normalizeHeading(value: string) { return value.replace(/\s+/gu, "").toLocaleLowerCase(); }
</script>
<template>
<div class="tasktree-detail-panel" :data-mode="mode">
<header v-if="mode === 'docked'" class="tasktree-docked-header">
<div><small>{{ kindLabel(task.kind) }}</small><strong :title="plainTitle(task)">{{ plainTitle(task) }}</strong></div>
<button type="button" title="切换到弹窗" aria-label="切换到弹窗" @click="emit('mode', 'dialog')"><Maximize2 :size="16" aria-hidden="true" /></button>
<button type="button" title="关闭详情" aria-label="关闭详情" @click="emit('close')"><X :size="17" aria-hidden="true" /></button>
</header>
<div class="tasktree-detail-shell">
<aside v-if="mode === 'dialog'" class="tasktree-detail-tree" aria-label="局部任务层级">
<h3>局部任务</h3>
<ol class="tasktree-local-tree">
<li v-if="parent" class="level-0 relation-parent">
<button type="button" @click="emit('select', parent)"><small>父节点 · {{ kindLabel(parent.kind) }}</small><strong>{{ plainTitle(parent) }}</strong></button>
</li>
<li v-for="sibling in siblings" :key="sibling.id" :class="[parent ? 'level-1' : 'level-0', { current: sibling.id === task.id }]">
<button v-if="sibling.id !== task.id" type="button" @click="emit('select', sibling)"><small>兄弟任务 · {{ kindLabel(sibling.kind) }}</small><strong>{{ plainTitle(sibling) }}</strong></button>
<button v-else class="current" type="button" aria-current="true"><small>当前任务 · {{ kindLabel(sibling.kind) }}</small><strong>{{ plainTitle(sibling) }}</strong></button>
</li>
<li v-for="child in children" :key="child.id" :class="parent ? 'level-2' : 'level-1'">
<button type="button" @click="emit('select', child)"><small>子节点 · {{ kindLabel(child.kind) }}</small><strong>{{ plainTitle(child) }}</strong></button>
</li>
</ol>
</aside>
<div class="tasktree-detail-main">
<div class="tasktree-detail-content">
<ContentViewer class="tasktree-detail-document" :content="combinedMarkdown" mode="markdown" title="任务说明与执行报告" :filename="`tasktree-${task.id}.md`" :line-numbers="false" />
</div>
</div>
</div>
<footer class="tasktree-detail-statusbar">
<div class="tasktree-detail-pager" aria-label="前后任务导航">
<button type="button" :disabled="!previousTask" title="上一个任务" aria-label="上一个任务" @click="previousTask && emit('select', previousTask)"><ChevronLeft :size="15" aria-hidden="true" /></button>
<output>{{ taskIndex + 1 }} / {{ tasks.length }}</output>
<button type="button" :disabled="!nextTask" title="下一个任务" aria-label="下一个任务" @click="nextTask && emit('select', nextTask)"><ChevronRight :size="15" aria-hidden="true" /></button>
</div>
<span>开始 <strong>{{ dateLabel(task.startAt) }}</strong></span><span>截止 <strong>{{ dateLabel(task.dueAt) }}</strong></span><span>状态 <strong>{{ task.status }}</strong></span>
<button v-if="mode === 'dialog'" class="tasktree-detail-dock-button" type="button" title="吸附到右侧" aria-label="吸附到右侧" @click="emit('mode', 'docked')"><PanelRight :size="15" aria-hidden="true" /></button>
</footer>
</div>
</template>
<style scoped>
.tasktree-detail-panel { display: grid; grid-template-rows: minmax(0, 1fr) 32px; width: 100%; height: 100%; min-width: 0; min-height: 0; background: var(--surface-primary); }
.tasktree-detail-panel[data-mode="docked"] { grid-template-rows: 48px minmax(0, 1fr) 32px; }
.tasktree-docked-header { display: flex; min-width: 0; align-items: center; gap: 6px; padding: 7px 9px 7px 12px; border-bottom: 1px solid var(--border-color); }
.tasktree-docked-header > div { display: grid; min-width: 0; flex: 1 1 auto; gap: 1px; }
.tasktree-docked-header small { color: var(--text-secondary); font-size: 9px; }
.tasktree-docked-header strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.tasktree-docked-header button { display: grid; width: 30px; height: 30px; flex: 0 0 auto; place-items: center; padding: 0; border: 1px solid var(--border-color); border-radius: 4px; background: var(--surface-primary); color: var(--text-secondary); cursor: pointer; }
.tasktree-detail-shell { display: grid; grid-template-columns: minmax(160px, 24%) minmax(0, 1fr); min-height: 0; }
.tasktree-detail-panel[data-mode="docked"] .tasktree-detail-shell { grid-template-columns: minmax(0, 1fr); }
.tasktree-detail-tree { min-width: 0; min-height: 0; padding: 14px 10px; overflow: auto; border-right: 1px solid var(--border-color); background: color-mix(in srgb, var(--surface-secondary) 72%, var(--surface-primary)); scrollbar-width: thin; }
.tasktree-detail-tree h3 { margin: 0 6px 14px; font-size: 12px; }
.tasktree-local-tree { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
.tasktree-local-tree li { position: relative; min-width: 0; }
.tasktree-local-tree li.level-1 { padding-left: 15px; }
.tasktree-local-tree li.level-2 { padding-left: 30px; }
.tasktree-local-tree li.level-1::before,
.tasktree-local-tree li.level-2::before { position: absolute; top: 0; bottom: 0; left: 6px; width: 1px; background: var(--border-color); content: ""; }
.tasktree-local-tree li.level-2::before { left: 21px; box-shadow: -15px 0 0 var(--border-subtle); }
.tasktree-local-tree li.relation-parent { margin-bottom: 5px; padding-bottom: 6px; border-bottom: 1px solid var(--border-subtle); }
.tasktree-detail-tree button { display: grid; width: 100%; min-width: 0; gap: 2px; padding: 8px; overflow: hidden; border: 1px solid transparent; border-radius: 4px; background: transparent; color: var(--text-primary); text-align: left; cursor: pointer; }
.tasktree-detail-tree button:hover { border-color: var(--border-color); background: var(--surface-primary); }
.tasktree-detail-tree button.current { border-color: #8eb7d7; background: #eaf4fb; cursor: default; }
.tasktree-detail-tree small { color: var(--text-secondary); font-family: var(--console-font-mono); font-size: 9px; }
.tasktree-detail-tree strong { overflow: hidden; font-size: 11px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.tasktree-detail-main { display: grid; grid-template-rows: minmax(0, 1fr); min-width: 0; min-height: 0; overflow: hidden; }
.tasktree-detail-content { display: grid; min-width: 0; min-height: 0; grid-template-rows: minmax(0, 1fr); padding: 10px; overflow: hidden; }
.tasktree-detail-document { width: 100%; height: 100%; max-height: 100%; min-height: 0; overflow: hidden; }
.tasktree-detail-document :deep(.content-viewer-toolbar) { display: grid; height: 34px; min-height: 34px; grid-template-columns: minmax(0, 1fr) minmax(90px, 170px) repeat(4, 26px); align-items: center; gap: 3px; box-sizing: border-box; padding: 3px 4px; }
.tasktree-detail-document :deep(.content-viewer-toolbar strong) { min-width: 0; max-width: none; margin-right: 0; overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.tasktree-detail-document :deep(.content-viewer-search) { width: 100%; height: 26px; min-width: 0; min-height: 26px; padding-block: 2px; }
.tasktree-detail-document :deep(.content-viewer-toolbar .icon-button) { width: 26px; height: 26px; min-height: 26px; padding: 0; }
.tasktree-detail-document :deep(.content-viewer-body) { max-height: 100%; min-height: 0; overflow: auto; overscroll-behavior: contain; }
.tasktree-detail-content :deep(code),
.tasktree-detail-content :deep(a) { overflow-wrap: anywhere; word-break: break-word; }
.tasktree-detail-statusbar { display: flex; min-width: 0; align-items: center; gap: 0; padding: 0 6px; overflow-x: auto; border-top: 1px solid var(--border-color); background: var(--surface-secondary); color: var(--text-secondary); font-family: var(--console-font-mono); font-size: 9px; white-space: nowrap; }
.tasktree-detail-statusbar span + span::before { margin: 0 8px; color: var(--border-color); content: "|"; }
.tasktree-detail-statusbar strong { color: var(--text-primary); }
.tasktree-detail-pager { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 2px; margin-right: 9px; padding-right: 8px; border-right: 1px solid var(--border-color); }
.tasktree-detail-pager button,
.tasktree-detail-dock-button { display: grid; width: 24px; height: 24px; flex: 0 0 24px; place-items: center; padding: 0; border: 0; border-radius: 3px; background: transparent; color: var(--text-secondary); cursor: pointer; }
.tasktree-detail-pager button:hover:not(:disabled),
.tasktree-detail-dock-button:hover { background: var(--surface-primary); color: var(--text-primary); }
.tasktree-detail-pager button:disabled { opacity: 0.35; cursor: default; }
.tasktree-detail-pager output { min-width: 42px; text-align: center; }
.tasktree-detail-dock-button { margin-left: auto; }
@media (max-width: 720px) {
.tasktree-detail-shell { grid-template-columns: 140px minmax(0, 1fr); }
}
</style>
@@ -2,27 +2,39 @@
import { computed, nextTick, onMounted, ref, watch } from "vue"; import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { ChevronDown, ChevronRight, ZoomIn, ZoomOut } from "lucide-vue-next"; import { ChevronDown, ChevronRight, ZoomIn, ZoomOut } from "lucide-vue-next";
import { tasktreeAPI, type TaskTreeGroup, type TaskTreeReport, type TaskTreeTask, type TaskTreeTimeline } from "@/api"; import { tasktreeAPI, type TaskTreeGroup, type TaskTreeGroupOverview, type TaskTreeTask, type TaskTreeTimeline } from "@/api/tasktree";
import AsyncBoundary from "@/components/common/AsyncBoundary.vue"; import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
import BaseDialog from "@/components/common/BaseDialog.vue"; import BaseDialog from "@/components/common/BaseDialog.vue";
import ContentViewer from "@/components/common/ContentViewer.vue"; import TaskTreeDetailPanel from "@/components/tasktree/TaskTreeDetailPanel.vue";
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue"; import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
import PageCommandBar from "@/components/layout/PageCommandBar.vue"; import PageCommandBar from "@/components/layout/PageCommandBar.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const groups = ref<TaskTreeGroup[]>([]); const groups = ref<TaskTreeGroup[]>([]);
const overview = ref<TaskTreeGroupOverview[]>([]);
const timeline = ref<TaskTreeTimeline | null>(null); const timeline = ref<TaskTreeTimeline | null>(null);
const selectedTask = ref<TaskTreeTask | null>(null); const selectedTask = ref<TaskTreeTask | null>(null);
const detailMode = ref<"dialog" | "docked">(route.query.detail === "docked" ? "docked" : "dialog");
const collapsedTaskIds = ref(new Set<string>()); const collapsedTaskIds = ref(new Set<string>());
const timelineHeaderElement = ref<HTMLElement | null>(null); const timelineHeaderElement = ref<HTMLElement | null>(null);
const taskLabelsElement = ref<HTMLElement | null>(null); const taskLabelsElement = ref<HTMLElement | null>(null);
const timelineBodyElement = ref<HTMLElement | null>(null); const timelineBodyElement = ref<HTMLElement | null>(null);
const labelChildScrollers = new Map<string, HTMLElement>();
const timelineChildScrollers = new Map<string, HTMLElement>();
const initializedChildScrollers = new Set<string>();
const childScrollEdges = ref(new Map<string, { atTop: boolean; atBottom: boolean }>());
const timelineScaleIndex = ref(1); const timelineScaleIndex = ref(1);
const taskColumnWidth = ref(typeof window !== "undefined" ? Math.round(window.innerWidth * (window.innerWidth <= 720 ? 0.55 : 0.21)) : 400);
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const groupId = computed(() => String(route.params.groupId || groups.value[0]?.id || "")); const groupId = computed(() => String(route.params.groupId || ""));
const datedTasks = computed(() => timeline.value?.tasks.filter((task) => task.startAt || task.dueAt) ?? []); const requestedTaskId = computed(() => String(route.query.task || ""));
const requestedDetailMode = computed<"dialog" | "docked">(() => route.query.detail === "docked" ? "docked" : "dialog");
const isGlobalView = computed(() => !groupId.value);
const datedItems = computed(() => isGlobalView.value
? overview.value.filter((item) => item.startAt || item.dueAt)
: timeline.value?.tasks.filter((task) => task.startAt || task.dueAt) ?? []);
const childrenByParent = computed(() => { const childrenByParent = computed(() => {
const children = new Map<string, TaskTreeTask[]>(); const children = new Map<string, TaskTreeTask[]>();
for (const task of timeline.value?.tasks ?? []) { for (const task of timeline.value?.tasks ?? []) {
@@ -31,9 +43,7 @@ const childrenByParent = computed(() => {
} }
return children; return children;
}); });
const visibleTasks = computed(() => (timeline.value?.tasks ?? []).filter( const rootTasks = computed(() => (timeline.value?.tasks ?? []).filter((task) => !task.parentId));
(task) => !task.parentId || !collapsedTaskIds.value.has(task.parentId)
));
const timelineScales = [ const timelineScales = [
{ label: "周", stepMs: 2 * 86400000, tickWidth: 96, precision: "date" }, { label: "周", stepMs: 2 * 86400000, tickWidth: 96, precision: "date" },
{ label: "日", stepMs: 86400000, tickWidth: 144, precision: "date" }, { label: "日", stepMs: 86400000, tickWidth: 144, precision: "date" },
@@ -42,40 +52,170 @@ const timelineScales = [
{ label: "秒", stepMs: 15 * 60000, tickWidth: 88, precision: "second" } { label: "秒", stepMs: 15 * 60000, tickWidth: 88, precision: "second" }
] as const; ] as const;
const timelineScale = computed(() => timelineScales[timelineScaleIndex.value]); const timelineScale = computed(() => timelineScales[timelineScaleIndex.value]);
const range = computed(() => timelineRange(datedTasks.value)); const range = computed(() => timelineRange(datedItems.value));
const timelineTicks = computed(() => Array.from( const timelineTicks = computed(() => Array.from(
{ length: Math.ceil(range.value.dayCount * 86400000 / timelineScale.value.stepMs) }, { length: Math.ceil(range.value.dayCount * 86400000 / timelineScale.value.stepMs) },
(_, index) => new Date(range.value.start.getTime() + index * timelineScale.value.stepMs) (_, index) => new Date(range.value.start.getTime() + index * timelineScale.value.stepMs)
)); ));
const timelineWidth = computed(() => timelineTicks.value.length * timelineScale.value.tickWidth); const timelineWidth = computed(() => timelineTicks.value.length * timelineScale.value.tickWidth);
const selectedReports = computed<TaskTreeReport[]>(() => selectedTask.value ? timeline.value?.reports.filter((report) => report.taskId === selectedTask.value?.id) ?? [] : []); const hasActiveData = computed(() => isGlobalView.value ? overview.value.length > 0 : Boolean(timeline.value));
const asyncState = computed(() => loading.value ? timeline.value ? "refreshing" : "initial-loading" : error.value ? timeline.value ? "partial" : "error" : timeline.value?.tasks.length ? "ready" : "empty"); const asyncState = computed(() => loading.value ? hasActiveData.value ? "refreshing" : "initial-loading" : error.value ? hasActiveData.value ? "partial" : "error" : hasActiveData.value ? "ready" : "empty");
const overviewTotals = computed(() => overview.value.reduce((totals, item) => ({
tasks: totals.tasks + item.taskCount,
subtasks: totals.subtasks + item.subtaskCount,
subsubtasks: totals.subsubtasks + item.subsubtaskCount,
reports: totals.reports + item.reportCount
}), { tasks: 0, subtasks: 0, subsubtasks: 0, reports: 0 }));
onMounted(loadGroups); onMounted(() => void loadActiveView());
watch(groupId, (value) => { if (value) void loadTimeline(value); }); watch(groupId, () => void loadActiveView());
watch(requestedTaskId, syncSelectedTaskFromRoute);
watch(requestedDetailMode, (mode) => { detailMode.value = mode; });
async function loadGroups() { async function loadActiveView() {
loading.value = true; error.value = null; loading.value = true; error.value = null;
const response = await tasktreeAPI.groups(); loading.value = false; const overviewResponse = await tasktreeAPI.overview();
if (!response.ok || response.data?.ok === false) { error.value = response.data?.error?.message || response.error || "TaskTree taskgroups 加载失败"; return; } if (!overviewResponse.ok || overviewResponse.data?.ok === false) {
groups.value = response.data?.data?.groups ?? []; loading.value = false;
const requested = String(route.params.groupId || ""); error.value = overviewResponse.data?.error?.message || overviewResponse.error || "TaskTree 全局视图加载失败";
const selected = groups.value.some((group) => group.id === requested) ? requested : groups.value[0]?.id; return;
if (selected && selected !== requested) await router.replace(`/projects/tasktree/${encodeURIComponent(selected)}`); }
else if (selected) await loadTimeline(selected); overview.value = overviewResponse.data?.data?.groups ?? [];
groups.value = overview.value.map((item) => item.group);
if (!groupId.value) {
timeline.value = null;
loading.value = false;
return;
}
const timelineResponse = await tasktreeAPI.timeline(groupId.value);
loading.value = false;
if (!timelineResponse.ok || timelineResponse.data?.ok === false || !timelineResponse.data?.data) {
error.value = timelineResponse.data?.error?.message || timelineResponse.error || "TaskTree timeline 加载失败";
return;
}
timeline.value = timelineResponse.data.data;
initializedChildScrollers.clear();
syncSelectedTaskFromRoute();
} }
async function loadTimeline(id = groupId.value) { function selectGroup(event: Event) {
if (!id) return; loading.value = true; error.value = null; const value = (event.target as HTMLSelectElement).value;
const response = await tasktreeAPI.timeline(id); loading.value = false; void router.push(value ? `/projects/tasktree/${encodeURIComponent(value)}` : "/projects/tasktree");
if (!response.ok || response.data?.ok === false || !response.data?.data) { error.value = response.data?.error?.message || response.error || "TaskTree timeline 加载失败"; return; }
timeline.value = response.data.data;
} }
function selectGroup(event: Event) { void router.push(`/projects/tasktree/${encodeURIComponent((event.target as HTMLSelectElement).value)}`); } function openGroup(id: string) { void router.push(`/projects/tasktree/${encodeURIComponent(id)}`); }
function toggleTask(taskId: string) { function toggleTask(taskId: string) {
const next = new Set(collapsedTaskIds.value); const next = new Set(collapsedTaskIds.value);
if (next.has(taskId)) next.delete(taskId); if (next.has(taskId)) next.delete(taskId);
else next.add(taskId); else next.add(taskId);
collapsedTaskIds.value = next; collapsedTaskIds.value = next;
if (!next.has(taskId)) {
initializedChildScrollers.delete(`labels:${taskId}`);
initializedChildScrollers.delete(`timeline:${taskId}`);
}
}
function visibleDescendants(rootId: string) {
const result: TaskTreeTask[] = [];
const append = (parentId: string) => {
if (collapsedTaskIds.value.has(parentId)) return;
for (const child of childrenByParent.value.get(parentId) ?? []) {
result.push(child);
append(child.id);
}
};
append(rootId);
return result;
}
function setChildScroller(side: "labels" | "timeline", groupId: string, element: Element | null) {
const map = side === "labels" ? labelChildScrollers : timelineChildScrollers;
if (!element) {
map.delete(groupId);
const edges = new Map(childScrollEdges.value);
edges.delete(`${side}:${groupId}`);
childScrollEdges.value = edges;
return;
}
const scroller = element as HTMLElement;
map.set(groupId, scroller);
const key = `${side}:${groupId}`;
if (initializedChildScrollers.has(key)) {
updateChildScrollEdges(side, groupId, scroller);
return;
}
initializedChildScrollers.add(key);
void nextTick(() => {
scroller.scrollTop = scroller.scrollHeight;
updateChildScrollEdges(side, groupId, scroller);
});
}
function syncChildScroller(groupId: string, source: "labels" | "timeline") {
const from = (source === "labels" ? labelChildScrollers : timelineChildScrollers).get(groupId);
const to = (source === "labels" ? timelineChildScrollers : labelChildScrollers).get(groupId);
if (from && to && to.scrollTop !== from.scrollTop) to.scrollTop = from.scrollTop;
if (from) updateChildScrollEdges(source, groupId, from);
if (to) updateChildScrollEdges(source === "labels" ? "timeline" : "labels", groupId, to);
}
function childScrollerClass(side: "labels" | "timeline", groupId: string, count: number) {
const edge = childScrollEdges.value.get(`${side}:${groupId}`);
return {
scrollable: count > 8,
"has-hidden-above": count > 8 && edge !== undefined && !edge.atTop,
"has-hidden-below": count > 8 && edge !== undefined && !edge.atBottom
};
}
function updateChildScrollEdges(side: "labels" | "timeline", groupId: string, scroller: HTMLElement) {
const next = {
atTop: scroller.scrollTop <= 1,
atBottom: scroller.scrollTop >= scroller.scrollHeight - scroller.clientHeight - 1
};
const key = `${side}:${groupId}`;
const current = childScrollEdges.value.get(key);
if (current?.atTop === next.atTop && current.atBottom === next.atBottom) return;
const edges = new Map(childScrollEdges.value);
edges.set(key, next);
childScrollEdges.value = edges;
}
function handleChildWheel(event: WheelEvent, groupId: string, side: "labels" | "timeline") {
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
const scroller = (side === "labels" ? labelChildScrollers : timelineChildScrollers).get(groupId);
if (!scroller) return;
const delta = event.deltaY * (event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 40 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? scroller.clientHeight : 1);
const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
const available = Math.max(0, delta < 0 ? scroller.scrollTop : maxScrollTop - scroller.scrollTop);
if (Math.abs(delta) <= available + 1) return;
event.preventDefault();
scroller.scrollTop = delta < 0 ? 0 : maxScrollTop;
syncChildScroller(groupId, side);
scrollOuterBy(side, delta < 0 ? delta + available : delta - available);
}
function scrollOuterBy(side: "labels" | "timeline", delta: number) {
const source = side === "labels" ? taskLabelsElement.value : timelineBodyElement.value;
const target = side === "labels" ? timelineBodyElement.value : taskLabelsElement.value;
if (!source) return;
const nextTop = Math.max(0, Math.min(source.scrollHeight - source.clientHeight, source.scrollTop + delta));
source.scrollTop = nextTop;
if (target) target.scrollTop = Math.max(0, Math.min(target.scrollHeight - target.clientHeight, nextTop));
}
function taskColumnLimit() { return Math.max(170, Math.min(760, window.innerWidth - 280)); }
function resizeTaskColumn(width: number) { taskColumnWidth.value = Math.max(170, Math.min(taskColumnLimit(), Math.round(width))); }
function startTaskColumnResize(event: PointerEvent) {
const handle = event.currentTarget as HTMLElement;
const startX = event.clientX;
const startWidth = taskColumnWidth.value;
handle.setPointerCapture(event.pointerId);
const move = (moveEvent: PointerEvent) => resizeTaskColumn(startWidth + moveEvent.clientX - startX);
const stop = (stopEvent: PointerEvent) => {
handle.releasePointerCapture(stopEvent.pointerId);
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", stop);
handle.removeEventListener("pointercancel", stop);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", stop);
handle.addEventListener("pointercancel", stop);
}
function resizeTaskColumnByKeyboard(event: KeyboardEvent) {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
resizeTaskColumn(taskColumnWidth.value + (event.key === "ArrowLeft" ? -16 : 16));
} }
async function changeTimelineScale(direction: -1 | 1) { async function changeTimelineScale(direction: -1 | 1) {
const body = timelineBodyElement.value; const body = timelineBodyElement.value;
@@ -103,15 +243,15 @@ function syncTaskLabels() {
const body = timelineBodyElement.value; const body = timelineBodyElement.value;
if (labels && body && body.scrollTop !== labels.scrollTop) body.scrollTop = labels.scrollTop; if (labels && body && body.scrollTop !== labels.scrollTop) body.scrollTop = labels.scrollTop;
} }
function timelineRange(tasks: TaskTreeTask[]) { function timelineRange(items: Array<{ startAt: string | null; dueAt: string | null }>) {
const values = tasks.flatMap((task) => [task.startAt, task.dueAt]).filter(Boolean).map((value) => new Date(value as string).getTime()); const values = items.flatMap((item) => [item.startAt, item.dueAt]).filter(Boolean).map((value) => new Date(value as string).getTime());
const today = startDay(new Date()); const min = values.length ? Math.min(...values) : today.getTime(); const max = values.length ? Math.max(...values) : min + 6 * 86400000; const today = startDay(new Date()); const min = values.length ? Math.min(...values) : today.getTime(); const max = values.length ? Math.max(...values) : min + 6 * 86400000;
const start = new Date(startDay(new Date(min)).getTime() - 86400000); const start = new Date(startDay(new Date(min)).getTime() - 86400000);
return { start, dayCount: Math.min(120, Math.max(7, Math.ceil((max - start.getTime()) / 86400000) + 2)) }; return { start, dayCount: Math.min(120, Math.max(7, Math.ceil((max - start.getTime()) / 86400000) + 2)) };
} }
function startDay(date: Date) { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); } function startDay(date: Date) { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); }
function barStyle(task: TaskTreeTask) { function barStyle(item: { startAt: string | null; dueAt: string | null }) {
const start = new Date(task.startAt || task.dueAt || range.value.start).getTime(); const end = new Date(task.dueAt || task.startAt || range.value.start).getTime(); const unit = 100 / range.value.dayCount; const start = new Date(item.startAt || item.dueAt || range.value.start).getTime(); const end = new Date(item.dueAt || item.startAt || range.value.start).getTime(); const unit = 100 / range.value.dayCount;
const left = Math.max(0, (start - range.value.start.getTime()) / 86400000) * unit; const width = Math.max(unit * 0.65, ((end - start) / 86400000 + 1) * unit); const left = Math.max(0, (start - range.value.start.getTime()) / 86400000) * unit; const width = Math.max(unit * 0.65, ((end - start) / 86400000 + 1) * unit);
return { left: `${left}%`, width: `${Math.min(100 - left, width)}%` }; return { left: `${left}%`, width: `${Math.min(100 - left, width)}%` };
} }
@@ -130,34 +270,52 @@ function tickLabel(date: Date) {
const second = String(date.getUTCSeconds()).padStart(2, "0"); const second = String(date.getUTCSeconds()).padStart(2, "0");
return `${monthDay} ${hour}:${minute}:${second}`; return `${monthDay} ${hour}:${minute}:${second}`;
} }
function dateLabel(value: string | null) { return value ? new Date(value).toLocaleString() : "未设置"; }
function statusLabel(status: string) { return ({ pending: "待处理", in_progress: "进行中", completed: "已完成", blocked: "阻塞" } as Record<string, string>)[status] || status; } function statusLabel(status: string) { return ({ pending: "待处理", in_progress: "进行中", completed: "已完成", blocked: "阻塞" } as Record<string, string>)[status] || status; }
function displayTaskTitle(task: TaskTreeTask | null) { function taskKindLabel(kind: TaskTreeTask["kind"]) { return ({ task: "Task", subtask: "Subtask", subsubtask: "Subsubtask" })[kind]; }
const title = String(task?.title || "任务详情").trim(); function plainTaskTitle(task: TaskTreeTask | null) {
const separator = title.search(/[:]/u); return String(task?.title || "任务详情").replace(/\[([^\]]+)\]\([^)]+\)/gu, "$1").replace(/`([^`]+)`/gu, "$1").trim();
return separator >= 10 && separator <= 120 ? title.slice(0, separator) : title;
} }
function reportMarkdown(report: TaskTreeReport) { function selectTaskFromTitle(event: MouseEvent, task: TaskTreeTask) {
const lines = report.body.replace(/\r\n?/gu, "\n").split("\n"); if ((event.target as Element).closest("a")) return;
const firstContent = lines.findIndex((line) => line.trim().length > 0); selectTask(task);
if (firstContent < 0) return report.body; }
const heading = lines[firstContent].match(/^#{1,6}\s+(.+?)\s*#*\s*$/u); function selectTask(task: TaskTreeTask | null) {
if (!heading || normalizeHeading(heading[1]) !== normalizeHeading(report.title)) return report.body; if (!task) return;
lines.splice(firstContent, 1); selectedTask.value = task;
return lines.join("\n").trimStart(); if (requestedTaskId.value !== task.id) void router.replace({ query: { ...route.query, task: task.id } });
}
function closeTask() {
selectedTask.value = null;
if (!requestedTaskId.value) return;
const query = { ...route.query };
delete query.task;
delete query.detail;
void router.replace({ query });
}
function setDetailMode(mode: "dialog" | "docked") {
detailMode.value = mode;
const query = { ...route.query };
if (mode === "docked") query.detail = "docked";
else delete query.detail;
void router.replace({ query });
}
function syncSelectedTaskFromRoute() {
if (!requestedTaskId.value) { selectedTask.value = null; return; }
selectedTask.value = timeline.value?.tasks.find((task) => task.id === requestedTaskId.value) ?? null;
} }
function normalizeHeading(value: string) { return value.replace(/\s+/gu, "").toLocaleLowerCase(); }
</script> </script>
<template> <template>
<main class="tasktree-page" data-testid="tasktree-page"> <main class="tasktree-page" data-testid="tasktree-page">
<PageCommandBar title="TaskTree" eyebrow="项目时间线" description="任务、时间节点与执行报告"> <PageCommandBar title="TaskTree" eyebrow="项目时间线" description="任务、时间节点与执行报告">
<template #actions><label class="tasktree-group-select"><span class="sr-only">Taskgroup</span><select :value="groupId" @change="selectGroup"><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label><button class="icon-button" type="button" title="刷新" aria-label="刷新" :disabled="loading" @click="loadTimeline()"><span aria-hidden="true"></span></button></template> <template #actions><label class="tasktree-group-select"><span class="sr-only">Taskgroup</span><select :value="groupId" @change="selectGroup"><option value="">全部 TaskGroups</option><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label><button class="icon-button" type="button" title="刷新" aria-label="刷新" :disabled="loading" @click="loadActiveView"><span aria-hidden="true"></span></button></template>
</PageCommandBar> </PageCommandBar>
<AsyncBoundary :state="asyncState" title="TaskTree 暂无任务" :message="error || '通过 CLI 创建 taskgroup 和任务后会显示在时间轴中。'" @retry="loadGroups"> <AsyncBoundary :state="asyncState" title="TaskTree 暂无任务" :message="error || '通过 CLI 创建 taskgroup 和任务后会显示在时间轴中。'" @retry="loadActiveView">
<section v-if="timeline" class="tasktree-workspace" aria-label="TaskTree 甘特图"> <div v-if="isGlobalView || timeline" class="tasktree-view-layout" :class="{ docked: detailMode === 'docked' && selectedTask }">
<section class="tasktree-workspace" aria-label="TaskTree 甘特图">
<header class="tasktree-context"> <header class="tasktree-context">
<div><strong>{{ timeline.group.name }}</strong><p>{{ timeline.group.description || '未填写 taskgroup 说明' }}</p></div> <div v-if="isGlobalView"><strong>全局 TaskGroups</strong><p>跨项目时间范围与工作量概览</p></div>
<div v-else-if="timeline"><strong>{{ timeline.group.name }}</strong><p>{{ timeline.group.description || '未填写 taskgroup 说明' }}</p></div>
<div class="tasktree-timeline-tools"> <div class="tasktree-timeline-tools">
<span>{{ range.dayCount }} </span> <span>{{ range.dayCount }} </span>
<div class="tasktree-zoom" role="group" aria-label="时间轴缩放"> <div class="tasktree-zoom" role="group" aria-label="时间轴缩放">
@@ -167,122 +325,232 @@ function normalizeHeading(value: string) { return value.replace(/\s+/gu, "").toL
</div> </div>
</div> </div>
</header> </header>
<div class="tasktree-gantt" :style="{ '--tick-count': timelineTicks.length, '--tick-width': `${timelineScale.tickWidth}px`, '--timeline-width': `${timelineWidth}px` }"> <div class="tasktree-gantt" :class="{ 'global-view': isGlobalView }" :style="{ '--task-column-width': `${taskColumnWidth}px`, '--tick-count': timelineTicks.length, '--tick-width': `${timelineScale.tickWidth}px`, '--timeline-width': `${timelineWidth}px` }">
<div class="tasktree-corner">任务</div> <div class="tasktree-corner">{{ isGlobalView ? 'TaskGroup' : '任务' }}</div>
<div
class="tasktree-column-resizer"
role="separator"
aria-label="调整任务列表宽度"
aria-orientation="vertical"
aria-valuemin="170"
:aria-valuemax="taskColumnLimit()"
:aria-valuenow="taskColumnWidth"
tabindex="0"
@pointerdown="startTaskColumnResize"
@keydown="resizeTaskColumnByKeyboard"
/>
<div ref="timelineHeaderElement" class="tasktree-timeline-header" @scroll="syncTimelineHeader"> <div ref="timelineHeaderElement" class="tasktree-timeline-header" @scroll="syncTimelineHeader">
<div class="tasktree-days"> <div class="tasktree-days">
<span v-for="tick in timelineTicks" :key="tick.toISOString()">{{ tickLabel(tick) }}</span> <span v-for="tick in timelineTicks" :key="tick.toISOString()">{{ tickLabel(tick) }}</span>
</div> </div>
</div> </div>
<div ref="taskLabelsElement" class="tasktree-labels" @scroll="syncTaskLabels"> <div ref="taskLabelsElement" class="tasktree-labels" @scroll="syncTaskLabels">
<div class="tasktree-labels-canvas"> <div v-if="isGlobalView" class="tasktree-labels-canvas tasktree-overview-labels">
<div v-for="task in visibleTasks" :key="task.id" class="tasktree-label" :class="{ subtask: task.kind === 'subtask' }" :data-dated="Boolean(task.startAt || task.dueAt)"> <div v-for="item in overview" :key="item.group.id" class="tasktree-label tasktree-group-label" role="link" tabindex="0" @click="openGroup(item.group.id)" @keydown.enter="openGroup(item.group.id)">
<button <div class="tasktree-group-title">
v-if="childrenByParent.has(task.id)" <MessageMarkdown class="tasktree-title-markdown" :source="item.group.name" />
class="tasktree-disclosure" <ChevronRight :size="17" aria-hidden="true" />
type="button" </div>
:title="collapsedTaskIds.has(task.id) ? '展开子任务' : '收起子任务'" <span>{{ item.taskCount }} tasks · {{ item.subtaskCount + item.subsubtaskCount }} children · {{ item.reportCount }} reports</span>
:aria-label="collapsedTaskIds.has(task.id) ? `展开 ${task.title} 的子任务` : `收起 ${task.title} 的子任务`"
:aria-expanded="!collapsedTaskIds.has(task.id)"
@click="toggleTask(task.id)"
>
<ChevronRight v-if="collapsedTaskIds.has(task.id)" :size="15" aria-hidden="true" />
<ChevronDown v-else :size="15" aria-hidden="true" />
</button>
<span v-else class="tasktree-disclosure-spacer" aria-hidden="true" />
<button class="tasktree-title" type="button" @click="selectedTask = task">
<span class="tasktree-status" :data-status="task.status" />
<span :title="task.title">{{ displayTaskTitle(task) }}</span>
</button>
</div> </div>
</div> </div>
<div v-else class="tasktree-labels-canvas tasktree-detail-labels">
<section v-for="root in rootTasks" :key="root.id" class="tasktree-task-group">
<div class="tasktree-label" :data-dated="Boolean(root.startAt || root.dueAt)">
<button
v-if="childrenByParent.has(root.id)"
class="tasktree-disclosure"
type="button"
:title="collapsedTaskIds.has(root.id) ? '展开子任务' : '收起子任务'"
:aria-label="collapsedTaskIds.has(root.id) ? `展开 ${root.title} 的子任务` : `收起 ${root.title} 的子任务`"
:aria-expanded="!collapsedTaskIds.has(root.id)"
@click="toggleTask(root.id)"
>
<ChevronRight v-if="collapsedTaskIds.has(root.id)" :size="15" aria-hidden="true" />
<ChevronDown v-else :size="15" aria-hidden="true" />
</button>
<span v-else class="tasktree-disclosure-spacer" aria-hidden="true" />
<div class="tasktree-title" role="button" tabindex="0" @click="selectTaskFromTitle($event, root)" @keydown.enter="selectTask(root)">
<span class="tasktree-status" :data-status="root.status" />
<MessageMarkdown class="tasktree-title-markdown" :source="root.title" />
</div>
</div>
<div
v-if="visibleDescendants(root.id).length"
:ref="(element) => setChildScroller('labels', root.id, element as Element | null)"
class="tasktree-child-viewport"
:class="childScrollerClass('labels', root.id, visibleDescendants(root.id).length)"
@scroll="syncChildScroller(root.id, 'labels')"
@wheel="handleChildWheel($event, root.id, 'labels')"
>
<div v-for="task in visibleDescendants(root.id)" :key="task.id" class="tasktree-label" :class="task.kind" :data-dated="Boolean(task.startAt || task.dueAt)">
<button
v-if="childrenByParent.has(task.id)"
class="tasktree-disclosure"
type="button"
:title="collapsedTaskIds.has(task.id) ? '展开下级任务' : '收起下级任务'"
:aria-label="collapsedTaskIds.has(task.id) ? `展开 ${task.title} 的下级任务` : `收起 ${task.title} 的下级任务`"
:aria-expanded="!collapsedTaskIds.has(task.id)"
@click="toggleTask(task.id)"
>
<ChevronRight v-if="collapsedTaskIds.has(task.id)" :size="15" aria-hidden="true" />
<ChevronDown v-else :size="15" aria-hidden="true" />
</button>
<span v-else class="tasktree-disclosure-spacer" aria-hidden="true" />
<div class="tasktree-title" role="button" tabindex="0" @click="selectTaskFromTitle($event, task)" @keydown.enter="selectTask(task)">
<span class="tasktree-status" :data-status="task.status" />
<MessageMarkdown class="tasktree-title-markdown" :source="task.title" />
</div>
</div>
</div>
</section>
</div>
</div> </div>
<div ref="timelineBodyElement" class="tasktree-timeline-body" @scroll="syncTimelineBody"> <div ref="timelineBodyElement" class="tasktree-timeline-body" @scroll="syncTimelineBody">
<div class="tasktree-timeline-canvas"> <div v-if="isGlobalView" class="tasktree-timeline-canvas tasktree-overview-timeline">
<div v-for="task in visibleTasks" :key="task.id" class="tasktree-track" :style="{ backgroundSize: `${100 / timelineTicks.length}% 100%` }"> <div v-for="item in overview" :key="item.group.id" class="tasktree-track tasktree-group-track" :style="{ backgroundSize: `${100 / timelineTicks.length}% 100%` }">
<button <button
v-if="task.startAt || task.dueAt" v-if="item.startAt || item.dueAt"
class="tasktree-bar" class="tasktree-group-bar"
:class="[`status-${task.status}`, { subtask: task.kind === 'subtask' }]" :style="barStyle(item)"
:style="barStyle(task)"
type="button" type="button"
:aria-label="`${task.title}${statusLabel(task.status)}`" :aria-label="`打开 ${item.group.name}`"
@click="selectedTask = task" @click="openGroup(item.group.id)"
> >
<span>{{ statusLabel(task.status) }}</span> <span>{{ item.taskCount + item.subtaskCount + item.subsubtaskCount }} </span>
</button> </button>
<span
v-for="milestone in timeline.milestones.filter((item) => item.taskId === task.id)"
:key="milestone.id"
class="tasktree-milestone"
role="img"
:aria-label="`里程碑:${milestone.title}`"
:title="milestone.title"
:style="milestoneStyle(milestone.occursAt)"
/>
</div> </div>
</div> </div>
<div v-else-if="timeline" class="tasktree-timeline-canvas tasktree-detail-timeline">
<section v-for="root in rootTasks" :key="root.id" class="tasktree-task-group">
<div class="tasktree-track" :style="{ backgroundSize: `${100 / timelineTicks.length}% 100%` }">
<button v-if="root.startAt || root.dueAt" class="tasktree-bar" :class="`status-${root.status}`" :style="barStyle(root)" type="button" :aria-label="`${root.title}${statusLabel(root.status)}`" @click="selectTask(root)"><span>{{ statusLabel(root.status) }}</span></button>
<span v-for="milestone in timeline.milestones.filter((item) => item.taskId === root.id)" :key="milestone.id" class="tasktree-milestone" role="img" :aria-label="`里程碑:${milestone.title}`" :title="milestone.title" :style="milestoneStyle(milestone.occursAt)" />
</div>
<div
v-if="visibleDescendants(root.id).length"
:ref="(element) => setChildScroller('timeline', root.id, element as Element | null)"
class="tasktree-child-viewport"
:class="childScrollerClass('timeline', root.id, visibleDescendants(root.id).length)"
@scroll="syncChildScroller(root.id, 'timeline')"
@wheel="handleChildWheel($event, root.id, 'timeline')"
>
<div v-for="task in visibleDescendants(root.id)" :key="task.id" class="tasktree-track" :style="{ backgroundSize: `${100 / timelineTicks.length}% 100%` }">
<button v-if="task.startAt || task.dueAt" class="tasktree-bar" :class="[`status-${task.status}`, task.kind]" :style="barStyle(task)" type="button" :aria-label="`${task.title}${statusLabel(task.status)}`" @click="selectTask(task)"><span>{{ statusLabel(task.status) }}</span></button>
<span v-for="milestone in timeline.milestones.filter((item) => item.taskId === task.id)" :key="milestone.id" class="tasktree-milestone" role="img" :aria-label="`里程碑:${milestone.title}`" :title="milestone.title" :style="milestoneStyle(milestone.occursAt)" />
</div>
</div>
</section>
</div>
</div> </div>
</div> </div>
<footer class="tasktree-statusbar" aria-label="TaskTree 状态"> <footer class="tasktree-statusbar" aria-label="TaskTree 状态">
<span><strong>{{ groups.length }}</strong> Taskgroups</span> <span><strong>{{ overview.length }}</strong> Taskgroups</span>
<span><strong>{{ timeline.tasks.filter((task) => task.kind === 'task').length }}</strong> Tasks</span> <span><strong>{{ isGlobalView ? overviewTotals.tasks : timeline?.tasks.filter((task) => task.kind === 'task').length }}</strong> Tasks</span>
<span><strong>{{ timeline.tasks.filter((task) => task.kind === 'subtask').length }}</strong> Subtasks</span> <span><strong>{{ isGlobalView ? overviewTotals.subtasks : timeline?.tasks.filter((task) => task.kind === 'subtask').length }}</strong> Subtasks</span>
<span><strong>{{ timeline.reports.length }}</strong> Reports</span> <span><strong>{{ isGlobalView ? overviewTotals.subsubtasks : timeline?.tasks.filter((task) => task.kind === 'subsubtask').length }}</strong> Subsubtasks</span>
<span><strong>{{ timeline.milestones.length }}</strong> Milestones</span> <span><strong>{{ isGlobalView ? overviewTotals.reports : timeline?.reports.length }}</strong> Reports</span>
<span v-if="!isGlobalView"><strong>{{ timeline?.milestones.length }}</strong> Milestones</span>
<span class="tasktree-statusbar-authority">PostgreSQL</span> <span class="tasktree-statusbar-authority">PostgreSQL</span>
</footer> </footer>
</section> </section>
<aside v-if="detailMode === 'docked' && selectedTask && timeline" class="tasktree-docked-detail" aria-label="任务详情">
<TaskTreeDetailPanel :task="selectedTask" :tasks="timeline.tasks" :reports="timeline.reports" mode="docked" @select="selectTask" @close="closeTask" @mode="setDetailMode" />
</aside>
</div>
</AsyncBoundary> </AsyncBoundary>
<BaseDialog :open="Boolean(selectedTask)" :title="displayTaskTitle(selectedTask)" :description="selectedTask ? `${selectedTask.kind === 'subtask' ? 'Subtask' : 'Task'} · ${statusLabel(selectedTask.status)}` : ''" surface-class="tasktree-detail-dialog" wide @close="selectedTask = null"> <BaseDialog :open="Boolean(selectedTask) && detailMode === 'dialog'" :title="plainTaskTitle(selectedTask)" :description="selectedTask ? `${taskKindLabel(selectedTask.kind)} · ${statusLabel(selectedTask.status)}` : ''" surface-class="tasktree-detail-dialog" wide @close="closeTask">
<div v-if="selectedTask" class="tasktree-detail"><dl><div><dt>开始</dt><dd>{{ dateLabel(selectedTask.startAt) }}</dd></div><div><dt>截止</dt><dd>{{ dateLabel(selectedTask.dueAt) }}</dd></div><div><dt>状态</dt><dd>{{ statusLabel(selectedTask.status) }}</dd></div></dl><section><h3>任务说明</h3><MessageMarkdown v-if="selectedTask.description" class="tasktree-description" :source="selectedTask.description" /><p v-else class="tasktree-muted">未填写任务说明。</p></section><section><h3>执行报告</h3><div v-if="selectedReports.length" class="tasktree-reports"><article v-for="report in selectedReports" :key="report.id"><time>{{ dateLabel(report.createdAt) }}</time><ContentViewer :content="reportMarkdown(report)" mode="markdown" :title="report.title" :filename="`${report.title}.md`" :line-numbers="false" /></article></div><p v-else class="tasktree-muted">尚无执行报告</p></section></div> <TaskTreeDetailPanel v-if="selectedTask && timeline" :task="selectedTask" :tasks="timeline.tasks" :reports="timeline.reports" mode="dialog" @select="selectTask" @close="closeTask" @mode="setDetailMode" />
</BaseDialog> </BaseDialog>
</main> </main>
</template> </template>
<style scoped> <style scoped>
.tasktree-page { display: grid; gap: 12px; min-width: 0; } .tasktree-page { display: grid; gap: 12px; min-width: 0; }
.tasktree-view-layout { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 0; gap: 10px; }
.tasktree-view-layout.docked { grid-template-columns: minmax(0, 1fr) clamp(520px, 34vw, 680px); }
.tasktree-workspace { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; height: calc(100dvh - 190px); min-height: 360px; border: 1px solid var(--border-color); background: var(--surface-primary); min-width: 0; overflow: hidden; } .tasktree-workspace { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; height: calc(100dvh - 190px); min-height: 360px; border: 1px solid var(--border-color); background: var(--surface-primary); min-width: 0; overflow: hidden; }
.tasktree-docked-detail { height: calc(100dvh - 190px); min-width: 0; min-height: 360px; overflow: hidden; border: 1px solid var(--border-color); background: var(--surface-primary); }
.tasktree-context { min-height: 58px; padding: 10px 14px; border-bottom: 1px solid var(--border-color); display: flex; align-items: center; justify-content: space-between; gap: 16px; } .tasktree-context { min-height: 58px; padding: 10px 14px; border-bottom: 1px solid var(--border-color); display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.tasktree-context p { margin: 3px 0 0; color: var(--text-secondary); font-size: 12px; } .tasktree-context p { margin: 3px 0 0; color: var(--text-secondary); font-size: 12px; }
.tasktree-timeline-tools { display: flex; align-items: center; gap: 10px; color: var(--text-secondary); font-size: 12px; } .tasktree-timeline-tools { display: flex; align-items: center; gap: 10px; color: var(--text-secondary); font-size: 12px; }
.tasktree-zoom { display: grid; grid-template-columns: 28px 42px 28px; height: 28px; align-items: stretch; border: 1px solid var(--border-color); background: var(--surface-primary); } .tasktree-zoom { display: grid; grid-template-columns: 28px 42px 28px; height: 28px; align-items: stretch; border: 1px solid var(--border-color); background: var(--surface-primary); }
.tasktree-zoom button { display: grid; place-items: center; padding: 0; border: 0; background: transparent; color: var(--text-secondary); cursor: pointer; } .tasktree-zoom button { display: grid; place-items: center; padding: 0; border: 0; background: transparent; color: var(--text-secondary); cursor: pointer; }
.tasktree-zoom button:hover:not(:disabled) { background: var(--surface-secondary); color: var(--text-primary); } .tasktree-zoom button:hover:not(:disabled) { background: var(--surface-secondary); color: var(--text-primary); }
.tasktree-zoom button:disabled { opacity: 0.35; cursor: default; }
.tasktree-zoom output { display: grid; place-items: center; border-inline: 1px solid var(--border-color); color: var(--text-primary); font-family: var(--console-font-mono); font-size: 10px; } .tasktree-zoom output { display: grid; place-items: center; border-inline: 1px solid var(--border-color); color: var(--text-primary); font-family: var(--console-font-mono); font-size: 10px; }
.tasktree-gantt { display: grid; grid-template-columns: minmax(210px, 280px) minmax(0, 1fr); grid-template-rows: 34px minmax(0, 1fr); min-height: 0; overflow: hidden; } .tasktree-gantt { display: grid; grid-template-areas: "corner resize header" "labels resize timeline"; grid-template-columns: var(--task-column-width) 7px minmax(0, 1fr); grid-template-rows: 34px minmax(0, 1fr); min-height: 0; height: 100%; overflow: hidden; }
.tasktree-corner { grid-area: corner; }
.tasktree-timeline-header { grid-area: header; }
.tasktree-labels { grid-area: labels; }
.tasktree-timeline-body { grid-area: timeline; }
.tasktree-column-resizer { position: relative; z-index: 3; grid-area: resize; width: 7px; min-height: 0; border: 0; border-inline: 1px solid var(--border-color); background: var(--surface-secondary); cursor: col-resize; touch-action: none; }
.tasktree-column-resizer::after { position: absolute; inset: 0 2px; background: transparent; content: ""; transition: background-color 120ms ease; }
.tasktree-column-resizer:hover::after,
.tasktree-column-resizer:focus-visible::after,
.tasktree-column-resizer:active::after { background: #2376c9; }
.tasktree-corner { display: flex; align-items: center; padding: 0 14px; border-right: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color); background: transparent; font-size: 11px; font-weight: 700; } .tasktree-corner { display: flex; align-items: center; padding: 0 14px; border-right: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color); background: transparent; font-size: 11px; font-weight: 700; }
.tasktree-timeline-header { min-width: 0; overflow-x: auto; overflow-y: hidden; border-bottom: 1px solid var(--border-color); background: transparent; scrollbar-width: none; overscroll-behavior-inline: contain; } .tasktree-timeline-header { min-width: 0; overflow-x: auto; overflow-y: hidden; border-bottom: 1px solid var(--border-color); background: transparent; scrollbar-width: none; overscroll-behavior-inline: contain; }
.tasktree-timeline-header::-webkit-scrollbar, .tasktree-timeline-header::-webkit-scrollbar { display: none; }
.tasktree-labels::-webkit-scrollbar { display: none; } .tasktree-days { width: max(100%, var(--timeline-width)); height: 100%; display: grid; grid-template-columns: repeat(var(--tick-count), minmax(var(--tick-width), 1fr)); }
.tasktree-days { width: var(--timeline-width); height: 100%; display: grid; grid-template-columns: repeat(var(--tick-count), var(--tick-width)); }
.tasktree-days span { display: flex; align-items: center; justify-content: center; min-width: 0; padding: 0 4px; overflow: hidden; border-right: 1px solid var(--border-subtle); color: var(--text-secondary); font-size: 10px; white-space: nowrap; } .tasktree-days span { display: flex; align-items: center; justify-content: center; min-width: 0; padding: 0 4px; overflow: hidden; border-right: 1px solid var(--border-subtle); color: var(--text-secondary); font-size: 10px; white-space: nowrap; }
.tasktree-labels { min-width: 0; min-height: 0; overflow-y: auto; overflow-x: hidden; border-right: 1px solid var(--border-color); scrollbar-width: none; overscroll-behavior-block: contain; } .tasktree-labels { min-width: 0; min-height: 0; max-height: 100%; overflow-y: auto; overflow-x: hidden; border-right: 1px solid var(--border-color); scrollbar-width: thin; overscroll-behavior-block: contain; }
.tasktree-labels-canvas { min-height: 100%; display: grid; grid-auto-rows: 46px; align-content: start; } .tasktree-labels-canvas { min-height: 100%; align-content: start; }
.tasktree-overview-labels { display: grid; grid-auto-rows: 68px; }
.tasktree-task-group { min-width: 0; }
.tasktree-label { height: 46px; border-bottom: 1px solid var(--border-subtle); background: transparent; padding: 0 8px; display: flex; align-items: center; color: var(--text-primary); min-width: 0; } .tasktree-label { height: 46px; border-bottom: 1px solid var(--border-subtle); background: transparent; padding: 0 8px; display: flex; align-items: center; color: var(--text-primary); min-width: 0; }
.tasktree-label:hover { background: rgb(255 255 255 / 32%); } .tasktree-label:hover { background: rgb(255 255 255 / 32%); }
.tasktree-label.subtask { padding-left: 30px; color: var(--text-secondary); } .tasktree-label.subtask { padding-left: 30px; color: var(--text-secondary); }
.tasktree-label.subsubtask { padding-left: 54px; color: var(--text-secondary); }
.tasktree-group-label { height: 68px; padding: 9px 14px; display: grid; align-content: center; gap: 5px; cursor: pointer; }
.tasktree-group-label > span { color: var(--text-secondary); font-family: var(--console-font-mono); font-size: 10px; }
.tasktree-group-title { display: flex; min-width: 0; align-items: center; gap: 8px; color: var(--text-primary); font-size: 15px; font-weight: 700; }
.tasktree-group-title svg { flex: 0 0 auto; color: var(--text-secondary); }
.tasktree-disclosure, .tasktree-disclosure,
.tasktree-disclosure-spacer { width: 24px; height: 28px; flex: 0 0 24px; } .tasktree-disclosure-spacer { width: 24px; height: 28px; flex: 0 0 24px; }
.tasktree-disclosure { display: grid; place-items: center; padding: 0; border: 0; background: transparent; color: var(--text-secondary); cursor: pointer; } .tasktree-disclosure { display: grid; place-items: center; padding: 0; border: 0; background: transparent; color: var(--text-secondary); cursor: pointer; }
.tasktree-disclosure:hover { color: var(--text-primary); background: var(--surface-secondary); } .tasktree-disclosure:hover { color: var(--text-primary); background: var(--surface-secondary); }
.tasktree-title { display: flex; align-items: center; gap: 8px; min-width: 0; height: 100%; padding: 0 4px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; } .tasktree-title { display: flex; flex: 1 1 auto; align-items: center; gap: 8px; min-width: 0; height: 100%; padding: 0 4px; color: inherit; text-align: left; cursor: pointer; }
.tasktree-title span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tasktree-title:focus-visible { outline: 2px solid #2376c9; outline-offset: -2px; }
.tasktree-title-markdown { min-width: 0; overflow: hidden; }
.tasktree-title-markdown :deep(p) { min-width: 0; margin: 0; overflow: hidden; font: inherit; text-overflow: ellipsis; white-space: nowrap; }
.tasktree-title-markdown :deep(a) { color: #1766ad; text-decoration: underline; text-underline-offset: 2px; }
.tasktree-title-markdown :deep(code) { padding: 1px 3px; border: 1px solid var(--border-subtle); background: var(--surface-secondary); color: inherit; font-size: 0.9em; }
.tasktree-status { width: 7px; height: 7px; border-radius: 50%; background: #83909c; flex: 0 0 auto; } .tasktree-status { width: 7px; height: 7px; border-radius: 50%; background: #83909c; flex: 0 0 auto; }
.tasktree-status[data-status="completed"] { background: #1f9d67; } .tasktree-status[data-status="completed"] { background: #1f9d67; }
.tasktree-status[data-status="in_progress"] { background: #2376c9; } .tasktree-status[data-status="in_progress"] { background: #2376c9; }
.tasktree-status[data-status="blocked"] { background: #c73e48; } .tasktree-status[data-status="blocked"] { background: #c73e48; }
.tasktree-timeline-body { min-width: 0; min-height: 0; overflow: auto; scrollbar-gutter: stable; overscroll-behavior: contain; touch-action: pan-x pan-y; } .tasktree-timeline-body { min-width: 0; min-height: 0; max-height: 100%; overflow: auto; scrollbar-gutter: stable; overscroll-behavior: contain; touch-action: pan-x pan-y; }
.tasktree-timeline-canvas { width: var(--timeline-width); min-height: 100%; display: grid; grid-auto-rows: 46px; align-content: start; } .tasktree-timeline-canvas { width: max(100%, var(--timeline-width)); min-height: 100%; align-content: start; }
.tasktree-overview-timeline { display: grid; grid-auto-rows: 68px; }
.tasktree-track { position: relative; height: 46px; border-bottom: 1px solid var(--border-subtle); background-image: linear-gradient(to right, var(--border-subtle) 1px, transparent 1px); } .tasktree-track { position: relative; height: 46px; border-bottom: 1px solid var(--border-subtle); background-image: linear-gradient(to right, var(--border-subtle) 1px, transparent 1px); }
.tasktree-group-track { height: 68px; }
.tasktree-bar { position: absolute; top: 10px; height: 26px; min-width: 18px; padding: 0; border: 0; border-radius: 4px; background: #2376c9; color: white; overflow: visible; } .tasktree-bar { position: absolute; top: 10px; height: 26px; min-width: 18px; padding: 0; border: 0; border-radius: 4px; background: #2376c9; color: white; overflow: visible; }
.tasktree-bar span { position: absolute; left: 7px; top: 50%; transform: translateY(-50%); font-size: 10px; line-height: 1; white-space: nowrap; text-shadow: 0 1px 2px rgb(0 0 0 / 45%); pointer-events: none; } .tasktree-bar span { position: absolute; left: 7px; top: 50%; transform: translateY(-50%); font-size: 10px; line-height: 1; white-space: nowrap; text-shadow: 0 1px 2px rgb(0 0 0 / 45%); pointer-events: none; }
.tasktree-bar.subtask { top: 14px; height: 18px; background: #5f7d99; } .tasktree-bar.subtask { top: 14px; height: 18px; background: #5f7d99; }
.tasktree-bar.subsubtask { top: 16px; height: 14px; background: #8294a5; }
.tasktree-group-bar { position: absolute; top: 16px; height: 36px; min-width: 28px; overflow: hidden; border: 1px solid #135b94; border-radius: 4px; background: #176ba8; color: white; box-shadow: inset 0 1px 0 rgb(255 255 255 / 22%), 0 2px 5px rgb(17 67 101 / 20%); cursor: pointer; }
.tasktree-group-bar:hover { background: #125d95; }
.tasktree-group-bar span { display: block; padding: 0 10px; overflow: hidden; font-size: 11px; font-weight: 700; line-height: 34px; text-overflow: ellipsis; white-space: nowrap; }
.tasktree-bar.status-completed { background: #23845c; } .tasktree-bar.status-completed { background: #23845c; }
.tasktree-bar.status-blocked { background: #b84149; } .tasktree-bar.status-blocked { background: #b84149; }
.tasktree-bar.status-pending { background: #687581; } .tasktree-bar.status-pending { background: #687581; }
.tasktree-milestone { position: absolute; top: 15px; z-index: 2; width: 16px; height: 16px; transform: translateX(-50%); background: #d39114; clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); filter: drop-shadow(0 0 0.5px #664000) drop-shadow(0 1px 1px rgb(0 0 0 / 35%)); } .tasktree-milestone { position: absolute; top: 15px; z-index: 2; width: 16px; height: 16px; transform: translateX(-50%); background: #d39114; clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); filter: drop-shadow(0 0 0.5px #664000) drop-shadow(0 1px 1px rgb(0 0 0 / 35%)); }
.tasktree-task-group:has(> .tasktree-child-viewport.scrollable) > .tasktree-label,
.tasktree-task-group:has(> .tasktree-child-viewport.scrollable) > .tasktree-track { position: relative; z-index: 3; border-bottom-color: #91a4b3; }
.tasktree-task-group:has(> .tasktree-child-viewport.has-hidden-above) > .tasktree-label,
.tasktree-task-group:has(> .tasktree-child-viewport.has-hidden-above) > .tasktree-track { box-shadow: 0 9px 15px -8px rgb(24 45 61 / 62%); }
.tasktree-child-viewport { width: 100%; max-height: calc(46px * 8); overflow: hidden; background: color-mix(in srgb, var(--surface-secondary) 55%, var(--surface-primary)); overscroll-behavior-y: none; scrollbar-color: #7891a5 transparent; scrollbar-width: thin; }
.tasktree-child-viewport.scrollable { overflow-y: auto; box-shadow: inset 0 1px 0 rgb(18 53 76 / 12%), inset 0 -1px 0 rgb(18 53 76 / 10%); }
.tasktree-child-viewport.scrollable::before,
.tasktree-child-viewport.scrollable::after { position: sticky; z-index: 5; display: block; height: 16px; margin-bottom: -16px; opacity: 0; pointer-events: none; content: ""; transition: opacity 100ms ease; }
.tasktree-child-viewport.scrollable::before { top: 0; background: linear-gradient(to bottom, rgb(29 50 65 / 25%), rgb(29 50 65 / 8%) 45%, transparent); box-shadow: inset 0 7px 9px -8px rgb(12 31 44 / 75%); }
.tasktree-child-viewport.scrollable::after { bottom: 0; margin-top: -16px; margin-bottom: 0; background: linear-gradient(to top, rgb(29 50 65 / 20%), transparent); box-shadow: inset 0 -7px 9px -8px rgb(12 31 44 / 65%); }
.tasktree-child-viewport.has-hidden-above::before,
.tasktree-child-viewport.has-hidden-below::after { opacity: 1; }
.tasktree-child-viewport::-webkit-scrollbar { width: 9px; }
.tasktree-child-viewport::-webkit-scrollbar-track { background: transparent; }
.tasktree-child-viewport::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 999px; background: #7891a5; background-clip: padding-box; }
.tasktree-child-viewport::-webkit-scrollbar-thumb:hover { background: #526f86; background-clip: padding-box; }
.tasktree-statusbar { min-width: 0; min-height: 28px; padding: 0 10px; display: flex; align-items: center; gap: 0; overflow-x: auto; border-top: 1px solid var(--border-color); background: var(--surface-secondary); color: var(--text-secondary); font-family: var(--console-font-mono); font-size: 10px; white-space: nowrap; scrollbar-width: thin; } .tasktree-statusbar { min-width: 0; min-height: 28px; padding: 0 10px; display: flex; align-items: center; gap: 0; overflow-x: auto; border-top: 1px solid var(--border-color); background: var(--surface-secondary); color: var(--text-secondary); font-family: var(--console-font-mono); font-size: 10px; white-space: nowrap; scrollbar-width: thin; }
.tasktree-statusbar span { display: inline-flex; align-items: center; gap: 4px; } .tasktree-statusbar span { display: inline-flex; align-items: center; gap: 4px; }
.tasktree-statusbar span + span::before { margin: 0 9px; color: var(--border-color); content: "|"; } .tasktree-statusbar span + span::before { margin: 0 9px; color: var(--border-color); content: "|"; }
@@ -291,29 +559,20 @@ function normalizeHeading(value: string) { return value.replace(/\s+/gu, "").toL
.tasktree-statusbar-authority::after { width: 6px; height: 6px; margin-left: 6px; border-radius: 50%; background: #1f9d67; content: ""; } .tasktree-statusbar-authority::after { width: 6px; height: 6px; margin-left: 6px; border-radius: 50%; background: #1f9d67; content: ""; }
.tasktree-group-select { display: flex; align-items: center; } .tasktree-group-select { display: flex; align-items: center; }
.tasktree-group-select select { min-width: 180px; padding: 7px 10px; border: 1px solid var(--border-color); background: var(--surface-primary); color: var(--text-primary); } .tasktree-group-select select { min-width: 180px; padding: 7px 10px; border: 1px solid var(--border-color); background: var(--surface-primary); color: var(--text-primary); }
.tasktree-detail { display: grid; gap: 18px; } :global(.console-overlay-surface.tasktree-detail-dialog) { height: 90dvh; min-height: 90dvh; max-height: 90dvh; }
.tasktree-detail dl { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 0; } :global(.console-overlay-surface.tasktree-detail-dialog .console-overlay-body) { min-height: 0; overflow: hidden; padding: 0; }
.tasktree-detail dl div { padding: 10px; border-left: 2px solid #2376c9; background: var(--surface-secondary); }
.tasktree-detail dt { font-size: 11px; color: var(--text-secondary); } @media (max-width: 1100px) {
.tasktree-detail dd { margin: 4px 0 0; } .tasktree-view-layout.docked { grid-template-columns: minmax(0, 1fr) minmax(320px, 42vw); }
.tasktree-detail h3 { font-size: 13px; margin: 0 0 8px; } }
.tasktree-detail p { margin: 0; white-space: pre-wrap; }
.tasktree-description { font-size: 14px; line-height: 1.65; }
.tasktree-reports { display: grid; gap: 8px; }
.tasktree-reports article { border: 1px solid var(--border-color); padding: 10px; }
.tasktree-reports time,
.tasktree-muted { font-size: 11px; color: var(--text-secondary); }
.tasktree-reports time { display: block; margin: 0 0 6px; text-align: right; }
.tasktree-reports :deep(.content-viewer) { max-height: min(52vh, 520px); }
.tasktree-reports :deep(.content-viewer-body) { overflow: auto; }
@media (max-width: 720px) { @media (max-width: 720px) {
.tasktree-view-layout.docked { grid-template-columns: minmax(0, 1fr); }
.tasktree-workspace { height: calc(100dvh - 225px); min-height: 400px; } .tasktree-workspace { height: calc(100dvh - 225px); min-height: 400px; }
.tasktree-docked-detail { height: 80dvh; min-height: 480px; }
.tasktree-context { align-items: flex-start; } .tasktree-context { align-items: flex-start; }
.tasktree-timeline-tools { flex-direction: column; align-items: flex-end; gap: 4px; } .tasktree-timeline-tools { flex-direction: column; align-items: flex-end; gap: 4px; }
.tasktree-gantt { grid-template-columns: 170px minmax(0, 1fr); }
.tasktree-statusbar-authority { margin-left: 0; } .tasktree-statusbar-authority { margin-left: 0; }
.tasktree-detail dl { grid-template-columns: 1fr; }
.tasktree-group-select select { min-width: 130px; max-width: 42vw; } .tasktree-group-select select { min-width: 130px; max-width: 42vw; }
} }
</style> </style>