Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/views/projects/MdtodoView.vue
T
2026-06-25 17:26:19 +08:00

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>