184 lines
13 KiB
Vue
184 lines
13 KiB
Vue
<script setup lang="ts">
|
|
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;
|
|
tasks: TaskTreeTask[];
|
|
reports: TaskTreeReport[];
|
|
mode: "dialog" | "docked";
|
|
}>();
|
|
const emit = defineEmits<{
|
|
select: [task: TaskTreeTask];
|
|
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);
|
|
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 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() || "未填写任务说明。";
|
|
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 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);
|
|
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(); }
|
|
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>
|
|
<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'" ref="detailTreeElement" class="tasktree-detail-tree" aria-label="局部任务层级">
|
|
<h3>局部任务</h3>
|
|
<ol class="tasktree-local-tree">
|
|
<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>
|
|
<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>{{ 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>
|
|
</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>
|