299 lines
17 KiB
Vue
299 lines
17 KiB
Vue
<!-- SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo. -->
|
|
<!-- Responsibility: MDTODO project page consuming public ProjectTask DTOs without Workbench or Markdown internals. -->
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, ref, watch } from "vue";
|
|
import { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource, type ProjectWorkbenchLinkRecord } from "@/api";
|
|
import EmptyState from "@/components/common/EmptyState.vue";
|
|
import LoadingState from "@/components/common/LoadingState.vue";
|
|
import PageHeader from "@/components/common/PageHeader.vue";
|
|
import type { ApiError, ErrorDiagnostic } from "@/types";
|
|
|
|
const loading = ref(false);
|
|
const taskLoading = ref(false);
|
|
const linkLoading = ref(false);
|
|
const error = ref<string | null>(null);
|
|
const apiError = ref<ApiError | null>(null);
|
|
const diagnostic = ref<ErrorDiagnostic | null>(null);
|
|
const navigation = ref<ProjectNavigationResponse | null>(null);
|
|
const sources = ref<ProjectSource[]>([]);
|
|
const files = ref<MdtodoFileRecord[]>([]);
|
|
const tasks = ref<MdtodoTaskRecord[]>([]);
|
|
const links = ref<ProjectWorkbenchLinkRecord[]>([]);
|
|
const selectedSourceId = ref<string | null>(null);
|
|
const selectedFileRef = ref<string | null>(null);
|
|
const selectedTaskRef = ref<string | null>(null);
|
|
|
|
const selectedSource = computed(() => sources.value.find((source) => source.sourceId === selectedSourceId.value) ?? null);
|
|
const selectedFile = computed(() => files.value.find((file) => file.fileRef === selectedFileRef.value) ?? null);
|
|
const selectedTask = computed(() => tasks.value.find((task) => task.taskRef === selectedTaskRef.value) ?? null);
|
|
const filteredTasks = computed(() => selectedFileRef.value ? tasks.value.filter((task) => task.fileRef === selectedFileRef.value) : tasks.value);
|
|
const taskStatusCounts = computed(() => filteredTasks.value.reduce((acc, task) => {
|
|
const key = task.status || "unknown";
|
|
acc[key] = (acc[key] ?? 0) + 1;
|
|
return acc;
|
|
}, {} as Record<string, number>));
|
|
const workbenchLaunchEnabled = computed(() => navigation.value?.navigation?.capabilities?.workbenchLaunch === true);
|
|
|
|
onMounted(() => void loadPage());
|
|
|
|
watch(selectedSourceId, async (sourceId) => {
|
|
if (!sourceId || loading.value) return;
|
|
await loadFilesAndTasks(sourceId);
|
|
});
|
|
|
|
watch(selectedTaskRef, async (taskRef) => {
|
|
await loadLinks(taskRef);
|
|
});
|
|
|
|
async function loadPage(): Promise<void> {
|
|
loading.value = true;
|
|
error.value = null;
|
|
apiError.value = null;
|
|
diagnostic.value = null;
|
|
try {
|
|
const [navigationResponse, sourceResponse] = await Promise.all([projectManagementAPI.navigation(), projectManagementAPI.sources()]);
|
|
if (!navigationResponse.ok) throw navigationResponse;
|
|
if (!sourceResponse.ok) throw sourceResponse;
|
|
navigation.value = navigationResponse.data;
|
|
sources.value = sourceResponse.data?.sources ?? [];
|
|
selectedSourceId.value = sources.value[0]?.sourceId ?? null;
|
|
if (selectedSourceId.value) await loadFilesAndTasks(selectedSourceId.value);
|
|
else await loadLinks(null);
|
|
} catch (err) {
|
|
setError(err);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function loadFilesAndTasks(sourceId: string): Promise<void> {
|
|
taskLoading.value = true;
|
|
try {
|
|
const [fileResponse, taskResponse] = await Promise.all([
|
|
projectManagementAPI.files(sourceId),
|
|
projectManagementAPI.tasks({ sourceId })
|
|
]);
|
|
if (!fileResponse.ok) throw fileResponse;
|
|
if (!taskResponse.ok) throw taskResponse;
|
|
files.value = fileResponse.data?.files ?? [];
|
|
tasks.value = taskResponse.data?.tasks ?? [];
|
|
selectedFileRef.value = files.value[0]?.fileRef ?? null;
|
|
const firstVisibleTask = selectedFileRef.value ? tasks.value.find((task) => task.fileRef === selectedFileRef.value) : tasks.value[0];
|
|
selectedTaskRef.value = firstVisibleTask?.taskRef ?? null;
|
|
if (!selectedTaskRef.value) await loadLinks(null);
|
|
} catch (err) {
|
|
setError(err);
|
|
} finally {
|
|
taskLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function loadLinks(taskRef: string | null): Promise<void> {
|
|
linkLoading.value = true;
|
|
try {
|
|
const response = await projectManagementAPI.workbenchLinks(taskRef ? { taskRef } : {});
|
|
if (!response.ok) throw response;
|
|
links.value = response.data?.links ?? [];
|
|
} catch (err) {
|
|
setError(err);
|
|
} finally {
|
|
linkLoading.value = false;
|
|
}
|
|
}
|
|
|
|
function selectFile(fileRef: string): void {
|
|
selectedFileRef.value = fileRef;
|
|
const nextTask = tasks.value.find((task) => task.fileRef === fileRef) ?? null;
|
|
selectedTaskRef.value = nextTask?.taskRef ?? null;
|
|
}
|
|
|
|
function selectTask(taskRef: string): void {
|
|
selectedTaskRef.value = taskRef;
|
|
}
|
|
|
|
function setError(err: unknown): void {
|
|
const context = projectApiError(err);
|
|
error.value = context.error;
|
|
apiError.value = context.apiError;
|
|
diagnostic.value = context.diagnostic;
|
|
}
|
|
|
|
function projectApiError(err: unknown): { error: string; apiError: ApiError | null; diagnostic: ErrorDiagnostic | null } {
|
|
if (err && typeof err === "object" && "error" in err) {
|
|
const result = err as { error?: string | null; apiError?: ApiError | null; diagnostic?: ErrorDiagnostic | null; status?: number };
|
|
return { error: result.error || `HTTP ${result.status ?? "unknown"}`, apiError: result.apiError ?? null, diagnostic: result.diagnostic ?? null };
|
|
}
|
|
return { error: err instanceof Error ? err.message : String(err || "MDTODO 加载失败"), apiError: null, diagnostic: null };
|
|
}
|
|
|
|
function statusLabel(value?: string | null): string {
|
|
if (value === "done") return "完成";
|
|
if (value === "blocked") return "阻塞";
|
|
if (value === "open") return "待办";
|
|
return value || "未知";
|
|
}
|
|
|
|
function sourceStatusTone(value?: string | null): string {
|
|
if (value === "active" || value === "ok" || value === "ready") return "ok";
|
|
if (value === "blocked" || value === "error" || value === "failed") return "error";
|
|
return "pending";
|
|
}
|
|
|
|
function taskIndent(task: MdtodoTaskRecord): Record<string, string> {
|
|
return { paddingInlineStart: `${10 + Math.max(0, task.depth ?? 0) * 16}px` };
|
|
}
|
|
|
|
function shortRef(value?: string | null): string {
|
|
if (!value) return "-";
|
|
return value.length > 42 ? `${value.slice(0, 24)}...${value.slice(-10)}` : value;
|
|
}
|
|
|
|
function displayDate(value?: string | null): string {
|
|
if (!value) return "-";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section class="route-stack mdtodo-page" data-testid="project-management-mdtodo">
|
|
<PageHeader eyebrow="Project Management" title="MDTODO" description="按 source、文件和任务查看当前项目上下文、任务状态和关联工作台摘要。" />
|
|
<LoadingState v-if="loading && !sources.length" label="加载 MDTODO source" />
|
|
<section v-else-if="error" class="data-panel mdtodo-error" data-testid="project-management-error">
|
|
<EmptyState title="MDTODO 加载失败" :description="error" :api-error="apiError" :diagnostic="diagnostic" action-label="重试" @action="loadPage" />
|
|
</section>
|
|
<template v-else>
|
|
<section class="mdtodo-summary-grid" data-testid="mdtodo-summary" aria-label="MDTODO 摘要">
|
|
<article class="metric-card"><span>Source</span><strong>{{ sources.length }}</strong><small>{{ selectedSource?.displayName || '未选择' }}</small></article>
|
|
<article class="metric-card"><span>Files</span><strong>{{ files.length }}</strong><small>{{ selectedFile?.relativePath || 'no file' }}</small></article>
|
|
<article class="metric-card"><span>Tasks</span><strong>{{ filteredTasks.length }}</strong><small>open {{ taskStatusCounts.open || 0 }} / done {{ taskStatusCounts.done || 0 }}</small></article>
|
|
<article class="metric-card"><span>Links</span><strong>{{ links.length }}</strong><small>{{ workbenchLaunchEnabled ? 'launch enabled' : 'launch waits for P3' }}</small></article>
|
|
</section>
|
|
|
|
<section class="mdtodo-workspace">
|
|
<aside class="data-panel mdtodo-source-panel" data-testid="mdtodo-source-list">
|
|
<header class="mdtodo-panel-header"><h2>Source</h2><button class="btn btn-secondary btn-sm" type="button" :disabled="loading" @click="loadPage">刷新</button></header>
|
|
<EmptyState v-if="!sources.length" title="暂无 source" description="ProjectSource registry 没有返回可见 MDTODO source。" />
|
|
<button v-for="source in sources" v-else :key="source.sourceId" class="source-option" type="button" :data-selected="source.sourceId === selectedSourceId" :data-source-id="source.sourceId" @click="selectedSourceId = source.sourceId">
|
|
<span class="pm-status" :data-tone="sourceStatusTone(source.status)">{{ source.status || 'unknown' }}</span>
|
|
<strong>{{ source.displayName || source.sourceId }}</strong>
|
|
<small>{{ source.kind || 'mdtodo' }} / {{ source.projectId || '-' }}</small>
|
|
</button>
|
|
</aside>
|
|
|
|
<aside class="data-panel mdtodo-file-panel" data-testid="mdtodo-file-list">
|
|
<header class="mdtodo-panel-header"><h2>文件</h2><span>{{ taskLoading ? '同步中' : `${files.length} 个` }}</span></header>
|
|
<LoadingState v-if="taskLoading" compact label="同步文件和任务" />
|
|
<EmptyState v-else-if="!files.length" title="暂无 TODO 文件" description="服务已连接,但当前 source 尚未发现可投影的 TODO。" />
|
|
<button v-for="file in files" v-else :key="file.fileRef" class="file-option" type="button" :data-selected="file.fileRef === selectedFileRef" :data-file-ref="file.fileRef" @click="selectFile(file.fileRef)">
|
|
<strong>{{ file.title || file.relativePath || file.fileRef }}</strong>
|
|
<small>{{ file.relativePath || file.fileRef }}</small>
|
|
<span :data-status="file.parseError ? 'error' : 'ok'">{{ file.parseError ? 'parse error' : `${file.taskCount ?? 0} tasks` }}</span>
|
|
</button>
|
|
</aside>
|
|
|
|
<section class="data-panel mdtodo-task-panel" data-testid="mdtodo-task-tree">
|
|
<header class="mdtodo-panel-header"><h2>任务树</h2><span>{{ filteredTasks.length }} tasks</span></header>
|
|
<EmptyState v-if="!filteredTasks.length" title="暂无任务" description="当前文件没有任务投影;可以刷新 source 状态后再查看。" />
|
|
<div v-else class="task-tree" role="tree" aria-label="MDTODO task tree">
|
|
<button v-for="task in filteredTasks" :key="task.taskRef" class="task-row" role="treeitem" type="button" :style="taskIndent(task)" :data-selected="task.taskRef === selectedTaskRef" :data-task-ref="task.taskRef" :data-task-status="task.status || 'unknown'" @click="selectTask(task.taskRef)">
|
|
<span class="task-status" :data-status="task.status || 'unknown'">{{ statusLabel(task.status) }}</span>
|
|
<strong>{{ task.title || task.taskId || 'Untitled task' }}</strong>
|
|
<small>line {{ task.lineNumber ?? '-' }} / links {{ task.linkCount ?? 0 }}</small>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<aside class="data-panel mdtodo-detail-panel" data-testid="mdtodo-task-detail">
|
|
<header class="mdtodo-panel-header"><h2>任务详情</h2><span>{{ selectedTask ? statusLabel(selectedTask.status) : '未选择' }}</span></header>
|
|
<EmptyState v-if="!selectedTask" title="未选择任务" description="选择任务后显示 taskRef、状态、文件和 Workbench link 摘要。" />
|
|
<template v-else>
|
|
<dl class="task-detail-list">
|
|
<div><dt>Title</dt><dd>{{ selectedTask.title || selectedTask.taskId }}</dd></div>
|
|
<div><dt>TaskRef</dt><dd><code>{{ selectedTask.taskRef }}</code></dd></div>
|
|
<div><dt>Project</dt><dd><code>{{ selectedTask.projectId || '-' }}</code></dd></div>
|
|
<div><dt>FileRef</dt><dd><code>{{ selectedTask.fileRef || '-' }}</code></dd></div>
|
|
<div><dt>Updated</dt><dd>{{ displayDate(selectedTask.updatedAt) }}</dd></div>
|
|
</dl>
|
|
<button class="btn btn-primary" type="button" data-testid="mdtodo-workbench-launch" :disabled="!workbenchLaunchEnabled" :aria-disabled="!workbenchLaunchEnabled">
|
|
在 Workbench 执行
|
|
</button>
|
|
<p class="launch-blocker" data-testid="mdtodo-workbench-launch-blocker">{{ workbenchLaunchEnabled ? '公共 Workbench Launch API 已可用。' : 'Workbench Launch 在 P3 接入;P2 只展示解耦入口和 capability。' }}</p>
|
|
<section class="task-links" data-testid="mdtodo-workbench-link-summary">
|
|
<header><strong>Workbench Links</strong><span v-if="linkLoading">同步中</span></header>
|
|
<ul v-if="links.length">
|
|
<li v-for="link in links" :key="link.linkId">
|
|
<strong>{{ link.sessionId || link.linkId }}</strong>
|
|
<small>{{ shortRef(link.traceId || link.taskRef || link.projectId) }}</small>
|
|
</li>
|
|
</ul>
|
|
<p v-else>暂无关联 session。</p>
|
|
</section>
|
|
</template>
|
|
</aside>
|
|
</section>
|
|
</template>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.mdtodo-page { min-width: 0; }
|
|
.mdtodo-summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
|
.mdtodo-summary-grid .metric-card strong { color: #0f172a; font-size: 28px; line-height: 1.15; }
|
|
.mdtodo-summary-grid .metric-card small { min-width: 0; overflow: hidden; color: #64748b; text-overflow: ellipsis; white-space: nowrap; }
|
|
.mdtodo-workspace { display: grid; min-height: min(720px, calc(100dvh - 190px)); grid-template-columns: minmax(210px, 0.72fr) minmax(230px, 0.82fr) minmax(360px, 1.28fr) minmax(320px, 0.96fr); gap: 12px; }
|
|
.mdtodo-source-panel,
|
|
.mdtodo-file-panel,
|
|
.mdtodo-task-panel,
|
|
.mdtodo-detail-panel,
|
|
.mdtodo-error { display: grid; min-width: 0; min-height: 0; align-content: start; gap: 10px; padding: 14px; overflow: auto; }
|
|
.mdtodo-panel-header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 10px; }
|
|
.mdtodo-panel-header h2 { margin: 0; color: #0f172a; font-size: 16px; line-height: 1.2; }
|
|
.mdtodo-panel-header span { color: #64748b; font-size: 12px; white-space: nowrap; }
|
|
.source-option,
|
|
.file-option,
|
|
.task-row { display: grid; width: 100%; min-width: 0; gap: 5px; border: 1px solid #e2e8f0; border-radius: 8px; background: #ffffff; color: #0f172a; padding: 10px; text-align: left; }
|
|
.source-option[data-selected="true"],
|
|
.file-option[data-selected="true"],
|
|
.task-row[data-selected="true"] { border-color: #0891b2; background: #ecfeff; box-shadow: inset 3px 0 0 #0891b2; }
|
|
.source-option strong,
|
|
.file-option strong,
|
|
.task-row strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.source-option small,
|
|
.file-option small,
|
|
.task-row small { min-width: 0; overflow: hidden; color: #64748b; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
|
.file-option span { width: fit-content; border-radius: 999px; background: #f1f5f9; color: #475569; padding: 3px 7px; font-size: 11px; font-weight: 750; }
|
|
.file-option span[data-status="error"] { background: #fef2f2; color: #991b1b; }
|
|
.task-tree { display: grid; gap: 6px; }
|
|
.pm-status,
|
|
.task-status { display: inline-flex; width: fit-content; max-width: 100%; align-items: center; border-radius: 999px; border: 1px solid #cbd5e1; padding: 4px 8px; font-size: 11px; font-weight: 750; line-height: 1; }
|
|
.pm-status[data-tone="ok"] { border-color: #86efac; background: #dcfce7; color: #166534; }
|
|
.pm-status[data-tone="error"] { border-color: #fecaca; background: #fef2f2; color: #991b1b; }
|
|
.pm-status[data-tone="pending"] { border-color: #fde68a; background: #fffbeb; color: #92400e; }
|
|
.task-status[data-status="done"] { border-color: #86efac; background: #dcfce7; color: #166534; }
|
|
.task-status[data-status="blocked"] { border-color: #fed7aa; background: #fff7ed; color: #9a3412; }
|
|
.task-status { border-color: #bae6fd; background: #f0f9ff; color: #075985; }
|
|
.task-detail-list { display: grid; gap: 8px; margin: 0; }
|
|
.task-detail-list div { display: grid; gap: 4px; border-bottom: 1px solid #e2e8f0; padding-bottom: 8px; }
|
|
.task-detail-list dt { color: #64748b; font-size: 11px; font-weight: 800; text-transform: uppercase; }
|
|
.task-detail-list dd { min-width: 0; margin: 0; color: #0f172a; font-size: 13px; line-height: 1.45; overflow-wrap: anywhere; }
|
|
.task-detail-list code { font-size: 12px; }
|
|
.launch-blocker { margin: 0; border-radius: 8px; background: #f8fafc; color: #475569; padding: 10px; font-size: 12px; line-height: 1.45; }
|
|
.task-links { display: grid; gap: 8px; }
|
|
.task-links header { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: #0f172a; }
|
|
.task-links header span,
|
|
.task-links p { margin: 0; color: #64748b; font-size: 12px; }
|
|
.task-links ul { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
|
|
.task-links li { display: grid; min-width: 0; gap: 4px; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; }
|
|
.task-links li strong,
|
|
.task-links li small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.task-links li small { color: #64748b; }
|
|
@media (max-width: 1320px) { .mdtodo-workspace { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
@media (max-width: 820px) { .mdtodo-summary-grid,
|
|
.mdtodo-workspace { grid-template-columns: 1fr; }
|
|
.mdtodo-workspace { min-height: auto; }
|
|
}
|
|
</style>
|