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

214 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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>