feat(tasktree): 完善导入与甘特详情体验

This commit is contained in:
root
2026-07-17 09:26:49 +02:00
parent 1aaf2044f8
commit 3130e2b9db
12 changed files with 720 additions and 169 deletions
+1 -1
View File
@@ -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";
+4 -2
View File
@@ -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<T> = { ok?: boolean; data?: T; error?: { code?: string; message?: string } };
export const tasktreeAPI = {
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" })
};
@@ -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 { 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<TaskTreeGroup[]>([]);
const overview = ref<TaskTreeGroupOverview[]>([]);
const timeline = ref<TaskTreeTimeline | 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 timelineHeaderElement = ref<HTMLElement | null>(null);
const taskLabelsElement = 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 taskColumnWidth = ref(typeof window !== "undefined" ? Math.round(window.innerWidth * (window.innerWidth <= 720 ? 0.55 : 0.21)) : 400);
const loading = ref(false);
const error = ref<string | null>(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<string, TaskTreeTask[]>();
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<TaskTreeReport[]>(() => 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<string, string>)[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(); }
</script>
<template>
<main class="tasktree-page" data-testid="tasktree-page">
<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>
<AsyncBoundary :state="asyncState" title="TaskTree 暂无任务" :message="error || '通过 CLI 创建 taskgroup 和任务后会显示在时间轴中。'" @retry="loadGroups">
<section v-if="timeline" class="tasktree-workspace" aria-label="TaskTree 甘特图">
<AsyncBoundary :state="asyncState" title="TaskTree 暂无任务" :message="error || '通过 CLI 创建 taskgroup 和任务后会显示在时间轴中。'" @retry="loadActiveView">
<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">
<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">
<span>{{ range.dayCount }} </span>
<div class="tasktree-zoom" role="group" aria-label="时间轴缩放">
@@ -167,122 +325,232 @@ function normalizeHeading(value: string) { return value.replace(/\s+/gu, "").toL
</div>
</div>
</header>
<div class="tasktree-gantt" :style="{ '--tick-count': timelineTicks.length, '--tick-width': `${timelineScale.tickWidth}px`, '--timeline-width': `${timelineWidth}px` }">
<div class="tasktree-corner">任务</div>
<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">{{ 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 class="tasktree-days">
<span v-for="tick in timelineTicks" :key="tick.toISOString()">{{ tickLabel(tick) }}</span>
</div>
</div>
<div ref="taskLabelsElement" class="tasktree-labels" @scroll="syncTaskLabels">
<div class="tasktree-labels-canvas">
<div v-for="task in visibleTasks" :key="task.id" class="tasktree-label" :class="{ subtask: task.kind === 'subtask' }" :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" />
<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 v-if="isGlobalView" class="tasktree-labels-canvas tasktree-overview-labels">
<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)">
<div class="tasktree-group-title">
<MessageMarkdown class="tasktree-title-markdown" :source="item.group.name" />
<ChevronRight :size="17" aria-hidden="true" />
</div>
<span>{{ item.taskCount }} tasks · {{ item.subtaskCount + item.subsubtaskCount }} children · {{ item.reportCount }} reports</span>
</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 ref="timelineBodyElement" class="tasktree-timeline-body" @scroll="syncTimelineBody">
<div class="tasktree-timeline-canvas">
<div v-for="task in visibleTasks" :key="task.id" class="tasktree-track" :style="{ backgroundSize: `${100 / timelineTicks.length}% 100%` }">
<div v-if="isGlobalView" class="tasktree-timeline-canvas tasktree-overview-timeline">
<div v-for="item in overview" :key="item.group.id" class="tasktree-track tasktree-group-track" :style="{ backgroundSize: `${100 / timelineTicks.length}% 100%` }">
<button
v-if="task.startAt || task.dueAt"
class="tasktree-bar"
:class="[`status-${task.status}`, { subtask: task.kind === 'subtask' }]"
:style="barStyle(task)"
v-if="item.startAt || item.dueAt"
class="tasktree-group-bar"
:style="barStyle(item)"
type="button"
:aria-label="`${task.title}${statusLabel(task.status)}`"
@click="selectedTask = task"
:aria-label="`打开 ${item.group.name}`"
@click="openGroup(item.group.id)"
>
<span>{{ statusLabel(task.status) }}</span>
<span>{{ item.taskCount + item.subtaskCount + item.subsubtaskCount }} </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>
<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>
<footer class="tasktree-statusbar" aria-label="TaskTree 状态">
<span><strong>{{ groups.length }}</strong> Taskgroups</span>
<span><strong>{{ 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>{{ timeline.reports.length }}</strong> Reports</span>
<span><strong>{{ timeline.milestones.length }}</strong> Milestones</span>
<span><strong>{{ overview.length }}</strong> Taskgroups</span>
<span><strong>{{ isGlobalView ? overviewTotals.tasks : timeline?.tasks.filter((task) => task.kind === 'task').length }}</strong> Tasks</span>
<span><strong>{{ isGlobalView ? overviewTotals.subtasks : timeline?.tasks.filter((task) => task.kind === 'subtask').length }}</strong> Subtasks</span>
<span><strong>{{ isGlobalView ? overviewTotals.subsubtasks : timeline?.tasks.filter((task) => task.kind === 'subsubtask').length }}</strong> Subsubtasks</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>
</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>
<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">
<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>
<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">
<TaskTreeDetailPanel v-if="selectedTask && timeline" :task="selectedTask" :tasks="timeline.tasks" :reports="timeline.reports" mode="dialog" @select="selectTask" @close="closeTask" @mode="setDetailMode" />
</BaseDialog>
</main>
</template>
<style scoped>
.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-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 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-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: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-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-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-labels::-webkit-scrollbar { display: none; }
.tasktree-days { width: var(--timeline-width); height: 100%; display: grid; grid-template-columns: repeat(var(--tick-count), var(--tick-width)); }
.tasktree-timeline-header::-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 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-canvas { min-height: 100%; display: grid; grid-auto-rows: 46px; align-content: start; }
.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%; 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:hover { background: rgb(255 255 255 / 32%); }
.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-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: 8px; min-width: 0; height: 100%; padding: 0 4px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }
.tasktree-title span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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: 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; }
.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-canvas { width: var(--timeline-width); min-height: 100%; display: grid; grid-auto-rows: 46px; align-content: start; }
.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: 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-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 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.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-blocked { background: #b84149; }
.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-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 span { display: inline-flex; align-items: center; gap: 4px; }
.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-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-detail { display: grid; gap: 18px; }
.tasktree-detail dl { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 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); }
.tasktree-detail dd { margin: 4px 0 0; }
.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; }
:global(.console-overlay-surface.tasktree-detail-dialog) { height: 90dvh; min-height: 90dvh; max-height: 90dvh; }
:global(.console-overlay-surface.tasktree-detail-dialog .console-overlay-body) { min-height: 0; overflow: hidden; padding: 0; }
@media (max-width: 1100px) {
.tasktree-view-layout.docked { grid-template-columns: minmax(0, 1fr) minmax(320px, 42vw); }
}
@media (max-width: 720px) {
.tasktree-view-layout.docked { grid-template-columns: minmax(0, 1fr); }
.tasktree-workspace { height: calc(100dvh - 225px); min-height: 400px; }
.tasktree-docked-detail { height: 80dvh; min-height: 480px; }
.tasktree-context { align-items: flex-start; }
.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-detail dl { grid-template-columns: 1fr; }
.tasktree-group-select select { min-width: 130px; max-width: 42vw; }
}
</style>