feat(frontend): upgrade decision center workbench
This commit is contained in:
@@ -96,6 +96,7 @@ frontend shell 必须把左侧主模块与顶部子标签编译为统一的 URL
|
||||
- `Baidu Netdisk` 子标签必须把主 server `baidu-netdisk-backend` 后端渲染为 UniDesk React 控件,包括 OAuth 设备码二维码/用户码登录、账号容量、配置工作根文件浏览(当前默认百度网盘根目录 `/`)、staging 目录上传/下载任务、上传/下载自测按钮与 MD5 结果、脱敏安全说明、日志摘要和显式原始 JSON 按钮;不得把 access token、refresh token、dlink 或 staging 文件字节流裸露到浏览器。
|
||||
- `OA Event Flow` 子标签必须把主 server `oa-event-flow-backend` 后端渲染为 UniDesk React 控件,包括服务健康、事件表、tag 过滤、SSE live 状态、Trace/STEP stats 表、Code Queue/Pipeline 标签入口和显式原始 JSON 按钮;默认页面不得裸铺完整事件 JSON,事件表只展示结构化摘要,完整 envelope/payload 只能通过 `查看原始JSON` 打开。
|
||||
- `k3s Control` 子标签必须把 D601 `k3sctl-adapter` 控制面渲染为 UniDesk React 控件,包括 control plane 状态、manifest 列表、D601 scheduler/read/write 实例、active instance、single-writer/no-fallback 路径、Kubernetes API service proxy 状态、kubectl/k3s snapshot 摘要和显式原始 JSON 按钮;页面只能通过 `/api/microservices/k3sctl-adapter/proxy/api/control-plane` 取数,不得直接访问 provider-gateway、NodePort、业务容器端口或裸 k3s/kubectl API。
|
||||
- `Decision Center` 子标签必须把 D601 `decision-center` 用户服务渲染为需求管理与工作日记工作台。默认 `需求管理` 视图必须是一等工作区,结构化展示并录入外部目标、内部目标、阻塞、停放事项、决议、实验和债务,提供类型/状态/等级/关联目标筛选、记录编辑器、记录表和显式原始 JSON 按钮;默认页面不得裸铺 JSON。`工作日记` 视图必须提供“今天”按钮,使用浏览器当前日期生成 `YYYY-MM-DD`,自动打开或创建当天 Markdown 日记,允许编辑历史日记 Markdown 并通过 `/api/microservices/decision-center/proxy/api/diary/import` 保存到 PostgreSQL;完整日记 JSON 只能通过 `查看原始JSON` 打开。页面不得提供聊天/LLM 会话窗口。
|
||||
- `Code Queue` 子标签必须把稳定 `code-queue` 用户服务渲染为 UniDesk React 控件,前端 API 基址只能是 `/api/microservices/code-queue/proxy`,不能继续使用旧 `/api/code-queue-direct` 别名;backend-core 会把 queue CRUD、submit、history、readAt 和轻量 Trace 读取分流到主 server `code-queue-mgr`,把 active run steer/interrupt、judge、dev-container 和执行面健康分流到 D601 k3s/k8s Code Queue 执行面。页面包括多 queue lane、queue 内串行、queue 间并行、queue 合并(点击“合并 queue”后必须用公共 `UniDeskDialog` 打开独立小窗口,用下拉菜单选择源 queue;不得把源 queue 选择控件塞进正常提交任务的 Queue 选择区;合并后自动删除源 queue,只保留合并后的目标 queue,目标 queue 按原 queueEnteredAt/createdAt 时间顺序串行)、任务 ID/复制任务 ID、引用按钮、任务耗时、任务提交/批量提交、引用任务 ID、创建成功提示、清空输入、模型下拉、执行 Provider 下拉、执行模式下拉(默认容器/本机或 `windows-native`)、显式入队份数、默认模型 `gpt-5.5`、MiniMax judge 状态、Codex CLI-like 输出流、attempt 终态、运行中追加 prompt、打断、手动重试和显式原始 JSON 按钮;`windows-native` 模式必须在任务 JSON、卡片和 Trace 头部显示,并要求非本机 WSL Provider 与 `/mnt/<drive>` 工作目录;Codex CLI-like 输出流必须始终保留任务的初始 `Submitted prompt` 和运行中 `Steer prompt`;整个 agent loop 消息流统一命名为专有名词 `Trace`,`Trace` 包含 assistant message、user prompt、system event 和 tool call,但非错误 system event 默认只保留在原始输出/数据库中,不在 TraceView 展示;Code Queue 与 Pipeline/OpenCode messages 必须共用 `src/components/frontend/src/trace.tsx` 的 Trace 公共组件、统一 Trace item 接口和 codex/opencode port 适配层;连续 read/edit/run 工具调用只是在 Trace 内折叠为可展开工具调用组,汇总格式至少包含 `xx read, xx edit, xx run`,并展示读取文件、编辑文件、运行命令和耗时摘要;最近 3 个工具调用保持展开,工具调用内容不得自动换行且必须在工具调用块内部横向滚动,工具调用组展开后不得再增加额外左侧缩进;message 与 prompt 必须自动换行,普通 message 不显示左侧项目符号缩进且永不折叠;Trace 首屏可以是摘要预览,但终态任务被选中后必须自动在后台加载完整 Trace,手动“加载完整 Trace”也必须从 Code Queue output archive 分页补齐早期 trace,不得把 preview 的 `hasMore=false` 当成完整历史;即使热状态为控制体积裁剪了早期 raw output,也要从结构化 `basePrompt/displayPrompt/promptHistory` 和 archive 合成完整用户输入与 agent trace,并且初始 prompt 默认显示注入前 prompt 而不是引用注入全文;当初始 prompt 含引用注入时,引用内容必须默认折叠,并只在 Trace 的初始消息中提供可展开的“最终传入 Codex 的真实完整 prompt”,不得再渲染独立 Prompt 全量卡片;多轮引用注入必须按上游/最早上下文在前、直接引用在后的顺序排列,每一轮必须有明确 `Reference Round N/M` 分割线和时间范围,不能用固定 6 轮截断引用链;点击队列引用按钮必须自动把该任务 ID 写入提交表单的引用输入框,引用任务 ID 创建新任务时必须自动注入 `bun scripts/cli.ts codex task <taskId>` 的提示;连续执行同一 prompt 应通过入队份数一次性生成多条任务,避免快速连点造成操作员误判。
|
||||
- `MDTODO` 子标签必须把 D601 k3s `mdtodo` Service 渲染为 UniDesk React 控件,前端 API 基址只能是 `/api/microservices/mdtodo/proxy`;页面包括 TODO Markdown 文件列表、任务树、状态徽标、标题与正文编辑、新增根任务/子任务、删除任务、执行命令生成、hostPath 健康摘要和显式原始 JSON 按钮,不得 iframe 原 VS Code webview、公开 VSIX 旧前端或把完整 Markdown/JSON 默认铺在页面上。
|
||||
- `Code Queue` 前端改进必须在同一任务内重建并上线公网 frontend,不能只修改源码或本地 bundle;重建 frontend 是无状态 WebUI 替换,不会导致 Code Queue 长期任务失败。已结束未读任务只能在 task card 边角显示类似未读消息的 `codex-unread-badge` 圆点和“标为已读”操作,不得把整张卡片改成红色/琥珀色失败态边框、背景或胶囊标签;状态栏的“结束未读”提示也不得使用失败态红色。
|
||||
|
||||
@@ -6833,6 +6833,92 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.requirement-workspace {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.requirement-switcher,
|
||||
.editor-mode-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.requirement-switcher button,
|
||||
.editor-mode-grid button {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line-soft);
|
||||
color: var(--muted);
|
||||
background: rgba(0,0,0,0.18);
|
||||
}
|
||||
.requirement-switcher button.active,
|
||||
.requirement-switcher button:hover,
|
||||
.editor-mode-grid button.active,
|
||||
.editor-mode-grid button:hover {
|
||||
border-color: rgba(215, 161, 58, 0.56);
|
||||
color: var(--text);
|
||||
background: rgba(215, 161, 58, 0.12);
|
||||
}
|
||||
.requirement-lanes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(210px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.requirement-lane {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 210px;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(0,0,0,0.13);
|
||||
}
|
||||
.requirement-lane-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
.requirement-lane-head strong {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.record-editor-form,
|
||||
.diary-editor-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.decision-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.2fr) repeat(3, minmax(120px, 0.6fr));
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
.decision-form-grid label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.decision-form-grid textarea {
|
||||
min-height: 140px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.diary-editor-grid {
|
||||
grid-template-columns: 150px minmax(220px, 1fr) minmax(180px, 0.6fr) minmax(150px, 0.5fr);
|
||||
}
|
||||
.diary-editor-grid textarea {
|
||||
min-height: 220px;
|
||||
}
|
||||
.decision-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -6997,9 +7083,13 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.decision-hero,
|
||||
.decision-filter-bar,
|
||||
.decision-default-grid,
|
||||
.decision-form-grid,
|
||||
.diary-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.requirement-lanes {
|
||||
grid-template-columns: repeat(5, minmax(240px, 1fr));
|
||||
}
|
||||
.diary-entry-list,
|
||||
.diary-markdown {
|
||||
max-height: none;
|
||||
|
||||
@@ -14,6 +14,24 @@ const useState: any = React.useState;
|
||||
const recordTypes = ["all", "meeting", "decision", "goal", "blocker", "debt", "experiment"];
|
||||
const recordLevels = ["all", "G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"];
|
||||
const recordStatuses = ["all", "active", "blocked", "parked", "done"];
|
||||
const requirementViews = [
|
||||
{ id: "all", label: "全部需求" },
|
||||
{ id: "external-goal", label: "外部目标" },
|
||||
{ id: "internal-goal", label: "内部目标" },
|
||||
{ id: "blocker", label: "阻塞" },
|
||||
{ id: "parked", label: "停放事项" },
|
||||
{ id: "authority", label: "决议/实验/债务" },
|
||||
];
|
||||
const recordCategories = [
|
||||
{ id: "external-goal", label: "外部目标", type: "goal", level: "G0", status: "active", tags: ["external-goal", "requirement"] },
|
||||
{ id: "internal-goal", label: "内部目标", type: "goal", level: "G1", status: "active", tags: ["internal-goal", "requirement"] },
|
||||
{ id: "blocker", label: "阻塞", type: "blocker", level: "P0", status: "blocked", tags: ["blocker", "requirement"] },
|
||||
{ id: "parked", label: "停放事项", type: "goal", level: "G3", status: "parked", tags: ["parked", "requirement"] },
|
||||
{ id: "decision", label: "决议", type: "decision", level: "none", status: "active", tags: ["decision"] },
|
||||
{ id: "experiment", label: "实验", type: "experiment", level: "G2", status: "active", tags: ["experiment"] },
|
||||
{ id: "debt", label: "债务", type: "debt", level: "P2", status: "active", tags: ["debt"] },
|
||||
];
|
||||
const defaultDiarySourceFile = "frontend-work-diary.md";
|
||||
|
||||
function StatusBadge({ status, children }: AnyRecord) {
|
||||
const normalized = String(status || "unknown").toLowerCase();
|
||||
@@ -84,6 +102,13 @@ function statusTone(status: string): string {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function browserDateOnly(value = new Date()): string {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function fmtRecordTime(value: any): string {
|
||||
return fmtDate(value) || "--";
|
||||
}
|
||||
@@ -93,10 +118,118 @@ function shortText(value: any, max = 220): string {
|
||||
return text.length > max ? `${text.slice(0, max - 1)}...` : text;
|
||||
}
|
||||
|
||||
function RecordCard({ record, onRaw, compact }: AnyRecord) {
|
||||
function stableTestId(value: any): string {
|
||||
return String(value || "").replace(/[^A-Za-z0-9_-]+/g, "-") || "item";
|
||||
}
|
||||
|
||||
function parseCsv(value: any): string[] {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
function tagsText(value: any): string {
|
||||
return Array.isArray(value) ? value.join(", ") : String(value || "");
|
||||
}
|
||||
|
||||
function recordFormFromCategory(categoryId: string): AnyRecord {
|
||||
const category = recordCategories.find((item) => item.id === categoryId) || recordCategories[0];
|
||||
return {
|
||||
id: "",
|
||||
category: category?.id || "external-goal",
|
||||
type: category?.type || "goal",
|
||||
level: category?.level || "G0",
|
||||
status: category?.status || "active",
|
||||
title: "",
|
||||
body: "",
|
||||
linkedGoalId: "",
|
||||
tags: tagsText(category?.tags || []),
|
||||
evidenceLinks: "",
|
||||
sourceSession: "frontend",
|
||||
taskId: "",
|
||||
commitId: "",
|
||||
};
|
||||
}
|
||||
|
||||
function recordFormFromRecord(record: any): AnyRecord {
|
||||
return {
|
||||
id: record?.id || "",
|
||||
category: "custom",
|
||||
type: record?.type || "meeting",
|
||||
level: record?.level || "none",
|
||||
status: record?.status || "active",
|
||||
title: record?.title || "",
|
||||
body: record?.body || record?.summary || "",
|
||||
linkedGoalId: record?.linkedGoalId || "",
|
||||
tags: tagsText(record?.tags),
|
||||
evidenceLinks: tagsText(record?.evidenceLinks),
|
||||
sourceSession: record?.sourceSession || "frontend",
|
||||
taskId: record?.taskId || "",
|
||||
commitId: record?.commitId || "",
|
||||
};
|
||||
}
|
||||
|
||||
function recordPayloadFromForm(form: AnyRecord): AnyRecord {
|
||||
return {
|
||||
type: form.type,
|
||||
level: form.level,
|
||||
status: form.status,
|
||||
title: String(form.title || "").trim(),
|
||||
body: String(form.body || "").trim(),
|
||||
linkedGoalId: String(form.linkedGoalId || "").trim(),
|
||||
tags: parseCsv(form.tags),
|
||||
evidenceLinks: parseCsv(form.evidenceLinks),
|
||||
sourceSession: String(form.sourceSession || "").trim(),
|
||||
taskId: String(form.taskId || "").trim(),
|
||||
commitId: String(form.commitId || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function isExternalGoal(record: any): boolean {
|
||||
const tags = Array.isArray(record.tags) ? record.tags.map((tag: string) => tag.toLowerCase()) : [];
|
||||
return record.type === "goal" && record.status !== "blocked" && record.status !== "parked" && (record.level === "G0" || tags.includes("external-goal"));
|
||||
}
|
||||
|
||||
function isInternalGoal(record: any): boolean {
|
||||
const tags = Array.isArray(record.tags) ? record.tags.map((tag: string) => tag.toLowerCase()) : [];
|
||||
return record.type === "goal" && record.status !== "blocked" && record.status !== "parked" && !isExternalGoal(record) && (["G1", "G2", "G3"].includes(record.level) || tags.includes("internal-goal"));
|
||||
}
|
||||
|
||||
function recordMatchesRequirementView(record: any, view: string): boolean {
|
||||
if (view === "external-goal") return isExternalGoal(record);
|
||||
if (view === "internal-goal") return isInternalGoal(record);
|
||||
if (view === "blocker") return record.type === "blocker" || record.status === "blocked";
|
||||
if (view === "parked") return record.status === "parked";
|
||||
if (view === "authority") return ["decision", "experiment", "debt"].includes(record.type);
|
||||
return true;
|
||||
}
|
||||
|
||||
function diaryFormFromEntry(entry: any): AnyRecord {
|
||||
const date = entry?.date || browserDateOnly();
|
||||
return {
|
||||
date,
|
||||
title: entry?.title || `${date} 工作日记`,
|
||||
body: entry?.body || "",
|
||||
sourceFile: entry?.sourceFile || defaultDiarySourceFile,
|
||||
tags: tagsText(entry?.tags || ["frontend"]),
|
||||
};
|
||||
}
|
||||
|
||||
function diaryImportMarkdown(form: AnyRecord): string {
|
||||
const date = String(form.date || browserDateOnly()).trim();
|
||||
const title = String(form.title || `${date} 工作日记`).trim();
|
||||
const body = String(form.body || "").trim();
|
||||
const firstLine = body.split("\n")[0]?.trim() || "";
|
||||
if (firstLine === `# ${date}` || firstLine.startsWith(`# ${date} `) || firstLine === `# ${title}`) return body;
|
||||
return `# ${date}\n\n${body || `## ${title}\n`}`.trim();
|
||||
}
|
||||
|
||||
function RecordCard({ record, onRaw, compact, onEdit }: AnyRecord) {
|
||||
const tags = Array.isArray(record.tags) ? record.tags : [];
|
||||
const evidence = Array.isArray(record.evidenceLinks) ? record.evidenceLinks : [];
|
||||
return h("article", { className: `decision-record-card ${compact ? "compact" : ""}`, "data-testid": `decision-record-${String(record.id || "").replace(/[^A-Za-z0-9_-]+/g, "-")}` },
|
||||
return h("article", { className: `decision-record-card ${compact ? "compact" : ""}`, "data-testid": `decision-record-${stableTestId(record.id)}` },
|
||||
h("div", { className: "decision-record-head" },
|
||||
h("div", null,
|
||||
h("div", { className: "decision-record-meta" },
|
||||
@@ -106,7 +239,10 @@ function RecordCard({ record, onRaw, compact }: AnyRecord) {
|
||||
),
|
||||
h("strong", null, record.title || "--"),
|
||||
),
|
||||
h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw }),
|
||||
h("div", { className: "inline-actions" },
|
||||
onEdit ? h("button", { type: "button", className: "ghost-btn", onClick: () => onEdit(record), "data-testid": `edit-record-${stableTestId(record.id)}` }, "编辑") : null,
|
||||
h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw }),
|
||||
),
|
||||
),
|
||||
compact
|
||||
? h("p", { className: "decision-summary" }, shortText(record.summary || record.body))
|
||||
@@ -122,7 +258,7 @@ function RecordCard({ record, onRaw, compact }: AnyRecord) {
|
||||
);
|
||||
}
|
||||
|
||||
function RecordTable({ records, onRaw }: AnyRecord) {
|
||||
function RecordTable({ records, onRaw, onEdit }: AnyRecord) {
|
||||
if (!records.length) return h(EmptyState, { title: "暂无记录", text: "通过 CLI 上传会议记录或决议后会显示在这里。" });
|
||||
return h("div", { className: "table-wrap" },
|
||||
h("table", { className: "decision-table", "data-testid": "decision-center-record-table" },
|
||||
@@ -144,7 +280,10 @@ function RecordTable({ records, onRaw }: AnyRecord) {
|
||||
h("td", null, shortText(record.summary || record.body, 180)),
|
||||
h("td", null, Array.isArray(record.evidenceLinks) ? record.evidenceLinks.length : 0),
|
||||
h("td", null, fmtRecordTime(record.updatedAt)),
|
||||
h("td", null, h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw })),
|
||||
h("td", null, h("div", { className: "inline-actions" },
|
||||
onEdit ? h("button", { type: "button", className: "ghost-btn", onClick: () => onEdit(record), "data-testid": `table-edit-record-${stableTestId(record.id)}` }, "编辑") : null,
|
||||
h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw }),
|
||||
)),
|
||||
))),
|
||||
),
|
||||
);
|
||||
@@ -174,7 +313,7 @@ function diaryQuery(filters: AnyRecord): string {
|
||||
}
|
||||
|
||||
function DiaryEntryCard({ entry, selected, onSelect, onRaw }: AnyRecord) {
|
||||
return h("article", { className: `diary-entry-card ${selected ? "selected" : ""}`, "data-testid": `diary-entry-${String(entry.date || entry.id || "").replace(/[^A-Za-z0-9_-]+/g, "-")}` },
|
||||
return h("article", { className: `diary-entry-card ${selected ? "selected" : ""}`, "data-testid": `diary-entry-${stableTestId(entry.date || entry.id)}` },
|
||||
h("button", { type: "button", className: "diary-entry-main", onClick: () => onSelect(entry) },
|
||||
h("span", { className: "diary-date" }, entry.date || "--"),
|
||||
h("strong", null, entry.title || entry.markdownPath || "--"),
|
||||
@@ -188,13 +327,108 @@ function DiaryEntryCard({ entry, selected, onSelect, onRaw }: AnyRecord) {
|
||||
);
|
||||
}
|
||||
|
||||
function RequirementBoard({ records, activeView, onView, onEdit, onRaw }: AnyRecord) {
|
||||
const groups = [
|
||||
{ id: "external-goal", title: "外部目标", eyebrow: "G0 / user-facing", records: records.filter(isExternalGoal) },
|
||||
{ id: "internal-goal", title: "内部目标", eyebrow: "G1-G3 / delivery", records: records.filter(isInternalGoal) },
|
||||
{ id: "blocker", title: "阻塞", eyebrow: "P0-P1 / blocked", records: records.filter((record: any) => record.type === "blocker" || record.status === "blocked") },
|
||||
{ id: "parked", title: "停放事项", eyebrow: "Parked", records: records.filter((record: any) => record.status === "parked") },
|
||||
{ id: "authority", title: "决议/实验/债务", eyebrow: "Decision / Experiment / Debt", records: records.filter((record: any) => ["decision", "experiment", "debt"].includes(record.type)) },
|
||||
];
|
||||
return h("div", { className: "requirement-workspace", "data-testid": "requirement-workspace" },
|
||||
h("div", { className: "requirement-switcher", role: "tablist", "aria-label": "需求视图" },
|
||||
requirementViews.map((item) => h("button", {
|
||||
key: item.id,
|
||||
type: "button",
|
||||
className: activeView === item.id ? "active" : "",
|
||||
onClick: () => onView(item.id),
|
||||
"data-testid": `requirement-filter-${item.id}`,
|
||||
}, item.label)),
|
||||
),
|
||||
h("div", { className: "requirement-lanes" },
|
||||
groups.map((group) => {
|
||||
const visibleRecords = group.records.filter((record: any) => recordMatchesRequirementView(record, activeView)).slice(0, 10);
|
||||
return h("section", { key: group.id, className: "requirement-lane", "data-testid": `requirement-lane-${group.id}` },
|
||||
h("div", { className: "requirement-lane-head" },
|
||||
h("div", null,
|
||||
h("p", { className: "panel-eyebrow" }, group.eyebrow),
|
||||
h("strong", null, group.title),
|
||||
),
|
||||
h(StatusBadge, { status: group.records.length > 0 ? "unknown" : "warn" }, group.records.length),
|
||||
),
|
||||
visibleRecords.length === 0
|
||||
? h(EmptyState, { title: "暂无条目", text: "用右侧录入台补齐该类需求或筛选条件。" })
|
||||
: h("div", { className: "decision-card-list" }, visibleRecords.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, onEdit, compact: true }))),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function RecordEditor({ form, saving, message, error, onChange, onCategory, onSubmit, onReset }: AnyRecord) {
|
||||
return h("form", { className: "record-editor-form", onSubmit, "data-testid": "record-editor" },
|
||||
h("div", { className: "editor-mode-grid" },
|
||||
recordCategories.map((item) => h("button", {
|
||||
key: item.id,
|
||||
type: "button",
|
||||
className: form.category === item.id ? "active" : "",
|
||||
onClick: () => onCategory(item.id),
|
||||
"data-testid": `record-template-${item.id}`,
|
||||
}, item.label)),
|
||||
),
|
||||
h("div", { className: "decision-form-grid" },
|
||||
h("label", null, "标题", h("input", { value: form.title, onChange: (event: any) => onChange("title", event.target.value), placeholder: "需求、阻塞、决议或实验标题", "data-testid": "record-title-input" })),
|
||||
h("label", null, "类型", h("select", { value: form.type, onChange: (event: any) => onChange("type", event.target.value), "data-testid": "record-type-select" }, selectOptions(recordTypes.filter((item) => item !== "all")))),
|
||||
h("label", null, "等级", h("select", { value: form.level, onChange: (event: any) => onChange("level", event.target.value), "data-testid": "record-level-select" }, selectOptions(recordLevels.filter((item) => item !== "all")))),
|
||||
h("label", null, "状态", h("select", { value: form.status, onChange: (event: any) => onChange("status", event.target.value), "data-testid": "record-status-select" }, selectOptions(recordStatuses.filter((item) => item !== "all")))),
|
||||
h("label", null, "Linked Goal", h("input", { value: form.linkedGoalId, onChange: (event: any) => onChange("linkedGoalId", event.target.value), placeholder: "关联目标 id" })),
|
||||
h("label", null, "Tags", h("input", { value: form.tags, onChange: (event: any) => onChange("tags", event.target.value), placeholder: "external-goal, requirement" })),
|
||||
h("label", { className: "wide" }, "正文 Markdown", h("textarea", { value: form.body, onChange: (event: any) => onChange("body", event.target.value), placeholder: "- 背景\n- 验收标准\n- 下一步", "data-testid": "record-body-editor" })),
|
||||
h("label", null, "证据链接", h("input", { value: form.evidenceLinks, onChange: (event: any) => onChange("evidenceLinks", event.target.value), placeholder: "逗号分隔 URL" })),
|
||||
h("label", null, "Task ID", h("input", { value: form.taskId, onChange: (event: any) => onChange("taskId", event.target.value), placeholder: "可选" })),
|
||||
h("label", null, "Commit ID", h("input", { value: form.commitId, onChange: (event: any) => onChange("commitId", event.target.value), placeholder: "可选" })),
|
||||
),
|
||||
h("div", { className: "dispatch-actions" },
|
||||
h("button", { type: "submit", disabled: saving || !String(form.title || "").trim(), "data-testid": "save-record-button" }, saving ? "保存中" : form.id ? "保存记录" : "创建记录"),
|
||||
h("button", { type: "button", className: "ghost-btn", disabled: saving, onClick: onReset }, "新建"),
|
||||
form.id ? h("code", null, form.id) : null,
|
||||
),
|
||||
message ? h("p", { className: "muted paragraph", "data-testid": "record-editor-message" }, message) : null,
|
||||
h(UniDeskErrorBanner, { error, title: "记录保存失败", wide: true }),
|
||||
);
|
||||
}
|
||||
|
||||
function DiaryEditor({ form, saving, message, error, onChange, onToday, onSubmit }: AnyRecord) {
|
||||
return h("form", { className: "diary-editor-form", onSubmit, "data-testid": "diary-editor" },
|
||||
h("div", { className: "decision-form-grid diary-editor-grid" },
|
||||
h("label", null, "日期", h("input", { type: "date", value: form.date, onChange: (event: any) => onChange("date", event.target.value), "data-testid": "diary-date-input" })),
|
||||
h("label", null, "标题", h("input", { value: form.title, onChange: (event: any) => onChange("title", event.target.value), placeholder: "工作日记标题" })),
|
||||
h("label", null, "Source File", h("input", { value: form.sourceFile, onChange: (event: any) => onChange("sourceFile", event.target.value), placeholder: defaultDiarySourceFile })),
|
||||
h("label", null, "Tags", h("input", { value: form.tags, onChange: (event: any) => onChange("tags", event.target.value), placeholder: "frontend, daily" })),
|
||||
h("label", { className: "wide" }, "Markdown", h("textarea", { value: form.body, onChange: (event: any) => onChange("body", event.target.value), placeholder: "## 今日进展\n\n## 阻塞\n\n## 下一步", "data-testid": "diary-body-editor" })),
|
||||
),
|
||||
h("div", { className: "dispatch-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: onToday, disabled: saving, "data-testid": "today-diary-button" }, "今天"),
|
||||
h("button", { type: "submit", disabled: saving || !String(form.date || "").trim(), "data-testid": "save-diary-button" }, saving ? "保存中" : "保存日记"),
|
||||
h("code", null, `${String(form.date || browserDateOnly()).slice(0, 7)}/${form.date || browserDateOnly()}.md`),
|
||||
),
|
||||
message ? h("p", { className: "muted paragraph", "data-testid": "diary-editor-message" }, message) : null,
|
||||
h(UniDeskErrorBanner, { error, title: "日记保存失败", wide: true }),
|
||||
);
|
||||
}
|
||||
|
||||
export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
|
||||
const service = microservices.find((item: any) => item.id === "decision-center") || null;
|
||||
const [state, setState] = useState({ loading: false, error: "", health: null, records: [], refreshedAt: null });
|
||||
const [filters, setFilters] = useState({ type: "all", status: "all", level: "all", linkedGoalId: "" });
|
||||
const [activeView, setActiveView] = useState("records");
|
||||
const [requirementView, setRequirementView] = useState("all");
|
||||
const [recordForm, setRecordForm] = useState(recordFormFromCategory("external-goal"));
|
||||
const [recordSaveState, setRecordSaveState] = useState({ saving: false, error: "", message: "" });
|
||||
const [diaryState, setDiaryState] = useState({ loading: false, error: "", entries: [], months: [], selected: null, refreshedAt: null });
|
||||
const [diaryFilters, setDiaryFilters] = useState({ month: "all", from: "", to: "" });
|
||||
const [diaryForm, setDiaryForm] = useState(diaryFormFromEntry({ date: browserDateOnly(), title: `${browserDateOnly()} 工作日记`, tags: ["frontend"] }));
|
||||
const [diarySaveState, setDiarySaveState] = useState({ saving: false, error: "", message: "" });
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (!service) return;
|
||||
@@ -224,14 +458,16 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
const entriesResponse = await requestJson(decisionApi(apiBaseUrl, `/api/diary/entries?${diaryQuery(diaryFilters)}`));
|
||||
const monthsResponse = await requestJson(decisionApi(apiBaseUrl, "/api/diary/months"));
|
||||
const entries = Array.isArray(entriesResponse.entries) ? entriesResponse.entries : [];
|
||||
const nextSelected = prevSelectedDiary(entries, diaryState.selected);
|
||||
setDiaryState((prev: any) => ({
|
||||
loading: false,
|
||||
error: "",
|
||||
entries,
|
||||
months: Array.isArray(monthsResponse.months) ? monthsResponse.months : prev.months,
|
||||
selected: prev.selected && entries.some((entry: any) => entry.id === prev.selected?.id) ? prev.selected : entries[0] || null,
|
||||
selected: nextSelected,
|
||||
refreshedAt: new Date(),
|
||||
}));
|
||||
if (nextSelected && !nextSelected.body) void selectDiaryEntry(nextSelected);
|
||||
} catch (err) {
|
||||
setDiaryState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "日记加载失败") }));
|
||||
}
|
||||
@@ -241,12 +477,120 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
setDiaryState((prev: any) => ({ ...prev, selected: entry }));
|
||||
try {
|
||||
const response = await requestJson(decisionApi(apiBaseUrl, `/api/diary/entries/${encodeURIComponent(entry.date || entry.id)}`));
|
||||
setDiaryState((prev: any) => ({ ...prev, selected: response.entry || entry }));
|
||||
const selected = response.entry || entry;
|
||||
setDiaryState((prev: any) => ({ ...prev, selected }));
|
||||
setDiaryForm(diaryFormFromEntry(selected));
|
||||
} catch (err) {
|
||||
setDiaryState((prev: any) => ({ ...prev, error: errorMessage(err, "日记正文加载失败") }));
|
||||
}
|
||||
}
|
||||
|
||||
function resetRecordForm(category = "external-goal"): void {
|
||||
setRecordForm(recordFormFromCategory(category));
|
||||
setRecordSaveState({ saving: false, error: "", message: "" });
|
||||
}
|
||||
|
||||
function changeRecordForm(field: string, value: any): void {
|
||||
setRecordForm((prev: any) => ({ ...prev, [field]: value, category: field === "type" || field === "level" || field === "status" ? "custom" : prev.category }));
|
||||
}
|
||||
|
||||
function selectRecordCategory(category: string): void {
|
||||
const next = recordFormFromCategory(category);
|
||||
setRecordForm((prev: any) => ({
|
||||
...prev,
|
||||
...next,
|
||||
id: prev.id || "",
|
||||
title: prev.title || "",
|
||||
body: prev.body || "",
|
||||
linkedGoalId: prev.linkedGoalId || "",
|
||||
evidenceLinks: prev.evidenceLinks || "",
|
||||
sourceSession: prev.sourceSession || "frontend",
|
||||
taskId: prev.taskId || "",
|
||||
commitId: prev.commitId || "",
|
||||
}));
|
||||
}
|
||||
|
||||
function editRecord(record: any): void {
|
||||
setRecordForm(recordFormFromRecord(record));
|
||||
setRecordSaveState({ saving: false, error: "", message: `正在编辑 ${record?.id || ""}` });
|
||||
setActiveView("records");
|
||||
}
|
||||
|
||||
async function saveRecord(event: any): Promise<void> {
|
||||
event.preventDefault();
|
||||
const payload = recordPayloadFromForm(recordForm);
|
||||
if (!payload.title) {
|
||||
setRecordSaveState({ saving: false, error: "标题不能为空", message: "" });
|
||||
return;
|
||||
}
|
||||
setRecordSaveState({ saving: true, error: "", message: "" });
|
||||
try {
|
||||
const editingId = String(recordForm.id || "").trim();
|
||||
const response = await requestJson(
|
||||
decisionApi(apiBaseUrl, editingId ? `/api/records/${encodeURIComponent(editingId)}` : "/api/records"),
|
||||
{ method: editingId ? "PUT" : "POST", body: payload },
|
||||
);
|
||||
const saved = response.record;
|
||||
setRecordForm(recordFormFromRecord(saved || { ...payload, id: editingId }));
|
||||
setRecordSaveState({ saving: false, error: "", message: `${editingId ? "已保存" : "已创建"} ${saved?.id || editingId || ""}` });
|
||||
await load();
|
||||
} catch (err) {
|
||||
setRecordSaveState({ saving: false, error: errorMessage(err, "记录保存失败"), message: "" });
|
||||
}
|
||||
}
|
||||
|
||||
function changeDiaryForm(field: string, value: any): void {
|
||||
setDiaryForm((prev: any) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
async function openTodayDiary(): Promise<void> {
|
||||
const today = browserDateOnly();
|
||||
setActiveView("diary");
|
||||
setDiarySaveState({ saving: false, error: "", message: "" });
|
||||
try {
|
||||
const response = await requestJson(decisionApi(apiBaseUrl, `/api/diary/entries/${encodeURIComponent(today)}`));
|
||||
const entry = response.entry;
|
||||
setDiaryState((prev: any) => ({ ...prev, selected: entry || prev.selected }));
|
||||
setDiaryForm(diaryFormFromEntry(entry || { date: today, title: `${today} 工作日记`, tags: ["frontend"] }));
|
||||
setDiarySaveState({ saving: false, error: "", message: entry ? `已打开今天 ${today}` : `已准备今天 ${today}` });
|
||||
} catch {
|
||||
setDiaryForm(diaryFormFromEntry({ date: today, title: `${today} 工作日记`, body: `## 今日进展\n\n## 阻塞\n\n## 下一步\n`, sourceFile: defaultDiarySourceFile, tags: ["frontend"] }));
|
||||
setDiarySaveState({ saving: false, error: "", message: `今天 ${today} 尚未存在,保存后自动创建` });
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDiary(event: any): Promise<void> {
|
||||
event.preventDefault();
|
||||
const date = String(diaryForm.date || "").trim();
|
||||
if (!date) {
|
||||
setDiarySaveState({ saving: false, error: "日期不能为空", message: "" });
|
||||
return;
|
||||
}
|
||||
setDiarySaveState({ saving: true, error: "", message: "" });
|
||||
try {
|
||||
const markdown = diaryImportMarkdown(diaryForm);
|
||||
const response = await requestJson(decisionApi(apiBaseUrl, "/api/diary/import"), {
|
||||
method: "POST",
|
||||
body: {
|
||||
markdown,
|
||||
sourceFile: String(diaryForm.sourceFile || defaultDiarySourceFile).trim() || defaultDiarySourceFile,
|
||||
tags: parseCsv(diaryForm.tags),
|
||||
},
|
||||
});
|
||||
const entry = Array.isArray(response.entries) ? response.entries.find((item: any) => item.date === date) || response.entries[0] : null;
|
||||
setDiarySaveState({ saving: false, error: "", message: `已保存 ${date} / ${response.pathPattern || "YYYY-MM/YYYY-MM-DD.md"}` });
|
||||
await loadDiary();
|
||||
if (entry) await selectDiaryEntry(entry);
|
||||
} catch (err) {
|
||||
setDiarySaveState({ saving: false, error: errorMessage(err, "日记保存失败"), message: "" });
|
||||
}
|
||||
}
|
||||
|
||||
function prevSelectedDiary(entries: any[], selected: any): any {
|
||||
if (selected && entries.some((entry: any) => entry.id === selected.id)) return selected;
|
||||
return entries[0] || null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [service?.id, service?.runtime?.providerStatus]);
|
||||
@@ -268,10 +612,13 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
const repository = microserviceRepository(service);
|
||||
const backend = microserviceBackend(service);
|
||||
const records = Array.isArray(state.records) ? state.records : [];
|
||||
const goals = records.filter((record: any) => record.type === "goal" && ["G0", "G1"].includes(record.level) && record.status !== "done").slice(0, 8);
|
||||
const blockers = records.filter((record: any) => record.type === "blocker" && ["P0", "P1"].includes(record.level) && record.status !== "done").slice(0, 8);
|
||||
const externalGoals = records.filter((record: any) => isExternalGoal(record) && record.status !== "done");
|
||||
const internalGoals = records.filter((record: any) => isInternalGoal(record) && record.status !== "done");
|
||||
const blockers = records.filter((record: any) => (record.type === "blocker" || record.status === "blocked") && record.status !== "done").slice(0, 8);
|
||||
const parked = records.filter((record: any) => record.status === "parked").slice(0, 8);
|
||||
const authorityRecords = records.filter((record: any) => ["decision", "experiment", "debt"].includes(record.type)).slice(0, 12);
|
||||
const recentMeetings = records.filter((record: any) => record.type === "meeting" || record.type === "decision").slice(0, 12);
|
||||
const requirementFilteredRecords = records.filter((record: any) => recordMatchesRequirementView(record, requirementView));
|
||||
const diaryEntries = Array.isArray(diaryState.entries) ? diaryState.entries : [];
|
||||
const diaryMonths = Array.isArray(diaryState.months) ? diaryState.months : [];
|
||||
const selectedDiary = diaryState.selected;
|
||||
@@ -286,7 +633,7 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
h("div", { className: "metric-grid" },
|
||||
h(MetricCard, { label: "记录数", value: records.length, hint: `PostgreSQL / ${state.health?.storage || "postgres"}`, tone: "ok" }),
|
||||
h(MetricCard, { label: "日记", value: diaryCount, hint: "按月 Markdown", tone: diaryCount > 0 ? "ok" : "warn" }),
|
||||
h(MetricCard, { label: "G0/G1 目标", value: goals.length, hint: "active authority goals", tone: "ok" }),
|
||||
h(MetricCard, { label: "外部/内部目标", value: `${externalGoals.length}/${internalGoals.length}`, hint: "G0-facing / G1-G3 delivery", tone: "ok" }),
|
||||
h(MetricCard, { label: "P0/P1 Blocker", value: blockers.length, hint: "requires decision", tone: blockers.length > 0 ? "warn" : "ok" }),
|
||||
),
|
||||
h("div", { className: "microservice-ref-card" },
|
||||
@@ -299,10 +646,23 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
h(UniDeskErrorBanner, { error: state.error, title: "Decision Center 请求失败" }),
|
||||
),
|
||||
h("div", { className: "decision-tabs", role: "tablist" },
|
||||
h("button", { type: "button", className: activeView === "records" ? "active" : "", onClick: () => setActiveView("records") }, "权威记录"),
|
||||
h("button", { type: "button", className: activeView === "diary" ? "active" : "", onClick: () => setActiveView("diary") }, "工作日记"),
|
||||
h("button", { type: "button", className: activeView === "records" ? "active" : "", onClick: () => setActiveView("records"), "data-testid": "decision-tab-requirements" }, "需求管理"),
|
||||
h("button", { type: "button", className: activeView === "diary" ? "active" : "", onClick: () => setActiveView("diary"), "data-testid": "decision-tab-diary" }, "工作日记"),
|
||||
),
|
||||
activeView === "diary" ? h(React.Fragment, null,
|
||||
h(Panel, { title: "工作日记编辑台", eyebrow: "Daily Markdown", loading: diarySaveState.saving, actions: h("div", { className: "inline-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => void openTodayDiary(), disabled: diarySaveState.saving, "data-testid": "today-diary" }, "今天"),
|
||||
) },
|
||||
h(DiaryEditor, {
|
||||
form: diaryForm,
|
||||
saving: diarySaveState.saving,
|
||||
message: diarySaveState.message,
|
||||
error: diarySaveState.error,
|
||||
onChange: changeDiaryForm,
|
||||
onToday: () => void openTodayDiary(),
|
||||
onSubmit: (event: any) => void saveDiary(event),
|
||||
}),
|
||||
),
|
||||
h(Panel, { title: "日记筛选", eyebrow: "Markdown by Month", loading: diaryState.loading, actions: h("div", { className: "inline-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => void loadDiary(), disabled: diaryState.loading }, diaryState.loading ? "刷新中" : "刷新"),
|
||||
h(RawButton, { title: "Diary Months", data: diaryMonths, onOpen: onRaw, testId: "raw-decision-center-diary-months" }),
|
||||
@@ -321,7 +681,7 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
h("div", { className: "diary-layout" },
|
||||
h(Panel, { title: "按天条目", eyebrow: `${diaryEntries.length} Entries`, loading: diaryState.loading },
|
||||
diaryEntries.length === 0
|
||||
? h(EmptyState, { title: "暂无日记", text: "使用 CLI 导入按日期标题拆分的工作日志 Markdown。" })
|
||||
? h(EmptyState, { title: "暂无日记", text: "点击“今天”后可自动创建当天 Markdown 日记。" })
|
||||
: h("div", { className: "diary-entry-list" }, diaryEntries.map((entry: any) => h(DiaryEntryCard, { key: entry.id, entry, selected: selectedDiary?.id === entry.id, onSelect: selectDiaryEntry, onRaw }))),
|
||||
),
|
||||
h(Panel, { title: selectedDiary?.title || "日记正文", eyebrow: selectedDiary?.markdownPath || "Daily Markdown", actions: selectedDiary ? h(RawButton, { title: `Diary ${selectedDiary.date}`, data: selectedDiary, onOpen: onRaw, testId: "raw-decision-center-diary-selected" }) : null },
|
||||
@@ -331,6 +691,21 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
),
|
||||
),
|
||||
) : h(React.Fragment, null,
|
||||
h(Panel, { title: "需求管理工作区", eyebrow: `${requirementFilteredRecords.length} Filtered`, loading: state.loading },
|
||||
h(RequirementBoard, { records, activeView: requirementView, onView: setRequirementView, onEdit: editRecord, onRaw }),
|
||||
),
|
||||
h(Panel, { title: recordForm.id ? "编辑需求记录" : "录入需求记录", eyebrow: "Record Editor", loading: recordSaveState.saving },
|
||||
h(RecordEditor, {
|
||||
form: recordForm,
|
||||
saving: recordSaveState.saving,
|
||||
message: recordSaveState.message,
|
||||
error: recordSaveState.error,
|
||||
onChange: changeRecordForm,
|
||||
onCategory: selectRecordCategory,
|
||||
onSubmit: (event: any) => void saveRecord(event),
|
||||
onReset: () => resetRecordForm(),
|
||||
}),
|
||||
),
|
||||
h(Panel, { title: "筛选", eyebrow: "Type / Status / Level" },
|
||||
h("div", { className: "decision-filter-bar", "data-testid": "decision-center-filters" },
|
||||
h("label", null, "类型", h("select", { value: filters.type, onChange: (event: any) => setFilters((prev: any) => ({ ...prev, type: event.target.value })) }, selectOptions(recordTypes))),
|
||||
@@ -340,25 +715,33 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
),
|
||||
),
|
||||
h("div", { className: "decision-default-grid" },
|
||||
h(Panel, { title: "G0/G1 目标", eyebrow: `${goals.length} Goals` },
|
||||
goals.length === 0 ? h(EmptyState, { title: "暂无当前目标", text: "目标记录使用 type=goal 且 level=G0/G1。" }) :
|
||||
h("div", { className: "decision-card-list" }, goals.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, compact: true }))),
|
||||
h(Panel, { title: "外部目标", eyebrow: `${externalGoals.length} External Goals` },
|
||||
externalGoals.length === 0 ? h(EmptyState, { title: "暂无外部目标", text: "外部目标使用 G0 或 external-goal tag。" }) :
|
||||
h("div", { className: "decision-card-list" }, externalGoals.slice(0, 8).map((record: any) => h(RecordCard, { key: record.id, record, onRaw, onEdit: editRecord, compact: true }))),
|
||||
),
|
||||
h(Panel, { title: "内部目标", eyebrow: `${internalGoals.length} Internal Goals` },
|
||||
internalGoals.length === 0 ? h(EmptyState, { title: "暂无内部目标", text: "内部目标使用 G1/G2/G3 或 internal-goal tag。" }) :
|
||||
h("div", { className: "decision-card-list" }, internalGoals.slice(0, 8).map((record: any) => h(RecordCard, { key: record.id, record, onRaw, onEdit: editRecord, compact: true }))),
|
||||
),
|
||||
h(Panel, { title: "P0/P1 Blocker", eyebrow: `${blockers.length} Blockers` },
|
||||
blockers.length === 0 ? h(EmptyState, { title: "暂无高优先级阻塞", text: "阻塞记录使用 type=blocker 且 level=P0/P1。" }) :
|
||||
h("div", { className: "decision-card-list" }, blockers.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, compact: true }))),
|
||||
h("div", { className: "decision-card-list" }, blockers.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, onEdit: editRecord, compact: true }))),
|
||||
),
|
||||
h(Panel, { title: "停放事项", eyebrow: `${parked.length} Parked` },
|
||||
parked.length === 0 ? h(EmptyState, { title: "暂无停放事项", text: "status=parked 的记录会集中展示。" }) :
|
||||
h("div", { className: "decision-card-list" }, parked.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, compact: true }))),
|
||||
h("div", { className: "decision-card-list" }, parked.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, onEdit: editRecord, compact: true }))),
|
||||
),
|
||||
h(Panel, { title: "决议/实验/债务", eyebrow: `${authorityRecords.length} Authority` },
|
||||
authorityRecords.length === 0 ? h(EmptyState, { title: "暂无权威事项", text: "决议、实验和债务会集中展示。" }) :
|
||||
h("div", { className: "decision-card-list" }, authorityRecords.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, onEdit: editRecord, compact: true }))),
|
||||
),
|
||||
h(Panel, { title: "最近会议/决议", eyebrow: `${recentMeetings.length} Recent` },
|
||||
recentMeetings.length === 0 ? h(EmptyState, { title: "暂无会议或决议", text: "使用 CLI 上传 Markdown 会议记录后会显示。" }) :
|
||||
h("div", { className: "decision-card-list" }, recentMeetings.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, compact: true }))),
|
||||
h("div", { className: "decision-card-list" }, recentMeetings.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, onEdit: editRecord, compact: true }))),
|
||||
),
|
||||
),
|
||||
h(Panel, { title: "全部记录", eyebrow: `${records.length} Records`, actions: state.refreshedAt ? h("span", { className: "muted" }, `刷新 ${fmtClock(state.refreshedAt)}`) : null },
|
||||
h(RecordTable, { records, onRaw }),
|
||||
h(RecordTable, { records, onRaw, onEdit: editRecord }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user