214 lines
13 KiB
Vue
214 lines
13 KiB
Vue
<!-- SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo. -->
|
||
<!-- Responsibility: Project-management root page consuming only public project-management API DTOs. -->
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from "vue";
|
||
import { RouterLink } from "vue-router";
|
||
import { projectManagementAPI, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, 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 error = ref<string | null>(null);
|
||
const apiError = ref<ApiError | null>(null);
|
||
const diagnostic = ref<ErrorDiagnostic | null>(null);
|
||
const navigation = ref<ProjectNavigationResponse | null>(null);
|
||
const projects = ref<ProjectRecord[]>([]);
|
||
const sources = ref<ProjectSource[]>([]);
|
||
const tasks = ref<MdtodoTaskRecord[]>([]);
|
||
const links = ref<ProjectWorkbenchLinkRecord[]>([]);
|
||
|
||
const sourceById = computed(() => new Map(sources.value.map((source) => [source.sourceId, source])));
|
||
const recentTasks = computed(() => tasks.value.slice(0, 6));
|
||
const activeSourceCount = computed(() => sources.value.filter((source) => source.status !== "blocked" && source.status !== "error").length);
|
||
const projection = computed(() => navigation.value?.projection ?? null);
|
||
|
||
onMounted(() => void loadProjects());
|
||
|
||
async function loadProjects(): Promise<void> {
|
||
loading.value = true;
|
||
error.value = null;
|
||
apiError.value = null;
|
||
diagnostic.value = null;
|
||
try {
|
||
const [navigationResponse, projectResponse, sourceResponse, taskResponse, linkResponse] = await Promise.all([
|
||
projectManagementAPI.navigation(),
|
||
projectManagementAPI.projects(),
|
||
projectManagementAPI.sources(),
|
||
projectManagementAPI.tasks(),
|
||
projectManagementAPI.workbenchLinks()
|
||
]);
|
||
for (const response of [navigationResponse, projectResponse, sourceResponse, taskResponse, linkResponse]) {
|
||
if (!response.ok) throw response;
|
||
}
|
||
navigation.value = navigationResponse.data;
|
||
projects.value = projectResponse.data?.projects ?? [];
|
||
sources.value = sourceResponse.data?.sources ?? [];
|
||
tasks.value = taskResponse.data?.tasks ?? [];
|
||
links.value = linkResponse.data?.links ?? [];
|
||
} catch (err) {
|
||
const context = projectApiError(err);
|
||
error.value = context.error;
|
||
apiError.value = context.apiError;
|
||
diagnostic.value = context.diagnostic;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function projectTitle(project: ProjectRecord): string {
|
||
return project.name || project.title || project.projectId;
|
||
}
|
||
|
||
function projectSources(project: ProjectRecord): ProjectSource[] {
|
||
return (project.sourceIds ?? []).map((sourceId) => sourceById.value.get(sourceId)).filter((source): source is ProjectSource => Boolean(source));
|
||
}
|
||
|
||
function statusTone(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 displayCount(value?: number | null): string {
|
||
return Number.isFinite(value) ? String(value) : "0";
|
||
}
|
||
|
||
function taskStatusLabel(value?: string | null): string {
|
||
if (value === "done") return "完成";
|
||
if (value === "blocked") return "阻塞";
|
||
if (value === "open") return "待办";
|
||
return value || "未知";
|
||
}
|
||
|
||
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 || "项目管理加载失败"), apiError: null, diagnostic: null };
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section class="route-stack projects-page" data-testid="project-management-root">
|
||
<PageHeader eyebrow="Project Management" title="项目" description="查看项目、任务来源、最近任务和关联工作台摘要。" />
|
||
<LoadingState v-if="loading && !projects.length" label="加载项目管理投影" />
|
||
<section v-else-if="error" class="data-panel projects-error" data-testid="project-management-error">
|
||
<EmptyState title="项目管理加载失败" :description="error" :api-error="apiError" :diagnostic="diagnostic" action-label="重试" @action="loadProjects" />
|
||
</section>
|
||
<template v-else>
|
||
<section class="project-summary-grid" aria-label="项目管理摘要" data-testid="project-management-summary">
|
||
<article class="metric-card"><span>Projects</span><strong>{{ projects.length }}</strong><small>{{ navigation?.serviceId || 'hwlab-project-management' }}</small></article>
|
||
<article class="metric-card"><span>Sources</span><strong>{{ activeSourceCount }} / {{ sources.length }}</strong><small>{{ navigation?.navigation?.capabilities?.sourceOfTruth || 'markdown-files' }}</small></article>
|
||
<article class="metric-card"><span>Tasks</span><strong>{{ displayCount(projection?.taskCount ?? tasks.length) }}</strong><small>documents {{ displayCount(projection?.documentCount) }}</small></article>
|
||
<article class="metric-card"><span>Workbench Links</span><strong>{{ links.length }}</strong><small>{{ navigation?.navigation?.capabilities?.workbenchLinks ? 'link projection ready' : 'link projection pending' }}</small></article>
|
||
</section>
|
||
|
||
<section class="project-root-grid">
|
||
<section class="data-panel project-list-panel" data-testid="project-list">
|
||
<header class="project-panel-header">
|
||
<div><h2>项目列表</h2><p>由 `/v1/project-management/projects` 返回。</p></div>
|
||
<RouterLink class="btn btn-primary btn-sm" to="/projects/mdtodo" data-testid="open-mdtodo-page">打开 MDTODO</RouterLink>
|
||
</header>
|
||
<EmptyState v-if="!projects.length" title="暂无项目" description="项目管理服务已就绪,但当前 actor 没有可见项目。" />
|
||
<article v-for="project in projects" v-else :key="project.projectId" class="project-card" :data-project-id="project.projectId">
|
||
<div class="project-card-main">
|
||
<span class="pm-status" :data-tone="statusTone(project.status)">{{ project.status || 'unknown' }}</span>
|
||
<h3>{{ projectTitle(project) }}</h3>
|
||
<p>{{ project.projectId }}</p>
|
||
</div>
|
||
<div class="project-source-chips" aria-label="项目 source">
|
||
<span v-for="source in projectSources(project)" :key="source.sourceId" class="source-chip" :data-status="source.status || 'unknown'">{{ source.displayName || source.sourceId }}</span>
|
||
</div>
|
||
</article>
|
||
</section>
|
||
|
||
<section class="data-panel project-source-panel" data-testid="project-source-list">
|
||
<header class="project-panel-header"><div><h2>Source 状态</h2><p>显示当前可见 source 的摘要和投影状态。</p></div></header>
|
||
<ul v-if="sources.length" class="source-list">
|
||
<li v-for="source in sources" :key="source.sourceId">
|
||
<span class="pm-status" :data-tone="statusTone(source.status)">{{ source.status || 'unknown' }}</span>
|
||
<strong>{{ source.displayName || source.sourceId }}</strong>
|
||
<small>{{ source.kind || 'mdtodo' }} / {{ source.projectId || '-' }}</small>
|
||
</li>
|
||
</ul>
|
||
<EmptyState v-else title="暂无 source" description="当前 ProjectSource registry 没有可见 source。" />
|
||
</section>
|
||
</section>
|
||
|
||
<section class="project-root-grid lower">
|
||
<section class="data-panel project-task-panel" data-testid="project-recent-tasks">
|
||
<header class="project-panel-header"><div><h2>最近任务</h2><p>只展示任务投影摘要和 opaque taskRef 片段。</p></div></header>
|
||
<ul v-if="recentTasks.length" class="task-preview-list">
|
||
<li v-for="task in recentTasks" :key="task.taskRef">
|
||
<span class="task-status" :data-status="task.status || 'unknown'">{{ taskStatusLabel(task.status) }}</span>
|
||
<strong>{{ task.title || task.taskId || 'Untitled task' }}</strong>
|
||
<code>{{ task.taskRef }}</code>
|
||
</li>
|
||
</ul>
|
||
<EmptyState v-else title="暂无任务投影" description="MDTODO source 当前没有可展示任务,或尚未发现 Markdown TODO 文件。" />
|
||
</section>
|
||
<section class="data-panel project-link-panel" data-testid="project-workbench-links">
|
||
<header class="project-panel-header"><div><h2>Workbench Link</h2><p>P2 只展示 link 投影,启动动作在 P3 接入。</p></div></header>
|
||
<ul v-if="links.length" class="link-list">
|
||
<li v-for="link in links" :key="link.linkId"><strong>{{ link.sessionId || link.linkId }}</strong><small>{{ link.taskRef || link.projectId || '-' }}</small></li>
|
||
</ul>
|
||
<EmptyState v-else title="暂无 Workbench 关联" description="完成 P3 后,任务启动的 session 会在这里出现。" />
|
||
</section>
|
||
</section>
|
||
</template>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.projects-page { min-width: 0; }
|
||
.project-summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||
.project-summary-grid .metric-card strong { color: #0f172a; font-size: 28px; line-height: 1.15; }
|
||
.project-summary-grid .metric-card small { min-width: 0; overflow: hidden; color: #64748b; text-overflow: ellipsis; white-space: nowrap; }
|
||
.project-root-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr); gap: 12px; }
|
||
.project-root-grid.lower { grid-template-columns: minmax(0, 1fr) minmax(320px, 0.72fr); }
|
||
.project-list-panel,
|
||
.project-source-panel,
|
||
.project-task-panel,
|
||
.project-link-panel,
|
||
.projects-error { display: grid; min-width: 0; align-content: start; gap: 12px; padding: 14px; }
|
||
.project-panel-header { display: flex; min-width: 0; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||
.project-panel-header h2 { margin: 0; color: #0f172a; font-size: 16px; line-height: 1.2; }
|
||
.project-panel-header p { margin: 4px 0 0; color: #64748b; font-size: 12px; line-height: 1.45; }
|
||
.project-card { display: grid; gap: 10px; border: 1px solid #d8e1eb; border-radius: 8px; background: #f8fafc; padding: 12px; }
|
||
.project-card-main { display: grid; min-width: 0; gap: 6px; }
|
||
.project-card h3 { margin: 0; color: #0f172a; font-size: 18px; line-height: 1.2; }
|
||
.project-card p { margin: 0; color: #64748b; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
|
||
.project-source-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||
.source-chip,
|
||
.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; }
|
||
.source-chip { background: white; color: #334155; }
|
||
.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; }
|
||
.source-list,
|
||
.task-preview-list,
|
||
.link-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
||
.source-list li,
|
||
.task-preview-list li,
|
||
.link-list li { display: grid; min-width: 0; gap: 5px; border: 1px solid #e2e8f0; border-radius: 8px; background: #ffffff; padding: 10px; }
|
||
.source-list strong,
|
||
.task-preview-list strong,
|
||
.link-list strong { min-width: 0; overflow: hidden; color: #0f172a; text-overflow: ellipsis; white-space: nowrap; }
|
||
.source-list small,
|
||
.link-list small { min-width: 0; overflow: hidden; color: #64748b; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.task-preview-list code { min-width: 0; overflow: hidden; color: #475569; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.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; }
|
||
@media (max-width: 1180px) {
|
||
.project-summary-grid,
|
||
.project-root-grid,
|
||
.project-root-grid.lower { grid-template-columns: 1fr; }
|
||
}
|
||
</style>
|