diff --git a/docs/reference/frontend.md b/docs/reference/frontend.md index 139423e3..06f5a4bb 100644 --- a/docs/reference/frontend.md +++ b/docs/reference/frontend.md @@ -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/` 工作目录;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 ` 的提示;连续执行同一 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` 圆点和“标为已读”操作,不得把整张卡片改成红色/琥珀色失败态边框、背景或胶囊标签;状态栏的“结束未读”提示也不得使用失败态红色。 diff --git a/src/components/frontend/public/style.css b/src/components/frontend/public/style.css index 9b2275e6..8b624382 100644 --- a/src/components/frontend/public/style.css +++ b/src/components/frontend/public/style.css @@ -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; diff --git a/src/components/frontend/src/decision-center.tsx b/src/components/frontend/src/decision-center.tsx index a09c0f7f..13345a65 100644 --- a/src/components/frontend/src/decision-center.tsx +++ b/src/components/frontend/src/decision-center.tsx @@ -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 { 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 { + 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 { + 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 { + 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 }), ), ), );