|
|
|
@@ -1,7 +1,7 @@
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
|
|
|
|
import { useRoute, useRouter } from "vue-router";
|
|
|
|
|
import { ChevronDown, ChevronRight, RefreshCw, ZoomIn, ZoomOut } from "lucide-vue-next";
|
|
|
|
|
import { ArrowLeft, 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";
|
|
|
|
@@ -337,6 +337,21 @@ function barStyle(item: { startAt: string | null; dueAt: string | null }) {
|
|
|
|
|
const rightEdge = Math.min(100, Math.max(left, (end - rangeStart) / 86400000 * unit));
|
|
|
|
|
return { right: `${100 - rightEdge}%`, width: `${Math.max(0, rightEdge - left)}%` };
|
|
|
|
|
}
|
|
|
|
|
function overviewItemTotal(item: TaskTreeGroupOverview) {
|
|
|
|
|
return item.taskCount + item.subtaskCount + item.subsubtaskCount;
|
|
|
|
|
}
|
|
|
|
|
function overviewCompletion(item: TaskTreeGroupOverview) {
|
|
|
|
|
const total = overviewItemTotal(item);
|
|
|
|
|
return total > 0 ? Math.round(item.completedCount * 100 / total) : 0;
|
|
|
|
|
}
|
|
|
|
|
function groupBarStyle(item: TaskTreeGroupOverview) {
|
|
|
|
|
return { ...barStyle(item), "--completion": `${overviewCompletion(item)}%` };
|
|
|
|
|
}
|
|
|
|
|
function groupBarClass(item: TaskTreeGroupOverview) {
|
|
|
|
|
const start = displaySerial(new Date(item.startAt || item.dueAt || range.value.start));
|
|
|
|
|
const rangeStart = displaySerial(range.value.start);
|
|
|
|
|
return { "label-after": (start - rangeStart) / 86400000 / range.value.dayCount < 0.12 };
|
|
|
|
|
}
|
|
|
|
|
function trackStyle() {
|
|
|
|
|
return {
|
|
|
|
|
backgroundImage: `${weekendWash.value}, linear-gradient(to right, #edf1f0 1px, transparent 1px)`,
|
|
|
|
@@ -420,6 +435,7 @@ function syncSelectedTaskFromRoute() {
|
|
|
|
|
<div class="tasktree-context-copy">
|
|
|
|
|
<div class="tasktree-product-title"><span>项目时间线</span><strong>TaskTree</strong></div>
|
|
|
|
|
<span class="tasktree-context-separator" aria-hidden="true" />
|
|
|
|
|
<RouterLink v-if="!isGlobalView" class="icon-button tasktree-back-link" to="/projects/tasktree" title="返回全部 TaskGroups" aria-label="返回全部 TaskGroups"><ArrowLeft :size="16" aria-hidden="true" /></RouterLink>
|
|
|
|
|
<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>
|
|
|
|
@@ -525,12 +541,15 @@ function syncSelectedTaskFromRoute() {
|
|
|
|
|
<button
|
|
|
|
|
v-if="item.startAt || item.dueAt"
|
|
|
|
|
class="tasktree-group-bar"
|
|
|
|
|
:style="barStyle(item)"
|
|
|
|
|
:class="groupBarClass(item)"
|
|
|
|
|
:style="groupBarStyle(item)"
|
|
|
|
|
type="button"
|
|
|
|
|
:aria-label="`打开 ${item.group.name}`"
|
|
|
|
|
:aria-label="`打开 ${item.group.name},已完成 ${item.completedCount} / ${overviewItemTotal(item)},完成度 ${overviewCompletion(item)}%`"
|
|
|
|
|
@click="openGroup(item.group.id)"
|
|
|
|
|
>
|
|
|
|
|
<span>{{ item.taskCount + item.subtaskCount + item.subsubtaskCount }} 项</span>
|
|
|
|
|
<span class="tasktree-group-progress" aria-hidden="true" />
|
|
|
|
|
<span class="tasktree-group-progress-label inside"><strong>{{ item.completedCount }} / {{ overviewItemTotal(item) }}</strong><small>{{ overviewCompletion(item) }}%</small></span>
|
|
|
|
|
<span class="tasktree-group-progress-label outside" aria-hidden="true">{{ item.completedCount }} / {{ overviewItemTotal(item) }}</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
@@ -605,6 +624,8 @@ function syncSelectedTaskFromRoute() {
|
|
|
|
|
.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-back-link { width: 30px; height: 30px; min-height: 30px; flex: 0 0 30px; color: var(--text-secondary); }
|
|
|
|
|
.tasktree-back-link:hover { color: var(--text-primary); }
|
|
|
|
|
.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; }
|
|
|
|
@@ -690,9 +711,19 @@ function syncSelectedTaskFromRoute() {
|
|
|
|
|
.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-group-bar { container-type: inline-size; position: absolute; top: 16px; height: 36px; min-width: 28px; overflow: visible; border: 1px solid #4e6674; border-radius: 4px; background: #667d8b; color: #fff; box-shadow: inset 0 1px 0 rgb(255 255 255 / 28%), 0 2px 5px rgb(28 48 58 / 16%); cursor: pointer; isolation: isolate; }
|
|
|
|
|
.tasktree-group-bar:hover { border-color: #4f6976; box-shadow: inset 0 1px 0 rgb(255 255 255 / 68%), 0 3px 8px rgb(28 48 58 / 22%); }
|
|
|
|
|
.tasktree-group-progress { position: absolute; inset: 0 auto 0 0; z-index: -1; width: var(--completion); overflow: hidden; border-radius: 3px 0 0 3px; background: linear-gradient(90deg, #276d50, #4c9470); transition: width 180ms ease; }
|
|
|
|
|
.tasktree-group-progress-label { position: relative; z-index: 1; align-items: baseline; white-space: nowrap; }
|
|
|
|
|
.tasktree-group-progress-label.inside { display: flex; height: 34px; justify-content: center; gap: 7px; padding: 0 10px; line-height: 34px; text-shadow: 0 1px 1px rgb(20 42 31 / 55%); }
|
|
|
|
|
.tasktree-group-progress-label.inside strong { font-size: 11px; font-weight: 750; }
|
|
|
|
|
.tasktree-group-progress-label.inside small { font-family: var(--console-font-mono); font-size: 9px; opacity: 0.82; }
|
|
|
|
|
.tasktree-group-progress-label.outside { position: absolute; top: 50%; right: calc(100% + 8px); display: none; padding: 2px 4px; transform: translateY(-50%); background: rgb(255 255 255 / 88%); color: #41545d; font-family: var(--console-font-mono); font-size: 10px; font-weight: 700; line-height: 16px; }
|
|
|
|
|
.tasktree-group-bar.label-after .tasktree-group-progress-label.outside { right: auto; left: calc(100% + 8px); }
|
|
|
|
|
@container (max-width: 96px) {
|
|
|
|
|
.tasktree-group-progress-label.inside { display: none; }
|
|
|
|
|
.tasktree-group-progress-label.outside { display: block; }
|
|
|
|
|
}
|
|
|
|
|
.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); }
|
|
|
|
|