From 3130e2b9db40b3ad709df90bf565a37554c16689 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 17 Jul 2026 09:26:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(tasktree):=20=E5=AE=8C=E5=96=84=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E4=B8=8E=E7=94=98=E7=89=B9=E8=AF=A6=E6=83=85=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tasktree/contracts.ts | 15 +- internal/tasktree/dispatcher.ts | 1 + internal/tasktree/http.ts | 1 + internal/tasktree/mdtodo-import.test.ts | 50 +- internal/tasktree/mdtodo-import.ts | 96 +++- internal/tasktree/store.ts | 47 +- internal/tasktree/tasktree.test.ts | 15 +- tools/src/tasktree-cli.ts | 3 +- web/hwlab-cloud-web/src/api/index.ts | 2 +- web/hwlab-cloud-web/src/api/tasktree.ts | 6 +- .../tasktree/TaskTreeDetailPanel.vue | 148 +++++ .../src/views/projects/TaskTreeView.vue | 505 +++++++++++++----- 12 files changed, 720 insertions(+), 169 deletions(-) create mode 100644 web/hwlab-cloud-web/src/components/tasktree/TaskTreeDetailPanel.vue diff --git a/internal/tasktree/contracts.ts b/internal/tasktree/contracts.ts index 1e490d14..18541351 100644 --- a/internal/tasktree/contracts.ts +++ b/internal/tasktree/contracts.ts @@ -1,5 +1,5 @@ export type TaskStatus = "pending" | "in_progress" | "completed" | "blocked"; -export type TaskKind = "task" | "subtask"; +export type TaskKind = "task" | "subtask" | "subsubtask"; export type TaskGroup = { id: string; @@ -49,9 +49,20 @@ export type Timeline = { reports: ExecutionReport[]; }; +export type TaskGroupOverview = { + group: TaskGroup; + taskCount: number; + subtaskCount: number; + subsubtaskCount: number; + reportCount: number; + startAt: string | null; + dueAt: string | null; +}; + export type TaskTreeCommand = | { operation: "health" } | { operation: "group.list" } + | { operation: "group.overview" } | { operation: "group.get"; groupId: string } | { operation: "group.create"; name: string; description?: string } | { operation: "group.delete"; groupId: string } @@ -60,7 +71,7 @@ export type TaskTreeCommand = | { operation: "task.delete"; 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: "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: "workflow.start"; taskId: string }; diff --git a/internal/tasktree/dispatcher.ts b/internal/tasktree/dispatcher.ts index fb78d92c..8fb24f7d 100644 --- a/internal/tasktree/dispatcher.ts +++ b/internal/tasktree/dispatcher.ts @@ -19,6 +19,7 @@ export function createTaskTreeDispatcher(options: TaskTreeDispatcherOptions) { let data: unknown; if (operation === "health") data = await store.health(); 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.create") data = await store.createGroup(requiredText(command.name, "name"), command.description); else if (operation === "group.delete") data = { deleted: await store.deleteGroup(command.groupId) }; diff --git a/internal/tasktree/http.ts b/internal/tasktree/http.ts index f19c4872..225bfc63 100644 --- a/internal/tasktree/http.ts +++ b/internal/tasktree/http.ts @@ -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/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/overview" && request.method === "GET") return resultResponse(await options.dispatch({ operation: "group.overview" })); if (url.pathname === "/v1/tasktree/groups" && request.method === "POST") { const body = await bodyObject(request); return resultResponse(await options.dispatch({ operation: "group.create", name: text(body.name), description: text(body.description) }), 201); diff --git a/internal/tasktree/mdtodo-import.test.ts b/internal/tasktree/mdtodo-import.test.ts index 8768b33f..c82f563c 100644 --- a/internal/tasktree/mdtodo-import.test.ts +++ b/internal/tasktree/mdtodo-import.test.ts @@ -3,28 +3,70 @@ import { test } from "bun:test"; 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({ 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`, - 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.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[1]?.status, "completed"); 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]?.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", () => { const plan = parseMdtodoImport({ 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", reports: [] }); assert.equal(plan.summary.missingReportCount, 1); 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) 并保留后续文本。"); +}); diff --git a/internal/tasktree/mdtodo-import.ts b/internal/tasktree/mdtodo-import.ts index c1b916a1..4e54528b 100644 --- a/internal/tasktree/mdtodo-import.ts +++ b/internal/tasktree/mdtodo-import.ts @@ -1,11 +1,12 @@ -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import path from "node:path"; 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 = { sourcePath: string; + sourceModifiedAt: string; markdown: string; reports: MdtodoReportSource[]; groupName?: string; @@ -18,7 +19,9 @@ export type MdtodoImportTask = { title: string; description: string; 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 = { @@ -30,6 +33,7 @@ export type MdtodoImportPlan = { summary: { taskCount: number; subtaskCount: number; + subsubtaskCount: number; reportCount: number; missingReportCount: number; flattenedTaskCount: number; @@ -44,7 +48,7 @@ const markdownLinkPattern = /\[([^\]]*)\]\(([^)]+)\)/gu; export async function loadMdtodoImportPayload(filePath: string, options: { groupName?: string; dryRun?: boolean } = {}): Promise { 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 seen = new Set(); for (const link of reportLinks(markdown)) { @@ -52,12 +56,13 @@ export async function loadMdtodoImportPayload(filePath: string, options: { group if (seen.has(reportPath)) continue; seen.add(reportPath); 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 { // 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 { @@ -68,6 +73,7 @@ export function parseMdtodoImport(payload: MdtodoImportPayload): MdtodoImportPla 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 flattenedTaskCount = parsed.warnings.filter((warning) => warning.code === "nested_task_flattened").length; + const tasksBySourceId = new Map(parsed.tasks.map((task) => [task.sourceId, task])); return { sourcePath: payload.sourcePath, groupName: cleanTitle(payload.groupName || documentTitle(lines, payload.sourcePath)), @@ -76,7 +82,8 @@ export function parseMdtodoImport(payload: MdtodoImportPayload): MdtodoImportPla tasks: parsed.tasks, summary: { 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, missingReportCount, flattenedTaskCount, @@ -89,47 +96,54 @@ export function parseMdtodoImport(payload: MdtodoImportPayload): MdtodoImportPla function parseRxx(lines: string[], blocks: RxxBlock[], payload: MdtodoImportPayload) { const tasks: MdtodoImportTask[] = []; 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) { const depth = rxxDepth(block.sourceId); - const rootId = `R${block.sourceId.slice(1).split(".")[0]}`; - const parentSourceId = depth > 0 && roots.has(rootId) ? rootId : null; - if (depth > 1) warnings.push({ code: "nested_task_flattened", sourceId: block.sourceId, message: `${block.sourceId} was flattened to a subtask of ${rootId}` }); - 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` }); + const supportedSourceId = depth > 2 ? block.sourceId.split(".").slice(0, 3).join(".") : block.sourceId; + const parentSourceId = nearestRxxParent(supportedSourceId, sourceIds); + 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 parent task and was imported as a task` }); const description = lines.slice(block.lineIndex + 1, block.endLine).join("\n").trim(); + const reports = reportsForTask(description, payload, block.sourceId, warnings); tasks.push({ sourceId: block.sourceId, parentSourceId, title: cleanTitle(`${block.sourceId} ${block.title || conciseBodyTitle(description)}`), description, status: block.status, - reports: reportsForTask(description, payload, block.sourceId, warnings) + ...importedTaskRange(payload.sourceModifiedAt, reports), + reports }); } + expandParentRanges(tasks); return { tasks, warnings }; } function parseCheckboxes(lines: string[], payload: MdtodoImportPayload) { const tasks: MdtodoImportTask[] = []; const warnings: MdtodoImportPlan["warnings"] = []; - const roots: string[] = []; + const parentsByDepth: string[] = []; for (let index = 0; index < lines.length; index += 1) { const match = checkboxPattern.exec(lines[index]); if (!match) continue; const depth = Math.max(0, Math.floor(match[1].replace(/\t/gu, " ").length / 2)); const sourceId = `L${index + 1}`; - if (depth === 0) roots.push(sourceId); - const parentSourceId = depth > 0 ? roots.at(-1) ?? null : null; - if (depth > 1) warnings.push({ code: "nested_task_flattened", sourceId, message: `${sourceId} was flattened to the nearest root checkbox task` }); + const supportedDepth = Math.min(depth, 2); + const parentSourceId = supportedDepth > 0 ? parentsByDepth[supportedDepth - 1] ?? null : null; + if (depth > 2) warnings.push({ code: "nested_task_flattened", sourceId, message: `${sourceId} was flattened to the supported third checkbox level` }); tasks.push({ sourceId, parentSourceId, title: cleanTitle(match[3]), description: "", status: match[2].toLowerCase() === "x" ? "completed" : match[2] === "-" ? "blocked" : "pending", + ...importedTaskRange(payload.sourceModifiedAt, []), reports: [] }); + parentsByDepth[supportedDepth] = sourceId; + parentsByDepth.length = supportedDepth + 1; } + expandParentRanges(tasks); 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}` }); 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; } +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) { + 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) { + 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) { const links: Array<{ label: string; href: string }> = []; 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 firstBodyLine(body: string) { return body.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || "Untitled task"; } function conciseBodyTitle(body: string) { - const line = firstBodyLine(body); - const separator = line.search(/[::]/u); - return separator >= 6 && separator <= 120 ? line.slice(0, separator) : line; + return firstBodyLine(body); } 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 }); } diff --git a/internal/tasktree/store.ts b/internal/tasktree/store.ts index 87271903..012ed685 100644 --- a/internal/tasktree/store.ts +++ b/internal/tasktree/store.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; 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"; const { Pool } = pg; @@ -13,7 +13,7 @@ const schema = [ created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`, `CREATE TABLE IF NOT EXISTS tasktree_tasks ( 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' CHECK (status IN ('pending','in_progress','completed','blocked')), 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_parent ON tasktree_tasks(parent_id)`, `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 { @@ -47,7 +49,7 @@ export class TaskTreeStore { try { await client.query("BEGIN"); 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"); this.ready = true; } catch (error) { @@ -70,6 +72,25 @@ export class TaskTreeStore { return result.rows.map(groupRow); } + async groupOverview(): Promise { + 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 { await this.ensureSchema(); const result = await this.pool.query("SELECT * FROM tasktree_groups WHERE id=$1", [id]); @@ -91,11 +112,11 @@ export class TaskTreeStore { await this.ensureSchema(); let kind = "task"; 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].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"); - kind = "subtask"; + if (parent.rows[0].kind === "subsubtask") throw domainError("maximum_depth_exceeded", "TaskTree supports task, subtask, and subsubtask only"); + kind = parent.rows[0].kind === "task" ? "subtask" : "subsubtask"; } const result = await this.pool.query( `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()}`; taskIds.set(task.sourceId, taskId); 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( - `INSERT INTO tasktree_tasks (id,group_id,parent_id,kind,title,description,status,sort_order) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, - [taskId, groupId, parentId, kind, task.title, task.description, task.status, index] + `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,$9,$10)`, + [taskId, groupId, parentId, kind, task.title, task.description, task.status, task.startAt, task.dueAt, index] ); for (const report of task.reports) { await client.query( - "INSERT INTO tasktree_execution_reports (id,task_id,title,body,status) VALUES ($1,$2,$3,$4,$5)", - [`tr_${randomUUID()}`, taskId, report.title, report.body, report.status] + "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, 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 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 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) }; } diff --git a/internal/tasktree/tasktree.test.ts b/internal/tasktree/tasktree.test.ts index ee832e28..722fac3f 100644 --- a/internal/tasktree/tasktree.test.ts +++ b/internal/tasktree/tasktree.test.ts @@ -29,7 +29,7 @@ test("HTTP command endpoint returns the same dispatcher DTO", async () => { 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; assert.ok(databaseUrl, "TASKTREE_DATABASE_URL is required for the TaskTree store integration test"); 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" }); 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.createReport({ taskId: subtask.id, title: "Execution", body: "passed" }); 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" ); 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[1]?.kind, "subtask"); + assert.equal(timeline?.tasks[2]?.kind, "subsubtask"); assert.equal(timeline?.milestones.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 { await store.deleteGroup(group.id); await store.close(); diff --git a/tools/src/tasktree-cli.ts b/tools/src/tasktree-cli.ts index 67da1196..35c72ddb 100644 --- a/tools/src/tasktree-cli.ts +++ b/tools/src/tasktree-cli.ts @@ -25,7 +25,7 @@ function help() { mode: "local-dispatcher-default", usage: [ "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 milestone create --group ID [--task ID] --title TEXT --at ISO", "hwlab-cli tasktree report create --task ID --title TEXT (--body TEXT|--body-file PATH)", @@ -42,6 +42,7 @@ async function commandFrom(parsed: Parsed): Promise { if (group === "health") return { operation: "health" }; if (group === "timeline") return { operation: "timeline.get", groupId: required(parsed, "group") }; 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 === "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") }; diff --git a/web/hwlab-cloud-web/src/api/index.ts b/web/hwlab-cloud-web/src/api/index.ts index 2709a797..4cb09787 100644 --- a/web/hwlab-cloud-web/src/api/index.ts +++ b/web/hwlab-cloud-web/src/api/index.ts @@ -17,7 +17,7 @@ export { usageAPI } from "./usage"; export { billingAPI } from "./billing"; 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 { 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 { accessAPI } from "./access"; diff --git a/web/hwlab-cloud-web/src/api/tasktree.ts b/web/hwlab-cloud-web/src/api/tasktree.ts index 0a8d5748..fc3d3daa 100644 --- a/web/hwlab-cloud-web/src/api/tasktree.ts +++ b/web/hwlab-cloud-web/src/api/tasktree.ts @@ -1,14 +1,16 @@ -import { fetchJson } from "./client"; +import { fetchJson } from "@/api/client"; import type { ApiResult } from "@/types"; 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 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 TaskTreeGroupOverview = { group: TaskTreeGroup; taskCount: number; subtaskCount: number; subsubtaskCount: number; reportCount: number; startAt: string | null; dueAt: string | null }; type Result = { ok?: boolean; data?: T; error?: { code?: string; message?: string } }; export const tasktreeAPI = { groups: (): Promise>> => fetchJson("/v1/tasktree/groups", { timeoutMs: 12000, timeoutName: "TaskTree groups" }), + overview: (): Promise>> => fetchJson("/v1/tasktree/overview", { timeoutMs: 12000, timeoutName: "TaskTree overview" }), timeline: (groupId: string): Promise>> => fetchJson(`/v1/tasktree/groups/${encodeURIComponent(groupId)}/timeline`, { timeoutMs: 12000, timeoutName: "TaskTree timeline" }) }; diff --git a/web/hwlab-cloud-web/src/components/tasktree/TaskTreeDetailPanel.vue b/web/hwlab-cloud-web/src/components/tasktree/TaskTreeDetailPanel.vue new file mode 100644 index 00000000..bd710b8c --- /dev/null +++ b/web/hwlab-cloud-web/src/components/tasktree/TaskTreeDetailPanel.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue b/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue index 6420d9e3..06b48465 100644 --- a/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue +++ b/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue @@ -2,27 +2,39 @@ import { computed, nextTick, onMounted, ref, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; 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 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 PageCommandBar from "@/components/layout/PageCommandBar.vue"; const route = useRoute(); const router = useRouter(); const groups = ref([]); +const overview = ref([]); const timeline = ref(null); const selectedTask = ref(null); +const detailMode = ref<"dialog" | "docked">(route.query.detail === "docked" ? "docked" : "dialog"); const collapsedTaskIds = ref(new Set()); const timelineHeaderElement = ref(null); const taskLabelsElement = ref(null); const timelineBodyElement = ref(null); +const labelChildScrollers = new Map(); +const timelineChildScrollers = new Map(); +const initializedChildScrollers = new Set(); +const childScrollEdges = ref(new Map()); 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 error = ref(null); -const groupId = computed(() => String(route.params.groupId || groups.value[0]?.id || "")); -const datedTasks = computed(() => timeline.value?.tasks.filter((task) => task.startAt || task.dueAt) ?? []); +const groupId = computed(() => String(route.params.groupId || "")); +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 children = new Map(); for (const task of timeline.value?.tasks ?? []) { @@ -31,9 +43,7 @@ const childrenByParent = computed(() => { } return children; }); -const visibleTasks = computed(() => (timeline.value?.tasks ?? []).filter( - (task) => !task.parentId || !collapsedTaskIds.value.has(task.parentId) -)); +const rootTasks = computed(() => (timeline.value?.tasks ?? []).filter((task) => !task.parentId)); const timelineScales = [ { label: "周", stepMs: 2 * 86400000, tickWidth: 96, precision: "date" }, { label: "日", stepMs: 86400000, tickWidth: 144, precision: "date" }, @@ -42,40 +52,170 @@ const timelineScales = [ { label: "秒", stepMs: 15 * 60000, tickWidth: 88, precision: "second" } ] as const; const timelineScale = computed(() => timelineScales[timelineScaleIndex.value]); -const range = computed(() => timelineRange(datedTasks.value)); +const range = computed(() => timelineRange(datedItems.value)); const timelineTicks = computed(() => Array.from( { length: Math.ceil(range.value.dayCount * 86400000 / timelineScale.value.stepMs) }, (_, index) => new Date(range.value.start.getTime() + index * timelineScale.value.stepMs) )); const timelineWidth = computed(() => timelineTicks.value.length * timelineScale.value.tickWidth); -const selectedReports = computed(() => selectedTask.value ? timeline.value?.reports.filter((report) => report.taskId === selectedTask.value?.id) ?? [] : []); -const asyncState = computed(() => loading.value ? timeline.value ? "refreshing" : "initial-loading" : error.value ? timeline.value ? "partial" : "error" : timeline.value?.tasks.length ? "ready" : "empty"); +const hasActiveData = computed(() => isGlobalView.value ? overview.value.length > 0 : Boolean(timeline.value)); +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); -watch(groupId, (value) => { if (value) void loadTimeline(value); }); +onMounted(() => void loadActiveView()); +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; - const response = await tasktreeAPI.groups(); loading.value = false; - if (!response.ok || response.data?.ok === false) { error.value = response.data?.error?.message || response.error || "TaskTree taskgroups 加载失败"; return; } - groups.value = response.data?.data?.groups ?? []; - const requested = String(route.params.groupId || ""); - const selected = groups.value.some((group) => group.id === requested) ? requested : groups.value[0]?.id; - if (selected && selected !== requested) await router.replace(`/projects/tasktree/${encodeURIComponent(selected)}`); - else if (selected) await loadTimeline(selected); + const overviewResponse = await tasktreeAPI.overview(); + if (!overviewResponse.ok || overviewResponse.data?.ok === false) { + loading.value = false; + error.value = overviewResponse.data?.error?.message || overviewResponse.error || "TaskTree 全局视图加载失败"; + return; + } + 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) { - if (!id) return; loading.value = true; error.value = null; - const response = await tasktreeAPI.timeline(id); loading.value = false; - 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) { + const value = (event.target as HTMLSelectElement).value; + void router.push(value ? `/projects/tasktree/${encodeURIComponent(value)}` : "/projects/tasktree"); } -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) { const next = new Set(collapsedTaskIds.value); if (next.has(taskId)) next.delete(taskId); else next.add(taskId); 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) { const body = timelineBodyElement.value; @@ -103,15 +243,15 @@ function syncTaskLabels() { const body = timelineBodyElement.value; if (labels && body && body.scrollTop !== labels.scrollTop) body.scrollTop = labels.scrollTop; } -function timelineRange(tasks: TaskTreeTask[]) { - const values = tasks.flatMap((task) => [task.startAt, task.dueAt]).filter(Boolean).map((value) => new Date(value as string).getTime()); +function timelineRange(items: Array<{ startAt: string | null; dueAt: string | null }>) { + 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 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)) }; } function startDay(date: Date) { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); } -function barStyle(task: TaskTreeTask) { - 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; +function barStyle(item: { startAt: string | null; dueAt: string | null }) { + 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); 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"); 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)[status] || status; } -function displayTaskTitle(task: TaskTreeTask | null) { - const title = String(task?.title || "任务详情").trim(); - const separator = title.search(/[::]/u); - return separator >= 10 && separator <= 120 ? title.slice(0, separator) : title; +function taskKindLabel(kind: TaskTreeTask["kind"]) { return ({ task: "Task", subtask: "Subtask", subsubtask: "Subsubtask" })[kind]; } +function plainTaskTitle(task: TaskTreeTask | null) { + return String(task?.title || "任务详情").replace(/\[([^\]]+)\]\([^)]+\)/gu, "$1").replace(/`([^`]+)`/gu, "$1").trim(); } -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 selectTaskFromTitle(event: MouseEvent, task: TaskTreeTask) { + if ((event.target as Element).closest("a")) return; + selectTask(task); +} +function selectTask(task: TaskTreeTask | null) { + if (!task) return; + selectedTask.value = task; + 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(); }