merge: 改进 TaskTree 分组完成度视图

This commit is contained in:
root
2026-07-21 15:25:03 +02:00
5 changed files with 43 additions and 9 deletions
+1
View File
@@ -54,6 +54,7 @@ export type TaskGroupOverview = {
taskCount: number;
subtaskCount: number;
subsubtaskCount: number;
completedCount: number;
reportCount: number;
startAt: string | null;
dueAt: string | null;
+2 -1
View File
@@ -145,6 +145,7 @@ export class TaskTreeStore {
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 t.id) FILTER (WHERE t.status='completed')::int AS completed_count,
COUNT(DISTINCT r.id)::int AS report_count,
MIN(CASE
WHEN t.id IS NULL THEN NULL
@@ -696,7 +697,7 @@ async function schemaIsCurrent(pool: pg.Pool): Promise<boolean> {
}
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 overviewRow(row: any): TaskGroupOverview { return { group: groupRow(row), taskCount: Number(row.task_count), subtaskCount: Number(row.subtask_count), subsubtaskCount: Number(row.subsubtask_count), completedCount: Number(row.completed_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) }; }
+1
View File
@@ -217,6 +217,7 @@ test("native PostgreSQL store exposes overview and three task levels", async ()
assert.equal(overview?.taskCount, 1);
assert.equal(overview?.subtaskCount, 3);
assert.equal(overview?.subsubtaskCount, 1);
assert.equal(overview?.completedCount, 1);
assert.equal(overview?.reportCount, 1);
assert.equal(overview?.startAt, new Date(timelineStart).toISOString());
assert.equal(overview?.dueAt, new Date(timelineDue).toISOString());
+1 -1
View File
@@ -6,7 +6,7 @@ export type TaskTreeTask = { id: string; groupId: string; parentId: string | nul
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 };
export type TaskTreeGroupOverview = { group: TaskTreeGroup; taskCount: number; subtaskCount: number; subsubtaskCount: number; completedCount: number; reportCount: number; startAt: string | null; dueAt: string | null };
type Result<T> = { ok?: boolean; data?: T; error?: { code?: string; message?: string } };
export const tasktreeAPI = {
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ChevronDown, ChevronRight, RefreshCw, ZoomIn, ZoomOut } from "lucide-vue-next";
import { ArrowLeft, ChevronDown, ChevronRight, RefreshCw, ZoomIn, ZoomOut } from "lucide-vue-next";
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";
@@ -337,6 +337,21 @@ function barStyle(item: { startAt: string | null; dueAt: string | null }) {
const rightEdge = Math.min(100, Math.max(left, (end - rangeStart) / 86400000 * unit));
return { right: `${100 - rightEdge}%`, width: `${Math.max(0, rightEdge - left)}%` };
}
function overviewItemTotal(item: TaskTreeGroupOverview) {
return item.taskCount + item.subtaskCount + item.subsubtaskCount;
}
function overviewCompletion(item: TaskTreeGroupOverview) {
const total = overviewItemTotal(item);
return total > 0 ? Math.round(item.completedCount * 100 / total) : 0;
}
function groupBarStyle(item: TaskTreeGroupOverview) {
return { ...barStyle(item), "--completion": `${overviewCompletion(item)}%` };
}
function groupBarClass(item: TaskTreeGroupOverview) {
const start = displaySerial(new Date(item.startAt || item.dueAt || range.value.start));
const rangeStart = displaySerial(range.value.start);
return { "label-after": (start - rangeStart) / 86400000 / range.value.dayCount < 0.12 };
}
function trackStyle() {
return {
backgroundImage: `${weekendWash.value}, linear-gradient(to right, #edf1f0 1px, transparent 1px)`,
@@ -420,6 +435,7 @@ function syncSelectedTaskFromRoute() {
<div class="tasktree-context-copy">
<div class="tasktree-product-title"><span>项目时间线</span><strong>TaskTree</strong></div>
<span class="tasktree-context-separator" aria-hidden="true" />
<RouterLink v-if="!isGlobalView" class="icon-button tasktree-back-link" to="/projects/tasktree" title="返回全部 TaskGroups" aria-label="返回全部 TaskGroups"><ArrowLeft :size="16" aria-hidden="true" /></RouterLink>
<div v-if="isGlobalView" class="tasktree-current-group"><strong>全局 TaskGroups</strong><p>跨项目时间范围与工作量概览</p></div>
<div v-else-if="timeline" class="tasktree-current-group"><strong>{{ timeline.group.name }}</strong><p>{{ timeline.group.description || '未填写 taskgroup 说明' }}</p></div>
</div>
@@ -525,12 +541,15 @@ function syncSelectedTaskFromRoute() {
<button
v-if="item.startAt || item.dueAt"
class="tasktree-group-bar"
:style="barStyle(item)"
:class="groupBarClass(item)"
:style="groupBarStyle(item)"
type="button"
:aria-label="`打开 ${item.group.name}`"
:aria-label="`打开 ${item.group.name}已完成 ${item.completedCount} / ${overviewItemTotal(item)}完成度 ${overviewCompletion(item)}%`"
@click="openGroup(item.group.id)"
>
<span>{{ item.taskCount + item.subtaskCount + item.subsubtaskCount }} </span>
<span class="tasktree-group-progress" aria-hidden="true" />
<span class="tasktree-group-progress-label inside"><strong>{{ item.completedCount }} / {{ overviewItemTotal(item) }}</strong><small>{{ overviewCompletion(item) }}%</small></span>
<span class="tasktree-group-progress-label outside" aria-hidden="true">{{ item.completedCount }} / {{ overviewItemTotal(item) }}</span>
</button>
</div>
</div>
@@ -605,6 +624,8 @@ function syncSelectedTaskFromRoute() {
.tasktree-current-group strong { flex: 0 1 auto; overflow: hidden; color: var(--text-primary); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.tasktree-current-group p { min-width: 0; margin: 0; overflow: hidden; color: var(--text-secondary); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.tasktree-context-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 7px; }
.tasktree-back-link { width: 30px; height: 30px; min-height: 30px; flex: 0 0 30px; color: var(--text-secondary); }
.tasktree-back-link:hover { color: var(--text-primary); }
.tasktree-view-layout.docked .tasktree-current-group { max-width: 190px; }
.tasktree-view-layout.docked .tasktree-current-group p { display: none; }
.tasktree-view-layout.docked .tasktree-current-group strong { max-width: 190px; }
@@ -690,9 +711,19 @@ function syncSelectedTaskFromRoute() {
.tasktree-bar.is-summary::after { position: absolute; top: 0; width: 2px; height: 8px; border-radius: 0 0 1px 1px; background: inherit; content: ""; }
.tasktree-bar.is-summary::before { left: 0; }
.tasktree-bar.is-summary::after { right: 0; }
.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-group-bar { container-type: inline-size; position: absolute; top: 16px; height: 36px; min-width: 28px; overflow: visible; border: 1px solid #4e6674; border-radius: 4px; background: #667d8b; color: #fff; box-shadow: inset 0 1px 0 rgb(255 255 255 / 28%), 0 2px 5px rgb(28 48 58 / 16%); cursor: pointer; isolation: isolate; }
.tasktree-group-bar:hover { border-color: #4f6976; box-shadow: inset 0 1px 0 rgb(255 255 255 / 68%), 0 3px 8px rgb(28 48 58 / 22%); }
.tasktree-group-progress { position: absolute; inset: 0 auto 0 0; z-index: -1; width: var(--completion); overflow: hidden; border-radius: 3px 0 0 3px; background: linear-gradient(90deg, #276d50, #4c9470); transition: width 180ms ease; }
.tasktree-group-progress-label { position: relative; z-index: 1; align-items: baseline; white-space: nowrap; }
.tasktree-group-progress-label.inside { display: flex; height: 34px; justify-content: center; gap: 7px; padding: 0 10px; line-height: 34px; text-shadow: 0 1px 1px rgb(20 42 31 / 55%); }
.tasktree-group-progress-label.inside strong { font-size: 11px; font-weight: 750; }
.tasktree-group-progress-label.inside small { font-family: var(--console-font-mono); font-size: 9px; opacity: 0.82; }
.tasktree-group-progress-label.outside { position: absolute; top: 50%; right: calc(100% + 8px); display: none; padding: 2px 4px; transform: translateY(-50%); background: rgb(255 255 255 / 88%); color: #41545d; font-family: var(--console-font-mono); font-size: 10px; font-weight: 700; line-height: 16px; }
.tasktree-group-bar.label-after .tasktree-group-progress-label.outside { right: auto; left: calc(100% + 8px); }
@container (max-width: 96px) {
.tasktree-group-progress-label.inside { display: none; }
.tasktree-group-progress-label.outside { display: block; }
}
.tasktree-bar.status-completed { border-color: rgb(29 111 76 / 62%); background: linear-gradient(90deg, #277d5a, #4a9a72); }
.tasktree-bar.status-blocked { border-color: rgb(144 43 50 / 62%); background: linear-gradient(90deg, #a83c45, #c55a60); }
.tasktree-bar.status-pending { border-color: rgb(79 92 102 / 58%); background: linear-gradient(90deg, #63717b, #81909a); }