From 1beec6efe88733516e8a3374db7efd200a120d23 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 15:49:48 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20TaskTree=20?= =?UTF-8?q?=E7=88=B6=E7=BA=A7=E6=97=B6=E9=97=B4=E5=8C=85=E7=BB=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/utils/tasktree-time-range.test.ts | 39 +++++++++++++++ .../src/utils/tasktree-time-range.ts | 47 +++++++++++++++++++ .../src/views/projects/TaskTreeView.vue | 23 ++++++--- 3 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 web/hwlab-cloud-web/src/utils/tasktree-time-range.test.ts create mode 100644 web/hwlab-cloud-web/src/utils/tasktree-time-range.ts diff --git a/web/hwlab-cloud-web/src/utils/tasktree-time-range.test.ts b/web/hwlab-cloud-web/src/utils/tasktree-time-range.test.ts new file mode 100644 index 00000000..b82a4db8 --- /dev/null +++ b/web/hwlab-cloud-web/src/utils/tasktree-time-range.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; +import { deriveEffectiveTaskTimeRanges, type TaskTreeTimedNode } from "./tasktree-time-range"; + +function node(id: string, parentId: string | null, startAt: string | null, dueAt: string | null): TaskTreeTimedNode { + return { id, parentId, startAt, dueAt }; +} + +test("父级范围从最早子节点开始并保留自身更晚的截止时间", () => { + const ranges = deriveEffectiveTaskTimeRanges([ + node("R4", null, "2026-07-19T00:00:00.000Z", "2026-07-21T00:00:00.000Z"), + node("R4.1", "R4", "2026-07-18T20:00:00.000Z", "2026-07-18T21:00:00.000Z"), + ]); + assert.deepEqual(ranges.get("R4"), { + startAt: "2026-07-18T20:00:00.000Z", + dueAt: "2026-07-21T00:00:00.000Z", + }); +}); + +test("父级范围包含多层后代节点", () => { + const ranges = deriveEffectiveTaskTimeRanges([ + node("R1", null, "2026-07-20T00:00:00.000Z", "2026-07-20T01:00:00.000Z"), + node("R1.1", "R1", null, null), + node("R1.1.1", "R1.1", "2026-07-17T00:00:00.000Z", "2026-07-22T00:00:00.000Z"), + ]); + assert.deepEqual(ranges.get("R1"), { + startAt: "2026-07-17T00:00:00.000Z", + dueAt: "2026-07-22T00:00:00.000Z", + }); + assert.deepEqual(ranges.get("R1.1"), ranges.get("R1.1.1")); +}); + +test("无时间信息的分支保持无时间范围", () => { + const ranges = deriveEffectiveTaskTimeRanges([ + node("R2", null, null, null), + node("R2.1", "R2", null, null), + ]); + assert.deepEqual(ranges.get("R2"), { startAt: null, dueAt: null }); +}); diff --git a/web/hwlab-cloud-web/src/utils/tasktree-time-range.ts b/web/hwlab-cloud-web/src/utils/tasktree-time-range.ts new file mode 100644 index 00000000..e1e86913 --- /dev/null +++ b/web/hwlab-cloud-web/src/utils/tasktree-time-range.ts @@ -0,0 +1,47 @@ +// SPEC: PJ2026-010404 project management draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source. +// 职责:根据节点自身及全部后代推导只用于展示的 TaskTree 父级时间范围。 + +export type TaskTreeTimedNode = { + id: string; + parentId: string | null; + startAt: string | null; + dueAt: string | null; +}; + +export type TaskTreeTimeRange = Pick; + +export function deriveEffectiveTaskTimeRanges(tasks: TaskTreeTimedNode[]): Map { + const tasksById = new Map(tasks.map((task) => [task.id, task])); + const childrenByParent = new Map(); + for (const task of tasks) { + if (!task.parentId || !tasksById.has(task.parentId)) continue; + childrenByParent.set(task.parentId, [...(childrenByParent.get(task.parentId) ?? []), task]); + } + + const ranges = new Map(); + const resolving = new Set(); + const resolve = (task: TaskTreeTimedNode): TaskTreeTimeRange => { + const cached = ranges.get(task.id); + if (cached) return cached; + if (resolving.has(task.id)) return { startAt: task.startAt, dueAt: task.dueAt }; + resolving.add(task.id); + const values = [task.startAt, task.dueAt]; + for (const child of childrenByParent.get(task.id) ?? []) { + const childRange = resolve(child); + values.push(childRange.startAt, childRange.dueAt); + } + const timestamps = values + .filter((value): value is string => Boolean(value)) + .map((value) => new Date(value).getTime()) + .filter(Number.isFinite); + const range = timestamps.length > 0 + ? { startAt: new Date(Math.min(...timestamps)).toISOString(), dueAt: new Date(Math.max(...timestamps)).toISOString() } + : { startAt: null, dueAt: null }; + resolving.delete(task.id); + ranges.set(task.id, range); + return range; + }; + + for (const task of tasks) resolve(task); + return ranges; +} diff --git a/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue b/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue index 3c113b16..e2cc0d5e 100644 --- a/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue +++ b/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue @@ -8,6 +8,7 @@ import BaseDialog from "@/components/common/BaseDialog.vue"; import TaskTreeDetailPanel from "@/components/tasktree/TaskTreeDetailPanel.vue"; import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue"; import { displayTimeConfig } from "@/config/runtime"; +import { deriveEffectiveTaskTimeRanges } from "@/utils/tasktree-time-range"; const route = useRoute(); const router = useRouter(); @@ -58,6 +59,7 @@ const childrenByParent = computed(() => { return children; }); const tasksById = computed(() => new Map((timeline.value?.tasks ?? []).map((task) => [task.id, task]))); +const effectiveTaskRanges = computed(() => deriveEffectiveTaskTimeRanges(timeline.value?.tasks ?? [])); const rootTasks = computed(() => (timeline.value?.tasks ?? []).filter((task) => !task.parentId)); const timelineScales = [ { label: "周", stepMs: 2 * 86400000, tickWidth: 96, precision: "date" }, @@ -163,7 +165,7 @@ function hierarchyAncestors(task: TaskTreeTask) { while (parentId) { const parent = tasksById.value.get(parentId); if (!parent) break; - if (parent.startAt || parent.dueAt) result.unshift(parent); + if (hasTaskRange(parent)) result.unshift(parent); parentId = parent.parentId; } return result; @@ -335,7 +337,14 @@ function barStyle(item: { startAt: string | null; dueAt: string | null }) { const rangeStart = displaySerial(range.value.start); const left = Math.max(0, (start - rangeStart) / 86400000) * unit; const rightEdge = Math.min(100, Math.max(left, (end - rangeStart) / 86400000 * unit)); - return { right: `${100 - rightEdge}%`, width: `${Math.max(0, rightEdge - left)}%` }; + return { left: `${left}%`, width: `${Math.max(0, rightEdge - left)}%` }; +} +function effectiveTaskRange(task: TaskTreeTask) { + return effectiveTaskRanges.value.get(task.id) ?? task; +} +function hasTaskRange(task: TaskTreeTask) { + const taskRange = effectiveTaskRange(task); + return Boolean(taskRange.startAt || taskRange.dueAt); } function overviewItemTotal(item: TaskTreeGroupOverview) { return item.taskCount + item.subtaskCount + item.subsubtaskCount; @@ -556,8 +565,8 @@ function syncSelectedTaskFromRoute() {
-