fix(tasktree): 恢复父子层级甘特视觉

This commit is contained in:
root
2026-07-21 09:47:47 +02:00
parent 9f876ae42a
commit ed3b88b886
2 changed files with 273 additions and 89 deletions
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, nextTick, onMounted, ref, watch } 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";
import { displayTimeLabel, formatDisplayDateTime } from "@/config/runtime";
const props = defineProps<{
task: TaskTreeTask;
@@ -15,6 +16,7 @@ const emit = defineEmits<{
close: [];
mode: [mode: "dialog" | "docked"];
}>();
const detailTreeElement = ref<HTMLElement | null>(null);
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);
@@ -22,6 +24,24 @@ const nextTask = computed(() => taskIndex.value >= 0 ? props.tasks[taskIndex.val
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 localTreeItems = computed(() => {
const items: Array<{
task: TaskTreeTask;
relation: "parent" | "current" | "sibling" | "child";
level: number;
}> = [];
if (parent.value) items.push({ task: parent.value, relation: "parent", level: 0 });
for (const sibling of siblings.value) {
const current = sibling.id === props.task.id;
items.push({ task: sibling, relation: current ? "current" : "sibling", level: parent.value ? 1 : 0 });
if (current) {
for (const child of children.value) {
items.push({ task: child, relation: "child", level: parent.value ? 2 : 1 });
}
}
}
return items;
});
const taskReports = computed(() => props.reports.filter((report) => report.taskId === props.task.id));
const combinedMarkdown = computed(() => {
const description = props.task.description.trim() || "未填写任务说明。";
@@ -42,7 +62,10 @@ 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 relationLabel(relation: "parent" | "current" | "sibling" | "child") {
return ({ parent: "父节点", current: "当前任务", sibling: "兄弟任务", child: "子节点" })[relation];
}
function dateLabel(value: string | null) { return value ? formatDisplayDateTime(value) : "未设置"; }
function reportMarkdown(report: TaskTreeReport) {
const lines = report.body.replace(/\r\n?/gu, "\n").split("\n");
const firstContent = lines.findIndex((line) => line.trim().length > 0);
@@ -53,6 +76,19 @@ function reportMarkdown(report: TaskTreeReport) {
return lines.join("\n").trimStart();
}
function normalizeHeading(value: string) { return value.replace(/\s+/gu, "").toLocaleLowerCase(); }
async function revealCurrentTask() {
await nextTick();
const tree = detailTreeElement.value;
const row = tree?.querySelector<HTMLElement>("[data-current-task='true']");
if (!tree || !row) return;
const treeRect = tree.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const target = tree.scrollTop + rowRect.top - treeRect.top - tree.clientHeight * 0.18;
tree.scrollTop = Math.max(0, Math.min(tree.scrollHeight - tree.clientHeight, target));
}
onMounted(revealCurrentTask);
watch(() => props.task.id, revealCurrentTask);
</script>
<template>
@@ -63,18 +99,17 @@ function normalizeHeading(value: string) { return value.replace(/\s+/gu, "").toL
<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="局部任务层级">
<aside v-if="mode === 'dialog'" ref="detailTreeElement" 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
v-for="item in localTreeItems"
:key="item.task.id"
:class="[`level-${item.level}`, `relation-${item.relation}`, { current: item.relation === 'current' }]"
:data-current-task="item.relation === 'current' ? 'true' : undefined"
>
<button v-if="item.relation !== 'current'" type="button" @click="emit('select', item.task)"><small>{{ relationLabel(item.relation) }} · {{ kindLabel(item.task.kind) }}</small><strong>{{ plainTitle(item.task) }}</strong></button>
<button v-else class="current" type="button" aria-current="true"><small>{{ relationLabel(item.relation) }} · {{ kindLabel(item.task.kind) }}</small><strong>{{ plainTitle(item.task) }}</strong></button>
</li>
</ol>
</aside>
@@ -90,7 +125,7 @@ function normalizeHeading(value: string) { return value.replace(/\s+/gu, "").toL
<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>
<span>开始 <strong>{{ dateLabel(task.startAt) }}</strong></span><span>截止 <strong>{{ dateLabel(task.dueAt) }}</strong></span><span>时区 <strong>{{ displayTimeLabel() }}</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>