149 lines
12 KiB
Vue
149 lines
12 KiB
Vue
<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>
|