From 48db528a26467405c2fbf8448b63a32538d43199 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 13:38:09 +0200 Subject: [PATCH 1/2] fix(tasktree): revert regressed outline layout --- .../tasktree/TaskTreeDetailPanel.vue | 2 +- .../src/views/projects/TaskTreeView.vue | 21 +++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/web/hwlab-cloud-web/src/components/tasktree/TaskTreeDetailPanel.vue b/web/hwlab-cloud-web/src/components/tasktree/TaskTreeDetailPanel.vue index be4b6ab9..e9c2addd 100644 --- a/web/hwlab-cloud-web/src/components/tasktree/TaskTreeDetailPanel.vue +++ b/web/hwlab-cloud-web/src/components/tasktree/TaskTreeDetailPanel.vue @@ -56,7 +56,7 @@ const combinedMarkdown = computed(() => { }); function plainTitle(task: TaskTreeTask) { - return task.title.replace(/^R\d+(?:\.\d+){0,2}\s+/u, "").replace(/\[([^\]]+)\]\([^)]+\)/gu, "$1").replace(/`([^`]+)`/gu, "$1").trim(); + return task.title.replace(/\[([^\]]+)\]\([^)]+\)/gu, "$1").replace(/`([^`]+)`/gu, "$1").trim(); } function plainReportTitle(report: TaskTreeReport) { return report.title.replace(/\[([^\]]+)\]\([^)]+\)/gu, "$1").replace(/`([^`]+)`/gu, "$1").trim(); diff --git a/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue b/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue index c7022218..061037a3 100644 --- a/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue +++ b/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue @@ -357,7 +357,6 @@ function tickClass(date: Date) { }; } function statusLabel(status: string) { return ({ pending: "待处理", in_progress: "进行中", completed: "已完成", blocked: "阻塞" } as Record)[status] || status; } -function displayTaskTitle(task: TaskTreeTask) { return task.title.replace(/^R\d+(?:\.\d+){0,2}\s+/u, ""); } 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(); @@ -449,7 +448,6 @@ function syncSelectedTaskFromRoute() {
- {{ root.outlineRef }}
- {{ task.outlineRef }}
@@ -617,12 +616,12 @@ function syncSelectedTaskFromRoute() { .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 #edf0ef; background: #fff; padding: 0 8px; display: grid; grid-template-columns: 9ch 24px minmax(0, 1fr); align-items: center; color: var(--text-primary); min-width: 0; } +.tasktree-label { height: 46px; border-bottom: 1px solid #edf0ef; background: #fff; padding: 0 8px; display: flex; align-items: center; color: var(--text-primary); min-width: 0; } .tasktree-task-group > .tasktree-label { background: #f7f9f8; font-weight: 600; } .tasktree-label:hover { background: #f2f7f5; } -.tasktree-outline-ref { min-width: 0; padding-right: 7px; overflow: hidden; color: #64767b; font-family: var(--console-font-mono); font-size: 10px; font-weight: 700; line-height: 18px; text-align: right; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; } -.tasktree-label.subtask, -.tasktree-label.subsubtask { color: var(--text-secondary); } +.tasktree-outline-ref { flex: 0 0 auto; color: #587078; font-family: var(--console-font-mono); font-size: 11px; font-weight: 700; } +.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; } @@ -631,13 +630,13 @@ function syncSelectedTaskFromRoute() { .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:hover { color: var(--text-primary); background: var(--surface-secondary); } -.tasktree-title { display: flex; align-items: center; gap: 7px; min-width: 0; width: 100%; height: 100%; padding: 0 4px 0 3px; 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: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: 6px; height: 6px; 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="in_progress"] { background: #2376c9; } .tasktree-status[data-status="blocked"] { background: #c73e48; } From 3217b99e50020be1d99c11fad51a61b6493421e0 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 13:38:09 +0200 Subject: [PATCH 2/2] fix(tasktree): align active timeline presentation --- internal/tasktree/store.ts | 31 ++++++++++++--- .../src/views/projects/TaskTreeView.vue | 38 ++++++++++++++----- 2 files changed, 54 insertions(+), 15 deletions(-) diff --git a/internal/tasktree/store.ts b/internal/tasktree/store.ts index 88ae8380..dd0e120a 100644 --- a/internal/tasktree/store.ts +++ b/internal/tasktree/store.ts @@ -154,7 +154,12 @@ export class TaskTreeStore { 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 + ORDER BY GREATEST( + g.updated_at, + COALESCE(MAX(t.updated_at),g.updated_at), + COALESCE(MAX(r.created_at),g.updated_at), + COALESCE((SELECT MAX(m.created_at) FROM tasktree_milestones m WHERE m.group_id=g.id),g.updated_at) + ) DESC, g.name `); return result.rows.map(overviewRow); } @@ -338,11 +343,25 @@ export class TaskTreeStore { async createReport(input: { taskId: string; title: string; body: string; status?: string }): Promise { await this.ensureSchema(); - const result = await this.pool.query( - "INSERT INTO tasktree_execution_reports (id,task_id,title,body,status) VALUES ($1,$2,$3,$4,$5) RETURNING *", - [`tr_${randomUUID()}`, input.taskId, input.title, input.body, input.status ?? "succeeded"] - ); - return reportRow(result.rows[0]); + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const result = await client.query( + "INSERT INTO tasktree_execution_reports (id,task_id,title,body,status) VALUES ($1,$2,$3,$4,$5) RETURNING *", + [`tr_${randomUUID()}`, input.taskId, input.title, input.body, input.status ?? "succeeded"] + ); + await client.query( + "UPDATE tasktree_groups g SET updated_at=now() FROM tasktree_tasks t WHERE t.id=$1 AND g.id=t.group_id", + [input.taskId] + ); + await client.query("COMMIT"); + return reportRow(result.rows[0]); + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + throw error; + } finally { + client.release(); + } } async listReports(taskId: string): Promise { diff --git a/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue b/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue index 061037a3..50b6a46e 100644 --- a/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue +++ b/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue @@ -20,6 +20,7 @@ const collapsedTaskIds = ref(new Set()); const timelineHeaderElement = ref(null); const taskLabelsElement = ref(null); const timelineBodyElement = ref(null); +const timelineScrollbarWidth = ref(0); const labelChildScrollers = new Map(); const timelineChildScrollers = new Map(); const initializedChildGroups = new Set(); @@ -28,6 +29,7 @@ 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); +let positionedView = ""; const groupId = computed(() => String(route.params.groupId || "")); const displayTime = displayTimeConfig(); const displayPartsFormatter = new Intl.DateTimeFormat(displayTime.locale, { @@ -113,6 +115,8 @@ async function loadActiveView() { if (!groupId.value) { timeline.value = null; loading.value = false; + await nextTick(); + positionTimelineAtNow("global"); return; } const timelineResponse = await tasktreeAPI.timeline(groupId.value); @@ -124,6 +128,8 @@ async function loadActiveView() { timeline.value = timelineResponse.data.data; initializedChildGroups.clear(); syncSelectedTaskFromRoute(); + await nextTick(); + positionTimelineAtNow(groupId.value); } function selectGroup(event: Event) { const value = (event.target as HTMLSelectElement).value; @@ -282,13 +288,18 @@ async function changeTimelineScale(direction: -1 | 1) { function syncTimelineBody() { const body = timelineBodyElement.value; if (!body) return; + timelineScrollbarWidth.value = Math.max(0, body.offsetWidth - body.clientWidth); if (timelineHeaderElement.value && timelineHeaderElement.value.scrollLeft !== body.scrollLeft) timelineHeaderElement.value.scrollLeft = body.scrollLeft; if (taskLabelsElement.value && taskLabelsElement.value.scrollTop !== body.scrollTop) taskLabelsElement.value.scrollTop = body.scrollTop; } -function syncTimelineHeader() { - const header = timelineHeaderElement.value; +function positionTimelineAtNow(view: string) { const body = timelineBodyElement.value; - if (header && body && body.scrollLeft !== header.scrollLeft) body.scrollLeft = header.scrollLeft; + if (!body || positionedView === view) return; + timelineScrollbarWidth.value = Math.max(0, body.offsetWidth - body.clientWidth); + const now = todayOffset.value; + body.scrollLeft = now === null ? 0 : Math.max(0, Math.min(body.scrollWidth - body.clientWidth, body.scrollWidth * now / 100 - body.clientWidth * 0.8)); + if (timelineHeaderElement.value) timelineHeaderElement.value.scrollLeft = body.scrollLeft; + positionedView = view; } function syncTaskLabels() { const labels = taskLabelsElement.value; @@ -321,8 +332,10 @@ function displaySerial(date: Date) { const parts = displayParts(date); return Da function displayDaySerial(date: Date) { const parts = displayParts(date); return Date.UTC(parts.year, parts.month - 1, parts.day); } function barStyle(item: { startAt: string | null; dueAt: string | null }) { const start = displaySerial(new Date(item.startAt || item.dueAt || range.value.start)); const end = displaySerial(new Date(item.dueAt || item.startAt || range.value.start)); const unit = 100 / range.value.dayCount; - const left = Math.max(0, (start - displaySerial(range.value.start)) / 86400000) * unit; const width = Math.max(unit * 0.65, ((end - start) / 86400000) * unit); - return { left: `${left}%`, width: `${Math.min(100 - left, width)}%` }; + 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)}%` }; } function trackStyle() { return { @@ -393,7 +406,14 @@ function syncSelectedTaskFromRoute() {