diff --git a/scripts/decision-center-workspace-contract-test.ts b/scripts/decision-center-workspace-contract-test.ts new file mode 100644 index 00000000..5dec3b1d --- /dev/null +++ b/scripts/decision-center-workspace-contract-test.ts @@ -0,0 +1,159 @@ +import { readFileSync } from "node:fs"; + +type JsonRecord = Record; + +function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +function source(path: string): string { + return readFileSync(path, "utf8"); +} + +function includesAll(text: string, snippets: string[]): boolean { + return snippets.every((snippet) => text.includes(snippet)); +} + +export function runDecisionCenterWorkspaceContract(): JsonRecord { + const frontend = source("src/components/frontend/src/decision-center.tsx"); + const css = source("src/components/frontend/public/style.css"); + + assertCondition( + includesAll(frontend, [ + "function extractDocNo(record: any): string", + "function buildDocTypeTree(records: any[]): Array<{ type: DocTypeCode", + "function buildTagGroups(records: any[]): Array<{ tag: string", + "DOC_TYPE_CODES = [\"DCSN\", \"GOAL\", \"PLAN\", \"RPRT\", \"ACTN\", \"ISSU\", \"RETR\", \"RQST\", \"RESP\", \"MINS\"]", + "docTypeLabels: Record", + ]), + "frontend must implement doc-no extraction, doc-type tree, and tag grouping", + ); + + assertCondition( + includesAll(frontend, [ + "function DocTreeNode({ record, depth, onSelect, selectedId }: AnyRecord)", + "function DocTreeGroup({ group, onSelect, selectedId, defaultOpen }: AnyRecord)", + "function TagTreeGroup({ group, onSelect, selectedId, defaultOpen }: AnyRecord)", + "function DocTypeTree({ records, onSelect, selectedId, activeGrouping }: AnyRecord)", + ]), + "frontend must implement DocTreeNode, DocTreeGroup, TagTreeGroup, and DocTypeTree components", + ); + + assertCondition( + includesAll(frontend, [ + "function DocDetailSidebar({ record, onSelect, onSave, saving, error, saveMsg, onRaw }: AnyRecord)", + "function DocEditor({ form, saving, onChange, onSubmit }: AnyRecord)", + "function DocViewer({ record }: AnyRecord)", + "function DocDetailHeader({ record, onBack }: AnyRecord)", + ]), + "frontend must implement DocDetailSidebar, DocEditor, DocViewer, and DocDetailHeader", + ); + + assertCondition( + includesAll(frontend, [ + "function DocWorkspace({ records, selectedRecord, onSelect, onSave, saving, saveError, saveMsg, onRaw }: AnyRecord)", + "h(DocWorkspace, {", + "activeView === \"workspace\"", + "decision-tab-workspace", + ]), + "frontend must implement DocWorkspace component and workspace tab", + ); + + assertCondition( + includesAll(frontend, [ + "mode === \"view\"", + "mode === \"edit\"", + "setMode(\"view\")", + "setMode(\"edit\")", + "doc-sidebar-mode-tabs", + ]), + "frontend must implement read/edit mode toggle in DocDetailSidebar", + ); + + assertCondition( + includesAll(frontend, [ + "selectedRecord?.id", + "setState((prev: any) => ({ ...prev, selectedDoc: record", + "state.selectedDoc", + ]), + "frontend must track selectedDoc in state and sync on selection", + ); + + assertCondition( + includesAll(frontend, [ + "await onSave(form)", + "recordPayloadFromForm(form)", + "setRecordSaveState({ saving: false, error: \"\", message:", + "setState((prev: any) => ({ ...prev, selectedDoc: saved", + ]), + "frontend must call onSave, payload conversion, error handling, and sync selectedDoc after save", + ); + + assertCondition( + css.includes(".doc-workspace"), + "CSS must include .doc-workspace layout", + ); + + assertCondition( + css.includes(".doc-sidebar"), + "CSS must include .doc-sidebar", + ); + + assertCondition( + css.includes("grid-template-columns: minmax(200px, 260px) minmax(0, 1fr) minmax(0, 50%)"), + "CSS must implement three-column layout with right sidebar at 50%", + ); + + assertCondition( + includesAll(css, [ + ".doc-type-tree", + ".doc-tree-group", + ".doc-tree-node", + ".doc-list-item", + ".doc-full-markdown", + ".doc-editor-form", + ]), + "CSS must include all workspace component styles", + ); + + assertCondition( + includesAll(frontend, [ + "extractDocNo(record)", + "title.match(/^([A-Z]{2,5})[-−]?(\\d+)/u)", + "doc-no:([A-Z]{2,5})[-−]?(\\d+)", + ]), + "extractDocNo must extract from title prefix and doc-no tag", + ); + + assertCondition( + includesAll(frontend, [ + "grouping === \"type\"", + "grouping === \"tag\"", + "setGrouping(\"type\")", + "setGrouping(\"tag\")", + ]), + "workspace must support type and tag grouping toggle", + ); + + return { + ok: true, + checks: [ + "doc-no-extraction-functions", + "doc-type-tree-components", + "doc-detail-sidebar-components", + "doc-workspace-component", + "read-edit-mode-toggle", + "selected-doc-state-sync", + "save-and-sync", + "workspace-css-layout", + "workspace-css-components", + "three-column-layout-50-percent", + "doc-no-regex-from-title", + "type-tag-grouping-toggle", + ], + }; +} + +if (import.meta.main) { + process.stdout.write(`${JSON.stringify(runDecisionCenterWorkspaceContract(), null, 2)}\n`); +} \ No newline at end of file diff --git a/src/components/frontend/public/style.css b/src/components/frontend/public/style.css index b7ab4f1f..8bb64ec3 100644 --- a/src/components/frontend/public/style.css +++ b/src/components/frontend/public/style.css @@ -7338,6 +7338,305 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } overflow-wrap: anywhere; } +.doc-workspace { + display: grid; + grid-template-columns: minmax(200px, 260px) minmax(0, 1fr) minmax(0, 50%); + gap: 10px; + align-items: start; + min-height: 480px; +} +.doc-workspace-left { + display: grid; + gap: 6px; + min-width: 0; +} +.doc-workspace-left.collapsed { + min-width: 40px; +} +.doc-workspace-left-head { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + padding: 4px 0; +} +.doc-workspace-center { + display: grid; + gap: 6px; + min-width: 0; + max-height: calc(100vh - 340px); + overflow-y: auto; +} +.doc-workspace-right { + display: grid; + gap: 8px; + min-width: 0; + position: sticky; + top: 0; +} +.doc-workspace-right.collapsed { + min-width: 40px; +} +.doc-workspace-right-head { + display: flex; + justify-content: flex-end; + padding: 4px 0; +} +.doc-type-tree { + display: grid; + gap: 4px; + max-height: calc(100vh - 360px); + overflow-y: auto; + padding-right: 4px; +} +.doc-tree-group { + display: grid; + gap: 3px; +} +.doc-tree-group-head { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + width: 100%; + min-height: 28px; + padding: 4px 8px; + border: 1px solid var(--line-soft); + color: var(--text); + background: rgba(0,0,0,0.2); + cursor: pointer; + text-align: left; + font-size: 12px; +} +.doc-tree-group-head:hover { + border-color: rgba(78, 183, 168, 0.45); + background: rgba(78, 183, 168, 0.08); +} +.doc-tree-group-label { + font-weight: 600; + letter-spacing: 0.06em; +} +.doc-tree-group-count { + color: var(--muted); + font-size: 11px; +} +.doc-tree-caret { + color: var(--muted); + margin-left: auto; +} +.doc-tree-group-body { + display: grid; + gap: 2px; + padding-left: 8px; +} +.doc-tree-node { + display: grid; + gap: 2px; +} +.doc-tree-item { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + width: 100%; + min-height: 26px; + padding: 3px 8px; + border: 1px solid transparent; + color: var(--text); + background: transparent; + cursor: pointer; + text-align: left; + font-size: 12px; +} +.doc-tree-item:hover { + border-color: rgba(78, 183, 168, 0.3); + background: rgba(78, 183, 168, 0.06); +} +.doc-tree-item.selected { + border-color: rgba(78, 183, 168, 0.55); + background: rgba(78, 183, 168, 0.14); +} +.doc-tree-meta { + color: var(--accent); + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + min-width: 0; +} +.doc-tree-label { + min-width: 0; + overflow-wrap: anywhere; +} +.doc-record-list { + display: grid; + gap: 6px; +} +.doc-list-item { + display: grid; + gap: 4px; + width: 100%; + padding: 8px 10px; + border: 1px solid var(--line-soft); + color: var(--text); + background: rgba(0,0,0,0.14); + cursor: pointer; + text-align: left; +} +.doc-list-item:hover { + border-color: rgba(78, 183, 168, 0.4); + background: rgba(78, 183, 168, 0.08); +} +.doc-list-item.selected { + border-color: rgba(78, 183, 168, 0.6); + background: rgba(78, 183, 168, 0.16); +} +.doc-list-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + font-size: 11px; +} +.doc-list-meta span:first-child { + color: var(--accent); + letter-spacing: 0.12em; + text-transform: uppercase; +} +.doc-list-title { + font-weight: 600; + font-size: 13px; + overflow-wrap: anywhere; +} +.doc-list-summary { + color: var(--muted); + font-size: 12px; + line-height: 1.4; + overflow-wrap: anywhere; +} +.doc-sidebar { + display: grid; + gap: 10px; + min-width: 0; + max-height: calc(100vh - 200px); + overflow-y: auto; + padding-right: 4px; +} +.doc-sidebar.empty { + align-items: center; + justify-items: center; +} +.doc-sidebar-head { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + position: sticky; + top: 0; + background: var(--panel-3); + z-index: 2; + padding: 4px 0; +} +.doc-sidebar-mode-tabs { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.doc-sidebar-mode-tabs button { + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--line-soft); + color: var(--muted); + background: rgba(0,0,0,0.2); + cursor: pointer; + font-size: 12px; +} +.doc-sidebar-mode-tabs button.active, +.doc-sidebar-mode-tabs button:hover { + border-color: rgba(78, 183, 168, 0.5); + color: var(--text); + background: rgba(78, 183, 168, 0.1); +} +.doc-detail-header { + display: grid; + gap: 6px; + padding: 10px 0; + border-bottom: 1px solid rgba(255,255,255,0.07); +} +.doc-detail-docno { + color: var(--accent); + font-size: 12px; + letter-spacing: 0.12em; +} +.doc-detail-title { + margin: 0; + font-size: 16px; + overflow-wrap: anywhere; +} +.doc-detail-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + color: var(--muted); + font-size: 11px; +} +.doc-detail-source { + color: var(--accent); + font-size: 12px; + overflow-wrap: anywhere; +} +.doc-detail-evidence { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.doc-detail-evidence a { + color: var(--text); + font-size: 11px; + overflow-wrap: anywhere; +} +.doc-viewer { + display: grid; + gap: 8px; +} +.doc-full-markdown { + max-height: none; + overflow-wrap: anywhere; +} +.doc-editor-form { + display: grid; + gap: 8px; +} +.doc-editor-form label { + display: grid; + gap: 5px; + color: var(--muted); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.doc-editor-form input, +.doc-editor-form select, +.doc-editor-form textarea { + min-height: 30px; + padding: 6px 8px; + border: 1px solid var(--line-soft); + color: var(--text); + background: rgba(0,0,0,0.2); + font-size: 13px; + line-height: 1.4; +} +.doc-editor-form textarea { + min-height: 160px; + resize: vertical; +} +.doc-editor-form input:focus, +.doc-editor-form select:focus, +.doc-editor-form textarea:focus { + border-color: rgba(78, 183, 168, 0.5); + outline: none; +} + @media (max-width: 1100px) { .mdtodo-layout, .decision-hero, @@ -7356,4 +7655,14 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); } .diary-markdown { max-height: none; } + .doc-workspace { + grid-template-columns: 1fr; + } + .doc-workspace-left.collapsed, + .doc-workspace-right.collapsed { + display: none; + } + .doc-sidebar { + max-height: none; + } } diff --git a/src/components/frontend/src/decision-center.tsx b/src/components/frontend/src/decision-center.tsx index f2513049..ee43dfca 100644 --- a/src/components/frontend/src/decision-center.tsx +++ b/src/components/frontend/src/decision-center.tsx @@ -15,6 +15,24 @@ const recordTypes = ["all", "meeting", "decision", "goal", "blocker", "debt", "e const recordLevels = ["all", "G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"]; const recordStatuses = ["all", "active", "blocked", "parked", "done"]; const diaryWeekdays = ["一", "二", "三", "四", "五", "六", "日"]; + +const DOC_TYPE_CODES = ["DCSN", "GOAL", "PLAN", "RPRT", "ACTN", "ISSU", "RETR", "RQST", "RESP", "MINS"] as const; +type DocTypeCode = typeof DOC_TYPE_CODES[number]; + +const docTypeLabels: Record = { + DCSN: "决策/决议", + GOAL: "目标", + PLAN: "计划", + RPRT: "报告", + ACTN: "行动", + ISSU: "问题", + RETR: "检索", + RQST: "请求", + RESP: "响应", + MINS: "会议纪要", +}; + +const docTypeOrder: DocTypeCode[] = ["DCSN", "GOAL", "PLAN", "RPRT", "ACTN", "ISSU", "RETR", "RQST", "RESP", "MINS"]; const requirementViews = [ { id: "all", label: "全部需求" }, { id: "external-goal", label: "外部目标" }, @@ -206,6 +224,357 @@ function tagsText(value: any): string { return Array.isArray(value) ? value.join(", ") : String(value || ""); } +function extractDocNo(record: any): string { + const title = String(record?.title || ""); + const match = title.match(/^([A-Z]{2,5})[-−]?(\d+)/u); + if (match) return `${match[1]}-${match[2]}`; + const tags = Array.isArray(record?.tags) ? record.tags : []; + for (const tag of tags) { + const tagStr = String(tag || ""); + const m = tagStr.match(/^doc-no:([A-Z]{2,5})[-−]?(\d+)/iu); + if (m) return `${m[1]}-${m[2]}`; + } + return ""; +} + +function buildDocTypeTree(records: any[]): Array<{ type: DocTypeCode; label: string; nodes: any[] }> { + const byType = new Map(); + for (const code of docTypeOrder) byType.set(code, []); + for (const record of records) { + const title = String(record?.title || ""); + let code: DocTypeCode | null = null; + const match = title.match(/^([A-Z]{2,5})[-−]?/u); + if (match) { + const candidate = match[1].toUpperCase() as DocTypeCode; + if (DOC_TYPE_CODES.includes(candidate)) code = candidate; + } + if (!code) { + const tags = Array.isArray(record?.tags) ? record.tags : []; + for (const tag of tags) { + const m = String(tag || "").match(/^doc-type:([A-Z]{2,5})/iu); + if (m) { + const candidate = m[1].toUpperCase() as DocTypeCode; + if (DOC_TYPE_CODES.includes(candidate)) { code = candidate; break; } + } + } + } + if (!code) code = "DCSN"; + byType.get(code)!.push(record); + } + return docTypeOrder + .map((code) => ({ type: code, label: docTypeLabels[code], nodes: byType.get(code) || [] })) + .filter((group) => group.nodes.length > 0); +} + +function buildTagGroups(records: any[]): Array<{ tag: string; nodes: any[] }> { + const tagCounts = new Map(); + const priorityTags = ["thesis", "external-demand", "D518"]; + for (const record of records) { + const tags = Array.isArray(record?.tags) ? record.tags : []; + const labelTags = tags.filter((t: string) => { + const lower = String(t || "").toLowerCase(); + return priorityTags.some((p) => lower.includes(p)) || /^doc-type:/.test(lower); + }); + if (labelTags.length === 0) labelTags.push("未分类"); + for (const tag of labelTags) { + const bucket = tagCounts.get(String(tag)) || []; + bucket.push(record); + tagCounts.set(String(tag), bucket); + } + } + return [...tagCounts.entries()] + .map(([tag, nodes]) => ({ tag, nodes })) + .sort((a, b) => { + const ai = priorityTags.findIndex((p) => a.tag.toLowerCase().includes(p)); + const bi = priorityTags.findIndex((p) => b.tag.toLowerCase().includes(p)); + if (ai !== bi) return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi); + return a.tag.localeCompare(b.tag); + }); +} + +function DocTreeNode({ record, depth, onSelect, selectedId }: AnyRecord) { + const docNo = extractDocNo(record); + const label = docNo ? `${docNo} ${record.title || ""}` : (record.title || record.id || "--"); + return h("div", { className: `doc-tree-node depth-${Math.min(depth, 3)}` }, + h("button", { + type: "button", + className: `doc-tree-item ${selectedId === record.id ? "selected" : ""}`, + onClick: () => onSelect(record), + "data-testid": `doc-tree-item-${stableTestId(record.id)}`, + }, + h("span", { className: "doc-tree-meta" }, record.type), + h("span", { className: "doc-tree-label" }, shortText(label, 80)), + ), + ); +} + +function DocTreeGroup({ group, onSelect, selectedId, defaultOpen }: AnyRecord) { + const [open, setOpen] = useState(defaultOpen !== false); + return h("div", { className: "doc-tree-group", "data-testid": `doc-tree-group-${group.type}` }, + h("button", { + type: "button", + className: `doc-tree-group-head ${open ? "open" : ""}`, + onClick: () => setOpen((v: any) => !v), + "data-testid": `doc-tree-group-toggle-${group.type}`, + }, + h("span", { className: "doc-tree-group-label" }, group.label), + h("span", { className: "doc-tree-group-count" }, group.nodes.length), + h("span", { className: "doc-tree-caret" }, open ? "▾" : "▸"), + ), + open ? h("div", { className: "doc-tree-group-body" }, + group.nodes.map((record: any) => h(DocTreeNode, { key: record.id, record, depth: 0, onSelect, selectedId })), + ) : null, + ); +} + +function TagTreeGroup({ group, onSelect, selectedId, defaultOpen }: AnyRecord) { + const [open, setOpen] = useState(defaultOpen !== false); + return h("div", { className: "doc-tree-group", "data-testid": `tag-tree-group-${stableTestId(group.tag)}` }, + h("button", { + type: "button", + className: `doc-tree-group-head ${open ? "open" : ""}`, + onClick: () => setOpen((v: any) => !v), + "data-testid": `tag-tree-group-toggle-${stableTestId(group.tag)}`, + }, + h("span", { className: "doc-tree-group-label" }, group.tag), + h("span", { className: "doc-tree-group-count" }, group.nodes.length), + h("span", { className: "doc-tree-caret" }, open ? "▾" : "▸"), + ), + open ? h("div", { className: "doc-tree-group-body" }, + group.nodes.map((record: any) => h(DocTreeNode, { key: record.id, record, depth: 0, onSelect, selectedId })), + ) : null, + ); +} + +function DocTypeTree({ records, onSelect, selectedId, activeGrouping }: AnyRecord) { + if (!records.length) return h(EmptyState, { title: "暂无文书", text: "通过 CLI 上传文书后会显示在目录树中。" }); + const groups = activeGrouping === "type" ? buildDocTypeTree(records) : buildTagGroups(records); + if (groups.length === 0) return h(EmptyState, { title: "暂无文书", text: "通过 CLI 上传文书后会显示在目录树中。" }); + return h("div", { className: "doc-type-tree", "data-testid": "doc-type-tree" }, + activeGrouping === "type" + ? groups.map((group: any) => h(DocTreeGroup, { key: group.type, group, onSelect, selectedId })) + : groups.map((group: any) => h(TagTreeGroup, { key: group.tag, group, onSelect, selectedId })), + ); +} + +function DocDetailHeader({ record, onBack }: AnyRecord) { + const docNo = extractDocNo(record); + return h("div", { className: "doc-detail-header" }, + onBack ? h("button", { type: "button", className: "ghost-btn compact", onClick: onBack, "data-testid": "doc-detail-back" }, "← 返回") : null, + docNo ? h("span", { className: "doc-detail-docno", "data-testid": "doc-detail-docno" }, docNo) : null, + h("h2", { className: "doc-detail-title" }, record.title || "--"), + h("div", { className: "doc-detail-meta" }, + h("span", null, record.type || "--"), + h(StatusBadge, { status: levelTone(record.level) }, record.level || "none"), + h(StatusBadge, { status: statusTone(record.status) }, record.status || "--"), + h("span", { className: "muted" }, fmtRecordTime(record.updatedAt)), + ), + record.source ? h("a", { href: record.source, target: "_blank", rel: "noreferrer", className: "doc-detail-source" }, `来源: ${record.source}`) : null, + Array.isArray(record.evidenceLinks) && record.evidenceLinks.length > 0 + ? h("div", { className: "doc-detail-evidence" }, + record.evidenceLinks.slice(0, 3).map((link: string) => h("a", { key: link, href: link, target: "_blank", rel: "noreferrer" }, shortText(link, 60))), + ) + : null, + ); +} + +function DocViewer({ record }: AnyRecord) { + return h("div", { className: "doc-viewer", "data-testid": "doc-viewer" }, + h(MarkdownBody, { markdown: record.body || record.summary || "", className: "doc-full-markdown" }), + ); +} + +function DocEditor({ form, saving, onChange, onSubmit }: AnyRecord) { + return h("form", { className: "doc-editor-form", onSubmit, "data-testid": "doc-editor" }, + h("label", null, "标题", + h("input", { + value: form.title || "", + onChange: (e: any) => onChange("title", e.target.value), + placeholder: "文书标题", + "data-testid": "doc-title-input", + }), + ), + h("label", null, "类型", + h("select", { + value: form.type || "decision", + onChange: (e: any) => onChange("type", e.target.value), + "data-testid": "doc-type-input", + }, selectOptions(recordTypes.filter((t: string) => t !== "all"))), + ), + h("label", null, "优先级", + h("select", { + value: form.level || "none", + onChange: (e: any) => onChange("level", e.target.value), + "data-testid": "doc-level-input", + }, selectOptions(recordLevels.filter((l: string) => l !== "all"))), + ), + h("label", null, "状态", + h("select", { + value: form.status || "active", + onChange: (e: any) => onChange("status", e.target.value), + "data-testid": "doc-status-input", + }, selectOptions(recordStatuses.filter((s: string) => s !== "all"))), + ), + h("label", null, "Tags", + h("input", { + value: form.tags || "", + onChange: (e: any) => onChange("tags", e.target.value), + placeholder: "逗号分隔", + "data-testid": "doc-tags-input", + }), + ), + h("label", { className: "wide" }, "正文", + h("textarea", { + value: form.body || "", + onChange: (e: any) => onChange("body", e.target.value), + placeholder: "Markdown 正文", + "data-testid": "doc-body-editor", + }), + ), + h("div", { className: "dispatch-actions" }, + h("button", { + type: "submit", + disabled: saving || !String(form.title || "").trim(), + "data-testid": "doc-save-button", + }, saving ? "保存中…" : "保存"), + ), + ); +} + +function DocDetailSidebar({ record, onSelect, onSave, saving, error, saveMsg, onRaw }: AnyRecord) { + const [mode, setMode] = useState("view"); + const [form, setForm] = useState({}); + + function syncForm(rec: any): void { + setForm(recordFormFromRecord(rec)); + } + + useEffect(() => { + if (record) syncForm(record); + }, [record?.id]); + + function changeField(field: string, value: any): void { + setForm((prev: any) => ({ ...prev, [field]: value })); + } + + async function handleSubmit(e: any): Promise { + e.preventDefault(); + await onSave(form); + } + + if (!record) { + return h("div", { className: "doc-sidebar empty", "data-testid": "doc-sidebar-empty" }, + h(EmptyState, { title: "未选择文书", text: "从左侧目录树或列表选择文书查看全文。" }), + ); + } + + return h("div", { className: "doc-sidebar", "data-testid": "doc-sidebar" }, + h("div", { className: "doc-sidebar-head" }, + h("div", { className: "doc-sidebar-mode-tabs", role: "tablist" }, + h("button", { + type: "button", + className: mode === "view" ? "active" : "", + onClick: () => { setMode("view"); if (record) syncForm(record); }, + "data-testid": "doc-mode-view", + }, "阅读"), + h("button", { + type: "button", + className: mode === "edit" ? "active" : "", + onClick: () => setMode("edit"), + "data-testid": "doc-mode-edit", + }, "编辑"), + ), + h("div", { className: "inline-actions" }, + h(RawButton, { title: `Doc ${record.id}`, data: record, onOpen: onRaw, testId: "doc-sidebar-raw" }), + ), + ), + mode === "view" + ? h(React.Fragment, null, + h(DocDetailHeader, { record, onBack: onSelect ? () => onSelect(null) : undefined }), + h(DocViewer, { record }), + ) + : h(React.Fragment, null, + h(UniDeskErrorBanner, { error, title: "保存失败" }), + saveMsg ? h("p", { className: "muted paragraph", "data-testid": "doc-save-msg" }, saveMsg) : null, + h(DocEditor, { form, saving, onChange: changeField, onSubmit: handleSubmit }), + ), + ); +} + +function DocWorkspace({ records, selectedRecord, onSelect, onSave, saving, saveError, saveMsg, onRaw }: AnyRecord) { + const [leftOpen, setLeftOpen] = useState(true); + const [rightOpen, setRightOpen] = useState(true); + const [grouping, setGrouping] = useState("type"); + + return h("div", { className: "doc-workspace", "data-testid": "doc-workspace" }, + h("div", { className: `doc-workspace-left ${leftOpen ? "open" : "collapsed"}` }, + h("div", { className: "doc-workspace-left-head" }, + h("span", { className: "panel-eyebrow" }, "目录树"), + h("div", { className: "inline-actions" }, + h("button", { + type: "button", + className: `ghost-btn compact ${grouping === "type" ? "active" : ""}`, + onClick: () => setGrouping("type"), + "data-testid": "group-by-type", + }, "按文类"), + h("button", { + type: "button", + className: `ghost-btn compact ${grouping === "tag" ? "active" : ""}`, + onClick: () => setGrouping("tag"), + "data-testid": "group-by-tag", + }, "按标签"), + h("button", { + type: "button", + className: "ghost-btn compact", + onClick: () => setLeftOpen((v: any) => !v), + "data-testid": "toggle-left-panel", + }, leftOpen ? "←" : "→"), + ), + ), + leftOpen ? h(DocTypeTree, { records, onSelect, selectedId: selectedRecord?.id, activeGrouping: grouping }) : null, + ), + h("div", { className: "doc-workspace-center" }, + h("div", { className: "doc-record-list", "data-testid": "doc-record-list" }, + records.length === 0 + ? h(EmptyState, { title: "暂无记录", text: "通过 CLI 上传文书后会显示在这里。" }) + : records.map((record: any) => h("button", { + key: record.id, + type: "button", + className: `doc-list-item ${selectedRecord?.id === record.id ? "selected" : ""}`, + onClick: () => onSelect(record), + "data-testid": `doc-list-item-${stableTestId(record.id)}`, + }, + h("span", { className: "doc-list-meta" }, + h("span", null, record.type || "--"), + h(StatusBadge, { status: levelTone(record.level) }, record.level || "none"), + ), + h("span", { className: "doc-list-title" }, shortText(record.title || "--", 60)), + h("span", { className: "doc-list-summary" }, shortText(record.summary || record.body, 100)), + )), + ), + ), + h("div", { className: `doc-workspace-right ${rightOpen ? "open" : "collapsed"}` }, + h("div", { className: "doc-workspace-right-head" }, + h("button", { + type: "button", + className: "ghost-btn compact", + onClick: () => setRightOpen((v: any) => !v), + "data-testid": "toggle-right-panel", + }, rightOpen ? "→" : "←"), + ), + rightOpen ? h(DocDetailSidebar, { + record: selectedRecord, + onSelect, + onSave, + saving, + error: saveError, + saveMsg, + onRaw, + }) : null, + ), + ); +} + function recordFormFromCategory(categoryId: string): AnyRecord { const category = recordCategories.find((item) => item.id === categoryId) || recordCategories[0]; return { @@ -652,7 +1021,7 @@ function DiaryEditor({ form, saving, message, error, onChange, onToday, onSubmit 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 [state, setState] = useState({ loading: false, error: "", health: null, records: [], refreshedAt: null, selectedDoc: null }); const [filters, setFilters] = useState({ type: "all", status: "all", level: "all", linkedGoalId: "" }); const [activeView, setActiveView] = useState("records"); const [requirementView, setRequirementView] = useState("all"); @@ -924,8 +1293,43 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" } h("div", { className: "decision-tabs", role: "tablist" }, 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" }, "工作日记"), + h("button", { type: "button", className: activeView === "workspace" ? "active" : "", onClick: () => setActiveView("workspace"), "data-testid": "decision-tab-workspace" }, "文书工作台"), ), - activeView === "diary" ? h(React.Fragment, null, + activeView === "workspace" ? h(React.Fragment, null, + h(DocWorkspace, { + records, + selectedRecord: state.selectedDoc, + onSelect: async (record: any) => { + setState((prev: any) => ({ ...prev, selectedDoc: record })); + if (!record?.body && record?.id) { + try { + const response = await requestJson(decisionApi(apiBaseUrl, `/api/records/${encodeURIComponent(record.id)}`)); + setState((prev: any) => ({ ...prev, selectedDoc: response.record || record })); + } catch { /* ignore */ } + } + }, + onSave: async (form: AnyRecord) => { + setRecordSaveState({ saving: true, error: "", message: "" }); + try { + const editingId = String(form.id || "").trim(); + const response = await requestJson( + decisionApi(apiBaseUrl, editingId ? `/api/records/${encodeURIComponent(editingId)}` : "/api/records"), + { method: editingId ? "PUT" : "POST", body: recordPayloadFromForm(form) }, + ); + const saved = response.record; + setRecordSaveState({ saving: false, error: "", message: `已保存 ${saved?.id || editingId}` }); + await load(); + if (saved) setState((prev: any) => ({ ...prev, selectedDoc: saved })); + } catch (err) { + setRecordSaveState({ saving: false, error: errorMessage(err, "保存失败"), message: "" }); + } + }, + saving: recordSaveState.saving, + saveError: recordSaveState.error, + saveMsg: recordSaveState.message, + onRaw, + }), + ) : 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" }, "今天"), ) },