fix(tasktree): 恢复父子层级甘特视觉
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ChevronDown, ChevronRight, ZoomIn, ZoomOut } from "lucide-vue-next";
|
||||
import { ChevronDown, ChevronRight, RefreshCw, ZoomIn, ZoomOut } from "lucide-vue-next";
|
||||
import { tasktreeAPI, type TaskTreeGroup, type TaskTreeGroupOverview, type TaskTreeTask, type TaskTreeTimeline } from "@/api/tasktree";
|
||||
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
|
||||
import BaseDialog from "@/components/common/BaseDialog.vue";
|
||||
import TaskTreeDetailPanel from "@/components/tasktree/TaskTreeDetailPanel.vue";
|
||||
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
|
||||
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
||||
import { displayTimeConfig } from "@/config/runtime";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -22,13 +22,25 @@ 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 initializedChildGroups = 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 || ""));
|
||||
const displayTime = displayTimeConfig();
|
||||
const displayPartsFormatter = new Intl.DateTimeFormat(displayTime.locale, {
|
||||
timeZone: displayTime.timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23"
|
||||
});
|
||||
const taskRowHeight = 46;
|
||||
const requestedTaskId = computed(() => String(route.query.task || ""));
|
||||
const requestedDetailMode = computed<"dialog" | "docked">(() => route.query.detail === "docked" ? "docked" : "dialog");
|
||||
const isGlobalView = computed(() => !groupId.value);
|
||||
@@ -43,6 +55,7 @@ const childrenByParent = computed(() => {
|
||||
}
|
||||
return children;
|
||||
});
|
||||
const tasksById = computed(() => new Map((timeline.value?.tasks ?? []).map((task) => [task.id, task])));
|
||||
const rootTasks = computed(() => (timeline.value?.tasks ?? []).filter((task) => !task.parentId));
|
||||
const timelineScales = [
|
||||
{ label: "周", stepMs: 2 * 86400000, tickWidth: 96, precision: "date" },
|
||||
@@ -58,6 +71,21 @@ const timelineTicks = computed(() => Array.from(
|
||||
(_, index) => new Date(range.value.start.getTime() + index * timelineScale.value.stepMs)
|
||||
));
|
||||
const timelineWidth = computed(() => timelineTicks.value.length * timelineScale.value.tickWidth);
|
||||
const todayOffset = computed(() => {
|
||||
const offset = (displaySerial(new Date()) - displaySerial(range.value.start)) / 86400000;
|
||||
return offset < 0 || offset >= range.value.dayCount ? null : offset * 100 / range.value.dayCount;
|
||||
});
|
||||
const weekendWash = computed(() => {
|
||||
const unit = 100 / range.value.dayCount;
|
||||
const stops: string[] = [];
|
||||
for (let index = 0; index < range.value.dayCount; index += 1) {
|
||||
const parts = displayParts(addDisplayDays(range.value.start, index));
|
||||
const day = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay();
|
||||
const color = day === 0 || day === 6 ? "rgb(67 83 88 / 3.2%)" : "transparent";
|
||||
stops.push(`${color} ${index * unit}%`, `${color} ${(index + 1) * unit}%`);
|
||||
}
|
||||
return `linear-gradient(to right, ${stops.join(", ")})`;
|
||||
});
|
||||
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) => ({
|
||||
@@ -94,7 +122,7 @@ async function loadActiveView() {
|
||||
return;
|
||||
}
|
||||
timeline.value = timelineResponse.data.data;
|
||||
initializedChildScrollers.clear();
|
||||
initializedChildGroups.clear();
|
||||
syncSelectedTaskFromRoute();
|
||||
}
|
||||
function selectGroup(event: Event) {
|
||||
@@ -108,8 +136,7 @@ function toggleTask(taskId: string) {
|
||||
else next.add(taskId);
|
||||
collapsedTaskIds.value = next;
|
||||
if (!next.has(taskId)) {
|
||||
initializedChildScrollers.delete(`labels:${taskId}`);
|
||||
initializedChildScrollers.delete(`timeline:${taskId}`);
|
||||
initializedChildGroups.delete(taskId);
|
||||
}
|
||||
}
|
||||
function visibleDescendants(rootId: string) {
|
||||
@@ -124,6 +151,24 @@ function visibleDescendants(rootId: string) {
|
||||
append(rootId);
|
||||
return result;
|
||||
}
|
||||
function hierarchyAncestors(task: TaskTreeTask) {
|
||||
const result: TaskTreeTask[] = [];
|
||||
let parentId = task.parentId;
|
||||
while (parentId) {
|
||||
const parent = tasksById.value.get(parentId);
|
||||
if (!parent) break;
|
||||
if (parent.startAt || parent.dueAt) result.unshift(parent);
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function hierarchyBandClass(task: TaskTreeTask, ancestor: TaskTreeTask) {
|
||||
const descendants = visibleDescendants(ancestor.id);
|
||||
return {
|
||||
"is-nested": ancestor.kind !== "task",
|
||||
"is-last": descendants.at(-1)?.id === task.id
|
||||
};
|
||||
}
|
||||
function setChildScroller(side: "labels" | "timeline", groupId: string, element: Element | null) {
|
||||
const map = side === "labels" ? labelChildScrollers : timelineChildScrollers;
|
||||
if (!element) {
|
||||
@@ -135,16 +180,23 @@ function setChildScroller(side: "labels" | "timeline", groupId: string, element:
|
||||
}
|
||||
const scroller = element as HTMLElement;
|
||||
map.set(groupId, scroller);
|
||||
const key = `${side}:${groupId}`;
|
||||
if (initializedChildScrollers.has(key)) {
|
||||
if (initializedChildGroups.has(groupId)) {
|
||||
updateChildScrollEdges(side, groupId, scroller);
|
||||
return;
|
||||
}
|
||||
initializedChildScrollers.add(key);
|
||||
void nextTick(() => {
|
||||
scroller.scrollTop = scroller.scrollHeight;
|
||||
updateChildScrollEdges(side, groupId, scroller);
|
||||
});
|
||||
void nextTick(() => initializeChildGroup(groupId));
|
||||
}
|
||||
function initializeChildGroup(groupId: string) {
|
||||
if (initializedChildGroups.has(groupId)) return;
|
||||
const labels = labelChildScrollers.get(groupId);
|
||||
const timelineScroller = timelineChildScrollers.get(groupId);
|
||||
if (!labels || !timelineScroller) return;
|
||||
const desiredTop = Math.max(0, (visibleDescendants(groupId).length - 8) * taskRowHeight);
|
||||
labels.scrollTop = Math.min(desiredTop, Math.max(0, labels.scrollHeight - labels.clientHeight));
|
||||
timelineScroller.scrollTop = Math.min(desiredTop, Math.max(0, timelineScroller.scrollHeight - timelineScroller.clientHeight));
|
||||
initializedChildGroups.add(groupId);
|
||||
updateChildScrollEdges("labels", groupId, labels);
|
||||
updateChildScrollEdges("timeline", groupId, timelineScroller);
|
||||
}
|
||||
function syncChildScroller(groupId: string, source: "labels" | "timeline") {
|
||||
const from = (source === "labels" ? labelChildScrollers : timelineChildScrollers).get(groupId);
|
||||
@@ -245,31 +297,65 @@ function syncTaskLabels() {
|
||||
}
|
||||
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)) };
|
||||
const today = startDisplayDay(new Date()); const min = values.length ? Math.min(...values) : today.getTime(); const max = values.length ? Math.max(...values) : addDisplayDays(today, 6).getTime();
|
||||
const start = addDisplayDays(startDisplayDay(new Date(min)), -1);
|
||||
const dayCount = Math.round((displayDaySerial(startDisplayDay(new Date(max))) - displayDaySerial(start)) / 86400000) + 2;
|
||||
return { start, dayCount: Math.min(120, Math.max(7, dayCount)) };
|
||||
}
|
||||
function startDay(date: Date) { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); }
|
||||
function displayParts(date: Date) {
|
||||
const values = Object.fromEntries(displayPartsFormatter.formatToParts(date).filter((part) => part.type !== "literal").map((part) => [part.type, Number(part.value)]));
|
||||
return { year: values.year, month: values.month, day: values.day, hour: values.hour, minute: values.minute, second: values.second };
|
||||
}
|
||||
function instantForDisplayParts(parts: { year: number; month: number; day: number; hour?: number; minute?: number; second?: number }) {
|
||||
const target = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour ?? 0, parts.minute ?? 0, parts.second ?? 0);
|
||||
let guess = target;
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const shown = displayParts(new Date(guess));
|
||||
guess += target - Date.UTC(shown.year, shown.month - 1, shown.day, shown.hour, shown.minute, shown.second);
|
||||
}
|
||||
return new Date(guess);
|
||||
}
|
||||
function startDisplayDay(date: Date) { const parts = displayParts(date); return instantForDisplayParts({ year: parts.year, month: parts.month, day: parts.day }); }
|
||||
function addDisplayDays(date: Date, days: number) { const parts = displayParts(date); const serial = new Date(Date.UTC(parts.year, parts.month - 1, parts.day + days)); return instantForDisplayParts({ year: serial.getUTCFullYear(), month: serial.getUTCMonth() + 1, day: serial.getUTCDate(), hour: parts.hour, minute: parts.minute, second: parts.second }); }
|
||||
function displaySerial(date: Date) { const parts = displayParts(date); return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second); }
|
||||
function displayDaySerial(date: Date) { const parts = displayParts(date); return Date.UTC(parts.year, parts.month - 1, parts.day); }
|
||||
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);
|
||||
const start = displaySerial(new Date(item.startAt || item.dueAt || range.value.start)); const end = displaySerial(new Date(item.dueAt || item.startAt || range.value.start)); const unit = 100 / range.value.dayCount;
|
||||
const left = Math.max(0, (start - displaySerial(range.value.start)) / 86400000) * unit; const width = Math.max(unit * 0.65, ((end - start) / 86400000) * unit);
|
||||
return { left: `${left}%`, width: `${Math.min(100 - left, width)}%` };
|
||||
}
|
||||
function trackStyle() {
|
||||
return {
|
||||
backgroundImage: `${weekendWash.value}, linear-gradient(to right, #edf1f0 1px, transparent 1px)`,
|
||||
backgroundSize: `100% 100%, ${100 / timelineTicks.value.length}% 100%`
|
||||
};
|
||||
}
|
||||
function milestoneStyle(occursAt: string) {
|
||||
const time = new Date(occursAt).getTime();
|
||||
const left = Math.max(0, (time - range.value.start.getTime()) / 86400000) * (100 / range.value.dayCount);
|
||||
const time = displaySerial(new Date(occursAt));
|
||||
const left = Math.max(0, (time - displaySerial(range.value.start)) / 86400000) * (100 / range.value.dayCount);
|
||||
return { left: `${Math.min(100, left)}%` };
|
||||
}
|
||||
function tickLabel(date: Date) {
|
||||
const monthDay = `${date.getUTCMonth() + 1}/${date.getUTCDate()}`;
|
||||
const parts = displayParts(date);
|
||||
const monthDay = `${parts.month}/${parts.day}`;
|
||||
if (timelineScale.value.precision === "date") return monthDay;
|
||||
const hour = String(date.getUTCHours()).padStart(2, "0");
|
||||
const hour = String(parts.hour).padStart(2, "0");
|
||||
if (timelineScale.value.precision === "hour") return `${monthDay} ${hour}时`;
|
||||
const minute = String(date.getUTCMinutes()).padStart(2, "0");
|
||||
const minute = String(parts.minute).padStart(2, "0");
|
||||
if (timelineScale.value.precision === "minute") return `${monthDay} ${hour}:${minute}`;
|
||||
const second = String(date.getUTCSeconds()).padStart(2, "0");
|
||||
const second = String(parts.second).padStart(2, "0");
|
||||
return `${monthDay} ${hour}:${minute}:${second}`;
|
||||
}
|
||||
function tickClass(date: Date) {
|
||||
const end = date.getTime() + timelineScale.value.stepMs;
|
||||
const now = Date.now();
|
||||
const parts = displayParts(date);
|
||||
const weekday = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay();
|
||||
return {
|
||||
"is-current": date.getTime() <= now && end > now,
|
||||
"is-weekend": timelineScale.value.precision === "date" && (weekday === 0 || weekday === 6)
|
||||
};
|
||||
}
|
||||
function statusLabel(status: string) { return ({ pending: "待处理", in_progress: "进行中", completed: "已完成", blocked: "阻塞" } as Record<string, string>)[status] || status; }
|
||||
function taskKindLabel(kind: TaskTreeTask["kind"]) { return ({ task: "Task", subtask: "Subtask", subsubtask: "Subsubtask" })[kind]; }
|
||||
function plainTaskTitle(task: TaskTreeTask | null) {
|
||||
@@ -307,25 +393,30 @@ function syncSelectedTaskFromRoute() {
|
||||
|
||||
<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 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="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 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="时间轴缩放">
|
||||
<button type="button" title="缩小时间尺度" aria-label="缩小时间尺度" :disabled="timelineScaleIndex === 0" @click="changeTimelineScale(-1)"><ZoomOut :size="15" aria-hidden="true" /></button>
|
||||
<output :title="`当前时间尺度:${timelineScale.label}`" aria-live="polite">{{ timelineScale.label }}</output>
|
||||
<button type="button" title="放大时间尺度" aria-label="放大时间尺度" :disabled="timelineScaleIndex === timelineScales.length - 1" @click="changeTimelineScale(1)"><ZoomIn :size="15" aria-hidden="true" /></button>
|
||||
<div class="tasktree-context-copy">
|
||||
<div class="tasktree-product-title"><span>项目时间线</span><strong>TaskTree</strong></div>
|
||||
<span class="tasktree-context-separator" aria-hidden="true" />
|
||||
<div v-if="isGlobalView" class="tasktree-current-group"><strong>全局 TaskGroups</strong><p>跨项目时间范围与工作量概览</p></div>
|
||||
<div v-else-if="timeline" class="tasktree-current-group"><strong>{{ timeline.group.name }}</strong><p>{{ timeline.group.description || '未填写 taskgroup 说明' }}</p></div>
|
||||
</div>
|
||||
<div class="tasktree-context-actions">
|
||||
<div class="tasktree-timeline-tools">
|
||||
<span>{{ range.dayCount }} 天</span>
|
||||
<div class="tasktree-zoom" role="group" aria-label="时间轴缩放">
|
||||
<button type="button" title="缩小时间尺度" aria-label="缩小时间尺度" :disabled="timelineScaleIndex === 0" @click="changeTimelineScale(-1)"><ZoomOut :size="15" aria-hidden="true" /></button>
|
||||
<output :title="`当前时间尺度:${timelineScale.label}`" aria-live="polite">{{ timelineScale.label }}</output>
|
||||
<button type="button" title="放大时间尺度" aria-label="放大时间尺度" :disabled="timelineScaleIndex === timelineScales.length - 1" @click="changeTimelineScale(1)"><ZoomIn :size="15" aria-hidden="true" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<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 tasktree-refresh" type="button" title="刷新" aria-label="刷新" :disabled="loading" @click="loadActiveView"><RefreshCw :size="15" aria-hidden="true" /></button>
|
||||
</div>
|
||||
</header>
|
||||
<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-gantt" :class="{ 'global-view': isGlobalView, 'has-today': todayOffset !== null }" :style="{ '--task-column-width': `${taskColumnWidth}px`, '--tick-count': timelineTicks.length, '--tick-width': `${timelineScale.tickWidth}px`, '--timeline-width': `${timelineWidth}px`, '--today-left': `${todayOffset ?? 0}%` }">
|
||||
<div class="tasktree-corner">{{ isGlobalView ? 'TaskGroup' : '任务' }}</div>
|
||||
<div
|
||||
class="tasktree-column-resizer"
|
||||
@@ -341,7 +432,7 @@ function syncSelectedTaskFromRoute() {
|
||||
/>
|
||||
<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>
|
||||
<span v-for="tick in timelineTicks" :key="tick.toISOString()" :class="tickClass(tick)">{{ tickLabel(tick) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="taskLabelsElement" class="tasktree-labels" @scroll="syncTaskLabels">
|
||||
@@ -356,7 +447,7 @@ function syncSelectedTaskFromRoute() {
|
||||
</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)">
|
||||
<div class="tasktree-label" :data-task-id="root.id" :data-dated="Boolean(root.startAt || root.dueAt)">
|
||||
<button
|
||||
v-if="childrenByParent.has(root.id)"
|
||||
class="tasktree-disclosure"
|
||||
@@ -383,7 +474,7 @@ function syncSelectedTaskFromRoute() {
|
||||
@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)">
|
||||
<div v-for="task in visibleDescendants(root.id)" :key="task.id" class="tasktree-label" :class="task.kind" :data-task-id="task.id" :data-dated="Boolean(task.startAt || task.dueAt)">
|
||||
<button
|
||||
v-if="childrenByParent.has(task.id)"
|
||||
class="tasktree-disclosure"
|
||||
@@ -408,7 +499,7 @@ function syncSelectedTaskFromRoute() {
|
||||
</div>
|
||||
<div ref="timelineBodyElement" class="tasktree-timeline-body" @scroll="syncTimelineBody">
|
||||
<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%` }">
|
||||
<div v-for="item in overview" :key="item.group.id" class="tasktree-track tasktree-group-track" :style="trackStyle()">
|
||||
<button
|
||||
v-if="item.startAt || item.dueAt"
|
||||
class="tasktree-group-bar"
|
||||
@@ -423,8 +514,9 @@ function syncSelectedTaskFromRoute() {
|
||||
</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>
|
||||
<div class="tasktree-track" :data-task-id="root.id" :style="trackStyle()">
|
||||
<span v-if="childrenByParent.has(root.id) && (root.startAt || root.dueAt)" class="tasktree-summary-connector" :style="barStyle(root)" aria-hidden="true" />
|
||||
<button v-if="root.startAt || root.dueAt" class="tasktree-bar" :class="[`status-${root.status}`, { 'is-summary': childrenByParent.has(root.id) }]" :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
|
||||
@@ -435,8 +527,17 @@ function syncSelectedTaskFromRoute() {
|
||||
@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>
|
||||
<div v-for="task in visibleDescendants(root.id)" :key="task.id" class="tasktree-track" :data-task-id="task.id" :style="trackStyle()">
|
||||
<span
|
||||
v-for="ancestor in hierarchyAncestors(task)"
|
||||
:key="ancestor.id"
|
||||
class="tasktree-hierarchy-band"
|
||||
:class="hierarchyBandClass(task, ancestor)"
|
||||
:style="barStyle(ancestor)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-if="childrenByParent.has(task.id) && (task.startAt || task.dueAt)" class="tasktree-summary-connector is-nested" :style="barStyle(task)" aria-hidden="true" />
|
||||
<button v-if="task.startAt || task.dueAt" class="tasktree-bar" :class="[`status-${task.status}`, task.kind, { 'is-summary': childrenByParent.has(task.id) }]" :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>
|
||||
@@ -466,13 +567,26 @@ function syncSelectedTaskFromRoute() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tasktree-page { display: grid; gap: 12px; min-width: 0; }
|
||||
:global(.platform-content:has(> .tasktree-page)) { background: var(--console-canvas); }
|
||||
.tasktree-page { display: grid; 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-workspace { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; height: calc(100dvh - 82px); min-height: 360px; border: 1px solid #d9e0df; background: #fff; min-width: 0; overflow: hidden; }
|
||||
.tasktree-docked-detail { height: calc(100dvh - 82px); min-width: 0; min-height: 360px; overflow: hidden; border: 1px solid var(--border-color); background: var(--surface-primary); }
|
||||
.tasktree-context { min-height: 48px; padding: 6px 8px 6px 12px; border-bottom: 1px solid #dfe5e4; display: flex; align-items: center; justify-content: space-between; gap: 12px; background: #fff; }
|
||||
.tasktree-context-copy { display: flex; min-width: 0; align-items: center; gap: 10px; }
|
||||
.tasktree-product-title { display: flex; flex: 0 0 auto; align-items: baseline; gap: 8px; }
|
||||
.tasktree-product-title span { color: var(--console-cyan-700); font-family: var(--console-font-mono); font-size: 9px; font-weight: 700; }
|
||||
.tasktree-product-title strong { color: var(--text-primary); font-size: 18px; line-height: 1; }
|
||||
.tasktree-context-separator { width: 1px; height: 24px; flex: 0 0 1px; background: var(--border-color); }
|
||||
.tasktree-current-group { display: flex; min-width: 0; max-width: min(38vw, 560px); align-items: baseline; gap: 8px; }
|
||||
.tasktree-current-group strong { flex: 0 1 auto; overflow: hidden; color: var(--text-primary); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tasktree-current-group p { min-width: 0; margin: 0; overflow: hidden; color: var(--text-secondary); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tasktree-context-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 7px; }
|
||||
.tasktree-view-layout.docked .tasktree-current-group { max-width: 190px; }
|
||||
.tasktree-view-layout.docked .tasktree-current-group p { display: none; }
|
||||
.tasktree-view-layout.docked .tasktree-current-group strong { max-width: 190px; }
|
||||
.tasktree-view-layout.docked .tasktree-group-select select { width: clamp(140px, 13vw, 180px); }
|
||||
.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; }
|
||||
@@ -489,16 +603,20 @@ function syncSelectedTaskFromRoute() {
|
||||
.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 { min-width: 0; overflow-x: auto; overflow-y: hidden; border-bottom: 1px solid #dfe5e4; background: #fbfcfc; scrollbar-width: none; overscroll-behavior-inline: contain; }
|
||||
.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; max-height: 100%; overflow-y: auto; overflow-x: hidden; border-right: 1px solid var(--border-color); scrollbar-width: thin; overscroll-behavior-block: contain; }
|
||||
.tasktree-days { position: relative; 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 { position: relative; display: flex; align-items: center; justify-content: center; min-width: 0; padding: 0 4px; overflow: hidden; border-right: 1px solid #e6ebea; color: #68777a; font-family: var(--console-font-mono); font-size: 10px; white-space: nowrap; }
|
||||
.tasktree-days span.is-weekend { background: #f3f6f5; color: #879194; }
|
||||
.tasktree-days span.is-current { color: #b55739; font-weight: 700; }
|
||||
.tasktree-days span.is-current::after { position: absolute; right: 22%; bottom: 3px; left: 22%; height: 2px; border-radius: 2px; background: #d46d4a; content: ""; }
|
||||
.tasktree-labels { min-width: 0; min-height: 0; max-height: 100%; overflow-y: auto; overflow-x: hidden; border-right: 1px solid #dfe5e4; background: #fcfdfd; 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 { height: 46px; border-bottom: 1px solid #edf0ef; background: #fff; padding: 0 8px; display: flex; align-items: center; color: var(--text-primary); min-width: 0; }
|
||||
.tasktree-task-group > .tasktree-label { background: #f7f9f8; font-weight: 600; }
|
||||
.tasktree-label:hover { background: #f2f7f5; }
|
||||
.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; }
|
||||
@@ -519,21 +637,45 @@ function syncSelectedTaskFromRoute() {
|
||||
.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; max-height: 100%; overflow: auto; scrollbar-gutter: stable; overscroll-behavior: contain; touch-action: pan-x pan-y; }
|
||||
.tasktree-timeline-body { min-width: 0; min-height: 0; max-height: 100%; overflow: auto; background: #fff; 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-track { position: relative; height: 46px; border-bottom: 1px solid #edf0ef; background-color: #fff; background-image: linear-gradient(to right, #edf1f0 1px, transparent 1px); }
|
||||
.tasktree-task-group > .tasktree-track { background-color: #f7f9f8; }
|
||||
.tasktree-gantt.has-today .tasktree-track::before { position: absolute; top: 0; bottom: -1px; left: var(--today-left); z-index: 1; width: 1px; background: rgb(204 91 57 / 48%); content: ""; pointer-events: none; }
|
||||
.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-hierarchy-band,
|
||||
.tasktree-summary-connector { --summary-ink: rgb(64 91 109 / 27%); --summary-wash: rgb(64 91 109 / 4.5%); position: absolute; z-index: 1; box-sizing: border-box; border-inline: 1px solid var(--summary-ink); background: linear-gradient(90deg, var(--summary-wash), transparent 12px, transparent calc(100% - 12px), var(--summary-wash)); pointer-events: none; }
|
||||
.tasktree-hierarchy-band { inset-block: 0; }
|
||||
.tasktree-hierarchy-band.is-last::before,
|
||||
.tasktree-hierarchy-band.is-last::after { position: absolute; bottom: 0; width: 9px; height: 1px; background: var(--summary-ink); content: ""; }
|
||||
.tasktree-hierarchy-band.is-last::before { left: 0; }
|
||||
.tasktree-hierarchy-band.is-last::after { right: 0; }
|
||||
.tasktree-hierarchy-band.is-nested,
|
||||
.tasktree-summary-connector.is-nested { --summary-ink: rgb(91 111 124 / 22%); --summary-wash: rgb(91 111 124 / 3.5%); }
|
||||
.tasktree-hierarchy-band.is-nested { inset-block: 3px; }
|
||||
.tasktree-summary-connector { top: 20px; bottom: 0; }
|
||||
.tasktree-summary-connector.is-nested { top: 20px; }
|
||||
.tasktree-bar { position: absolute; top: 11px; z-index: 2; height: 24px; min-width: 18px; padding: 0; border: 1px solid rgb(31 89 126 / 58%); border-radius: 4px; background: linear-gradient(90deg, #3875a4, #4a87b4); color: white; overflow: visible; box-shadow: inset 0 1px rgb(255 255 255 / 24%), 0 1px 2px rgb(22 46 64 / 14%); transition: filter 120ms ease, box-shadow 120ms ease, transform 120ms ease; }
|
||||
.tasktree-bar:hover { filter: brightness(0.96) saturate(1.08); box-shadow: inset 0 1px rgb(255 255 255 / 28%), 0 3px 7px rgb(22 46 64 / 18%); transform: translateY(-1px); }
|
||||
.tasktree-bar span { display: none; }
|
||||
.tasktree-bar.subtask { top: 15px; height: 16px; border-color: rgb(67 101 125 / 52%); border-radius: 3px; background: linear-gradient(90deg, #607c92, #7895aa); }
|
||||
.tasktree-bar.subsubtask { top: 17px; height: 12px; border-color: rgb(92 109 122 / 45%); border-radius: 2px; background: linear-gradient(90deg, #8194a2, #98aab7); }
|
||||
.tasktree-bar.is-summary { top: 17px; height: 3px; border: 0; border-radius: 1px; background: #466579; box-shadow: none; }
|
||||
.tasktree-bar.is-summary:hover { filter: brightness(0.9); transform: none; }
|
||||
.tasktree-bar.is-summary::before,
|
||||
.tasktree-bar.is-summary::after { position: absolute; top: 0; width: 2px; height: 8px; border-radius: 0 0 1px 1px; background: inherit; content: ""; }
|
||||
.tasktree-bar.is-summary::before { left: 0; }
|
||||
.tasktree-bar.is-summary::after { right: 0; }
|
||||
.tasktree-group-bar { position: absolute; top: 16px; height: 36px; min-width: 28px; overflow: hidden; border: 1px solid #135b94; border-radius: 4px; background: #176ba8; color: white; box-shadow: inset 0 1px 0 rgb(255 255 255 / 22%), 0 2px 5px rgb(17 67 101 / 20%); cursor: pointer; }
|
||||
.tasktree-group-bar:hover { background: #125d95; }
|
||||
.tasktree-group-bar span { display: block; padding: 0 10px; overflow: hidden; font-size: 11px; font-weight: 700; line-height: 34px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tasktree-bar.status-completed { background: #23845c; }
|
||||
.tasktree-bar.status-blocked { background: #b84149; }
|
||||
.tasktree-bar.status-pending { background: #687581; }
|
||||
.tasktree-bar.status-completed { border-color: rgb(29 111 76 / 62%); background: linear-gradient(90deg, #277d5a, #4a9a72); }
|
||||
.tasktree-bar.status-blocked { border-color: rgb(144 43 50 / 62%); background: linear-gradient(90deg, #a83c45, #c55a60); }
|
||||
.tasktree-bar.status-pending { border-color: rgb(79 92 102 / 58%); background: linear-gradient(90deg, #63717b, #81909a); }
|
||||
.tasktree-bar.status-completed.is-summary,
|
||||
.tasktree-bar.status-blocked.is-summary,
|
||||
.tasktree-bar.status-pending.is-summary { background: #466579; }
|
||||
.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; }
|
||||
@@ -541,12 +683,9 @@ function syncSelectedTaskFromRoute() {
|
||||
.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.has-hidden-above:not(.has-hidden-below) { box-shadow: inset 0 14px 12px -14px rgb(20 43 58 / 58%); }
|
||||
.tasktree-child-viewport.has-hidden-below:not(.has-hidden-above) { box-shadow: inset 0 -14px 12px -14px rgb(20 43 58 / 52%); }
|
||||
.tasktree-child-viewport.has-hidden-above.has-hidden-below { box-shadow: inset 0 14px 12px -14px rgb(20 43 58 / 58%), inset 0 -14px 12px -14px rgb(20 43 58 / 52%); }
|
||||
.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; }
|
||||
@@ -558,21 +697,31 @@ function syncSelectedTaskFromRoute() {
|
||||
.tasktree-statusbar-authority { margin-left: auto; }
|
||||
.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-group-select select { width: clamp(150px, 16vw, 240px); height: 30px; padding: 4px 8px; border: 1px solid var(--border-color); background: var(--surface-primary); color: var(--text-primary); }
|
||||
.tasktree-refresh { width: 30px; height: 30px; min-height: 30px; padding: 0; }
|
||||
: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); }
|
||||
.tasktree-current-group { max-width: 150px; }
|
||||
.tasktree-current-group p { display: none; }
|
||||
.tasktree-current-group strong { max-width: 150px; }
|
||||
.tasktree-timeline-tools > span { display: none; }
|
||||
.tasktree-group-select select { width: 132px; }
|
||||
.tasktree-context-copy,
|
||||
.tasktree-context-actions { gap: 6px; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.tasktree-view-layout.docked { grid-template-columns: minmax(0, 1fr); }
|
||||
.tasktree-workspace { height: calc(100dvh - 225px); min-height: 400px; }
|
||||
.tasktree-workspace { height: calc(100dvh - 68px); 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-context { min-height: 82px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.tasktree-current-group p { display: none; }
|
||||
.tasktree-context-actions { width: 100%; justify-content: space-between; }
|
||||
.tasktree-timeline-tools { gap: 4px; }
|
||||
.tasktree-statusbar-authority { margin-left: 0; }
|
||||
.tasktree-group-select select { min-width: 130px; max-width: 42vw; }
|
||||
.tasktree-group-select select { width: min(42vw, 180px); }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user