Merge pull request #2143 from pikasTech/feat/2135-project-pages
feat: add project management pages
This commit is contained in:
@@ -226,6 +226,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
if (path === "/v1/live-builds") return json(response, 200, { status: "ok", builds: [] });
|
||||
if (path === "/v1/hwpod/specs") return json(response, 200, hwpodSpecsPayload());
|
||||
if (path === "/v1/hwpod-node-ops") return json(response, 200, hwpodNodeOpsPayload());
|
||||
if (path.startsWith("/v1/project-management")) return projectManagementPayload(response, url, method);
|
||||
if (path === "/v1/web-performance/summary") {
|
||||
if (state.scenarioId === "performance-summary-error") return errorDiagnosticResponse(response, 502, "/v1/web-performance/summary", "upstream_unavailable", "暂时无法连接上游。");
|
||||
const matrixStatus = performanceSummaryErrorStatus(state.scenarioId);
|
||||
@@ -284,6 +285,64 @@ function hwpodNodeOpsPayload(): JsonRecord {
|
||||
};
|
||||
}
|
||||
|
||||
function projectManagementPayload(response: ServerResponse, url: URL, method: string): void {
|
||||
if (method !== "GET" && method !== "HEAD") return json(response, 405, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "method_not_allowed" }, valuesRedacted: true });
|
||||
const path = url.pathname;
|
||||
const source = projectManagementSource();
|
||||
const files = projectManagementFiles();
|
||||
const tasks = projectManagementTasks();
|
||||
const links = projectManagementLinks();
|
||||
|
||||
if (path === "/v1/project-management" || path === "/v1/project-management/navigation") {
|
||||
return json(response, 200, projectManagementEnvelope({
|
||||
navigation: {
|
||||
id: "project-management",
|
||||
label: "Project Management",
|
||||
actor: { id: "usr_e2e_admin", role: "admin", authMethod: "web-session" },
|
||||
items: [
|
||||
{ id: "projects", label: "Projects", apiRoute: "/v1/project-management/projects" },
|
||||
{ id: "mdtodo", label: "MDTODO", apiRoute: "/v1/project-management/mdtodo/tasks" }
|
||||
],
|
||||
capabilities: { mdtodoProjection: true, workbenchLaunch: false, workbenchLinks: true, sourceOfTruth: "markdown-files" }
|
||||
},
|
||||
projection: { sourceId: source.sourceId, documentCount: files.length, taskCount: tasks.length, projectedAt: "2026-06-25T09:00:00.000Z" }
|
||||
}));
|
||||
}
|
||||
if (path === "/v1/project-management/projects") return json(response, 200, projectManagementEnvelope({ projects: [{ projectId: "project_hwlab_v03", name: "HWLAB MDTODO", sourceIds: [source.sourceId], sourceOfTruth: "markdown-files", status: "active" }] }));
|
||||
if (path === "/v1/project-management/mdtodo/sources") return json(response, 200, projectManagementEnvelope({ sources: [source] }));
|
||||
if (path === "/v1/project-management/mdtodo/files") return json(response, 200, projectManagementEnvelope({ files: files.filter((file) => !url.searchParams.get("sourceId") || file.sourceId === url.searchParams.get("sourceId")) }));
|
||||
if (path === "/v1/project-management/mdtodo/tasks") return json(response, 200, projectManagementEnvelope({ tasks: tasks.filter((task) => (!url.searchParams.get("sourceId") || task.sourceId === url.searchParams.get("sourceId")) && (!url.searchParams.get("fileRef") || task.fileRef === url.searchParams.get("fileRef"))) }));
|
||||
if (path === "/v1/project-management/workbench-links") return json(response, 200, projectManagementEnvelope({ links: links.filter((link) => !url.searchParams.get("taskRef") || link.taskRef === url.searchParams.get("taskRef")) }));
|
||||
return json(response, 404, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "not_found" }, valuesRedacted: true });
|
||||
}
|
||||
|
||||
function projectManagementEnvelope(payload: JsonRecord): JsonRecord {
|
||||
return { ok: true, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", ...payload, valuesRedacted: true };
|
||||
}
|
||||
|
||||
function projectManagementSource(): JsonRecord {
|
||||
return { sourceId: "hwlab-v03-mdtodo", kind: "mdtodo-file-tree", displayName: "HWLAB MDTODO", projectId: "project_hwlab_v03", status: "active", rootRef: "[redacted]", valuesRedacted: true };
|
||||
}
|
||||
|
||||
function projectManagementFiles(): JsonRecord[] {
|
||||
return [
|
||||
{ sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", projectId: "project_hwlab_v03", relativePath: "project-management/PJ2026-01/MDTODO.md", title: "项目管理任务", taskCount: 2, parseError: null, revision: 1, updatedAt: "2026-06-25T09:00:00.000Z" },
|
||||
{ sourceId: "hwlab-v03-mdtodo", fileRef: "file_rollout", projectId: "project_hwlab_v03", relativePath: "ops/D601-v03-rollout.md", title: "D601 发布任务", taskCount: 1, parseError: null, revision: 1, updatedAt: "2026-06-25T09:00:00.000Z" }
|
||||
];
|
||||
}
|
||||
|
||||
function projectManagementTasks(): JsonRecord[] {
|
||||
return [
|
||||
{ taskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:t1", projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "t1", title: "实现项目根导航", status: "done", parentTaskRef: null, depth: 0, lineNumber: 4, ordinal: 1, linkCount: 1, updatedAt: "2026-06-25T09:00:00.000Z" },
|
||||
{ taskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:t2", projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_plan", taskId: "t2", title: "展示 MDTODO 任务详情", status: "open", parentTaskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:t1", depth: 1, lineNumber: 5, ordinal: 2, linkCount: 0, updatedAt: "2026-06-25T09:00:00.000Z" },
|
||||
{ taskRef: "mdtodo:hwlab-v03-mdtodo:file_rollout:t1", projectId: "project_hwlab_v03", sourceId: "hwlab-v03-mdtodo", fileRef: "file_rollout", taskId: "t1", title: "D601 v03 页面验收", status: "blocked", parentTaskRef: null, depth: 0, lineNumber: 3, ordinal: 3, linkCount: 0, updatedAt: "2026-06-25T09:00:00.000Z" }
|
||||
];
|
||||
}
|
||||
|
||||
function projectManagementLinks(): JsonRecord[] {
|
||||
return [{ linkId: "lnk_project_task_root", projectId: "project_hwlab_v03", taskRef: "mdtodo:hwlab-v03-mdtodo:file_plan:t1", sessionId: "ses_project_seed", traceId: "trc_project_seed", role: "reference", updatedAt: "2026-06-25T09:00:00.000Z" }];
|
||||
}
|
||||
|
||||
function webPerformanceSummaryPayload(url: URL): JsonRecord {
|
||||
const sampleWindow = fakePerformanceWindow(url.searchParams.get("window"));
|
||||
const rows = [
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo.
|
||||
// Responsibility: Cloud Web API barrel, including project-management public API exports.
|
||||
|
||||
export { fetchJson, fetchText, type ActivityRef, type ActivityRefSource, type ApiRequestOptions } from "./client";
|
||||
export { authAPI, HWLAB_WEB_SESSION_COOKIE } from "./auth";
|
||||
export { workbenchAPI } from "./workbench";
|
||||
@@ -11,6 +14,7 @@ export { apiKeysAPI } from "./apiKeys";
|
||||
export { usageAPI } from "./usage";
|
||||
export { billingAPI } from "./billing";
|
||||
export { systemAPI, type SkillUploadFileInput } from "./system";
|
||||
export { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectWorkbenchLinkRecord } from "./projectManagement";
|
||||
|
||||
import { fetchJson } from "./client";
|
||||
import { accessAPI } from "./access";
|
||||
@@ -24,6 +28,7 @@ import { usageAPI } from "./usage";
|
||||
import { billingAPI } from "./billing";
|
||||
import { workbenchAPI } from "./workbench";
|
||||
import { systemAPI } from "./system";
|
||||
import { projectManagementAPI } from "./projectManagement";
|
||||
import type { ApiResult, LiveBuildsPayload, LiveProbePayload, SkillsResponse } from "@/types";
|
||||
|
||||
export const api = {
|
||||
@@ -37,6 +42,7 @@ export const api = {
|
||||
apiKeys: apiKeysAPI,
|
||||
usage: usageAPI,
|
||||
billing: billingAPI,
|
||||
projectManagement: projectManagementAPI,
|
||||
system: systemAPI,
|
||||
healthLive: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }),
|
||||
health: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }),
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo.
|
||||
// Responsibility: Cloud Web client for the public project-management same-origin API.
|
||||
|
||||
import { fetchJson } from "./client";
|
||||
import type { ApiResult } from "@/types";
|
||||
|
||||
export interface ProjectManagementEnvelope {
|
||||
contractVersion?: string;
|
||||
serviceId?: string;
|
||||
ok?: boolean;
|
||||
valuesRedacted?: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectNavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
apiRoute?: string;
|
||||
}
|
||||
|
||||
export interface ProjectNavigationCapabilities {
|
||||
mdtodoProjection?: boolean;
|
||||
workbenchLaunch?: boolean;
|
||||
workbenchLinks?: boolean;
|
||||
sourceOfTruth?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ProjectNavigation {
|
||||
id: string;
|
||||
label: string;
|
||||
actor?: { id?: string | null; role?: string | null; authMethod?: string | null } | null;
|
||||
items?: ProjectNavigationItem[];
|
||||
capabilities?: ProjectNavigationCapabilities;
|
||||
}
|
||||
|
||||
export interface ProjectProjectionSummary {
|
||||
sourceId?: string;
|
||||
documentCount?: number;
|
||||
taskCount?: number;
|
||||
projectedAt?: string;
|
||||
startedAt?: string;
|
||||
}
|
||||
|
||||
export interface ProjectNavigationResponse extends ProjectManagementEnvelope {
|
||||
navigation?: ProjectNavigation;
|
||||
projection?: ProjectProjectionSummary | null;
|
||||
}
|
||||
|
||||
export interface ProjectRecord {
|
||||
projectId: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
sourceIds?: string[];
|
||||
sourceOfTruth?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ProjectSource {
|
||||
sourceId: string;
|
||||
kind?: string;
|
||||
displayName?: string;
|
||||
projectId?: string;
|
||||
status?: string;
|
||||
updatedAt?: string;
|
||||
valuesRedacted?: boolean;
|
||||
}
|
||||
|
||||
export interface MdtodoFileRecord {
|
||||
sourceId: string;
|
||||
fileRef: string;
|
||||
projectId?: string;
|
||||
relativePath?: string;
|
||||
title?: string;
|
||||
fingerprint?: string;
|
||||
taskCount?: number;
|
||||
parseError?: string | null;
|
||||
revision?: number;
|
||||
lastIndexedAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface MdtodoTaskRecord {
|
||||
taskRef: string;
|
||||
projectId?: string;
|
||||
sourceId?: string;
|
||||
fileRef?: string;
|
||||
taskId?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
parentTaskRef?: string | null;
|
||||
depth?: number;
|
||||
lineNumber?: number;
|
||||
ordinal?: number;
|
||||
linkCount?: number;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ProjectWorkbenchLinkRecord {
|
||||
linkId: string;
|
||||
projectId?: string;
|
||||
taskRef?: string | null;
|
||||
sessionId?: string | null;
|
||||
traceId?: string | null;
|
||||
createdBy?: string | null;
|
||||
role?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ProjectListResponse extends ProjectManagementEnvelope { projects?: ProjectRecord[] }
|
||||
export interface ProjectSourceListResponse extends ProjectManagementEnvelope { sources?: ProjectSource[] }
|
||||
export interface MdtodoFileListResponse extends ProjectManagementEnvelope { files?: MdtodoFileRecord[] }
|
||||
export interface MdtodoTaskListResponse extends ProjectManagementEnvelope { tasks?: MdtodoTaskRecord[] }
|
||||
export interface ProjectWorkbenchLinkListResponse extends ProjectManagementEnvelope { links?: ProjectWorkbenchLinkRecord[] }
|
||||
|
||||
export interface MdtodoTaskQuery {
|
||||
sourceId?: string | null;
|
||||
fileRef?: string | null;
|
||||
projectId?: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectWorkbenchLinkQuery {
|
||||
taskRef?: string | null;
|
||||
projectId?: string | null;
|
||||
sessionId?: string | null;
|
||||
}
|
||||
|
||||
export const projectManagementAPI = {
|
||||
navigation: (): Promise<ApiResult<ProjectNavigationResponse>> => fetchJson("/v1/project-management/navigation", { timeoutMs: 12000, timeoutName: "project navigation" }),
|
||||
projects: (): Promise<ApiResult<ProjectListResponse>> => fetchJson("/v1/project-management/projects", { timeoutMs: 12000, timeoutName: "project list" }),
|
||||
sources: (): Promise<ApiResult<ProjectSourceListResponse>> => fetchJson("/v1/project-management/mdtodo/sources", { timeoutMs: 12000, timeoutName: "project mdtodo sources" }),
|
||||
files: (sourceId?: string | null): Promise<ApiResult<MdtodoFileListResponse>> => fetchJson(`/v1/project-management/mdtodo/files${queryString({ sourceId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo files" }),
|
||||
tasks: (query: MdtodoTaskQuery = {}): Promise<ApiResult<MdtodoTaskListResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks${queryString({ sourceId: query.sourceId, fileRef: query.fileRef, projectId: query.projectId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo tasks" }),
|
||||
workbenchLinks: (query: ProjectWorkbenchLinkQuery = {}): Promise<ApiResult<ProjectWorkbenchLinkListResponse>> => fetchJson(`/v1/project-management/workbench-links${queryString({ taskRef: query.taskRef, projectId: query.projectId, sessionId: query.sessionId })}`, { timeoutMs: 12000, timeoutName: "project workbench links" })
|
||||
};
|
||||
|
||||
function queryString(values: Record<string, string | null | undefined>): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value) params.set(key, value);
|
||||
}
|
||||
const text = params.toString();
|
||||
return text ? `?${text}` : "";
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
<!-- SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo. -->
|
||||
<!-- Responsibility: App shell navigation entry for project-management pages without Workbench embedding. -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
@@ -17,6 +20,7 @@ const diagnosticsOpen = ref(false);
|
||||
|
||||
const navSections = [
|
||||
{ title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D" }] },
|
||||
{ title: "项目", items: [{ name: "Projects", label: "项目", path: "/projects", icon: "P" }, { name: "ProjectMdtodo", label: "MDTODO", path: "/projects/mdtodo", icon: "M" }] },
|
||||
{ title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys", icon: "K" }, { name: "Usage", label: "用量", path: "/usage", icon: "U" }, { name: "Billing", label: "充值", path: "/billing", icon: "$" }] },
|
||||
{ title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "A" }, { name: "Users", label: "用户", path: "/admin/users", icon: "P" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "B" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "H" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles", icon: "R" }] },
|
||||
{ title: "系统", items: [{ name: "Gate", label: "Gate", path: "/gate", icon: "G" }, { name: "Performance", label: "性能", path: "/performance", icon: "M" }, { name: "Skills", label: "Skills", path: "/skills", icon: "S" }, { name: "Settings", label: "设置", path: "/settings", icon: "T" }, { name: "Help", label: "帮助", path: "/help", icon: "?" }] }
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo.
|
||||
// Responsibility: Cloud Web routes, including project-management pages under /projects.
|
||||
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
|
||||
import { installRouterGuards } from "./guards";
|
||||
|
||||
const CodeWorkbenchView = () => import("@/views/workbench/CodeWorkbenchView.vue");
|
||||
const ProjectsView = () => import("@/views/projects/ProjectsView.vue");
|
||||
const ProjectMdtodoView = () => import("@/views/projects/MdtodoView.vue");
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: "/", redirect: "/workbench" },
|
||||
@@ -10,6 +15,8 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: "/dashboard", name: "Dashboard", component: () => import("@/views/user/DashboardView.vue"), meta: { requiresAuth: true, title: "平台概览", section: "user" } },
|
||||
{ path: "/workbench/sessions/:sessionId", alias: ["/workspace/sessions/:sessionId"], name: "CodeWorkbenchSession", component: CodeWorkbenchView, meta: { requiresAuth: true, title: "Code 工作台", section: "workbench" } },
|
||||
{ path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: CodeWorkbenchView, meta: { requiresAuth: true, title: "Code 工作台", section: "workbench" } },
|
||||
{ path: "/projects", name: "Projects", component: ProjectsView, meta: { requiresAuth: true, title: "项目", section: "project" } },
|
||||
{ path: "/projects/mdtodo", name: "ProjectMdtodo", component: ProjectMdtodoView, meta: { requiresAuth: true, title: "MDTODO", section: "project" } },
|
||||
{ path: "/api-keys", name: "ApiKeys", component: () => import("@/views/user/ApiKeysView.vue"), meta: { requiresAuth: true, title: "API Keys", section: "user" } },
|
||||
{ path: "/usage", name: "Usage", component: () => import("@/views/user/UsageView.vue"), meta: { requiresAuth: true, title: "用量与性能", section: "user" } },
|
||||
{ path: "/billing", name: "Billing", component: () => import("@/views/user/BillingView.vue"), meta: { requiresAuth: true, title: "充值与订阅", section: "user" } },
|
||||
|
||||
+4
-1
@@ -1,3 +1,6 @@
|
||||
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo.
|
||||
// Responsibility: Router metadata sections for Cloud Web navigation, including project management.
|
||||
|
||||
import "vue-router";
|
||||
|
||||
declare module "vue-router" {
|
||||
@@ -5,6 +8,6 @@ declare module "vue-router" {
|
||||
requiresAuth?: boolean;
|
||||
requiresAdmin?: boolean;
|
||||
title?: string;
|
||||
section?: "workbench" | "admin" | "user" | "system";
|
||||
section?: "workbench" | "project" | "admin" | "user" | "system";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
<!-- 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>
|
||||
@@ -0,0 +1,213 @@
|
||||
<!-- 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>
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo.
|
||||
// Responsibility: Fake-server browser regression for project root and MDTODO pages.
|
||||
|
||||
import { expect, saveScreenshot, test } from "../fixtures/test";
|
||||
|
||||
test.describe("project management pages", () => {
|
||||
test("renders project root and MDTODO DTOs without Workbench embedding", async ({ page }, testInfo) => {
|
||||
await page.goto("/projects");
|
||||
await expect(page.getByTestId("project-management-root")).toBeVisible();
|
||||
await expect(page.getByTestId("project-management-summary")).toContainText("Projects");
|
||||
await expect(page.getByTestId("project-list")).toContainText("HWLAB MDTODO");
|
||||
await expect(page.getByTestId("project-source-list")).toContainText("mdtodo-file-tree");
|
||||
await expect(page.getByText("[redacted]")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("open-mdtodo-page").click();
|
||||
await expect(page).toHaveURL(/\/projects\/mdtodo/u);
|
||||
await expect(page.getByTestId("project-management-mdtodo")).toBeVisible();
|
||||
await expect(page.getByTestId("mdtodo-source-list")).toContainText("HWLAB MDTODO");
|
||||
await expect(page.getByTestId("mdtodo-file-list")).toContainText("项目管理任务");
|
||||
await expect(page.getByTestId("mdtodo-task-tree")).toContainText("实现项目根导航");
|
||||
await expect(page.getByTestId("mdtodo-task-detail")).toContainText("mdtodo:hwlab-v03-mdtodo:file_plan:t1");
|
||||
await expect(page.getByTestId("mdtodo-workbench-link-summary")).toContainText("ses_project_seed");
|
||||
await expect(page.getByTestId("mdtodo-workbench-launch")).toBeDisabled();
|
||||
await expect(page.locator("iframe")).toHaveCount(0);
|
||||
await saveScreenshot(page, testInfo, "project-management-mdtodo");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user