feat(decision-center): add文书工作台 three-panel workspace (issue #51)

- Add DocTypeTree left panel with type grouping (DCSN|GOAL|PLAN|...) and tag grouping
- Add DocWorkspace with three-column layout: tree (260px) | list | detail (~50%)
- Add DocDetailSidebar with read/edit mode toggle, full markdown display
- Add DocDetailHeader with doc-no, title, type, priority, status, evidence links
- Add DocEditor for editing title, type, level, status, tags, body
- Extract doc-no from title prefix (e.g. DCSN-2025-001) and doc-no:* tag
- Support collapsible left/right panels for narrow screens
- Add decision-center-workspace-contract-test.ts for doc-no extraction,
  tree grouping, selection, detail display, edit sync
- Add .doc-workspace, .doc-sidebar, .doc-type-tree, .doc-tree-* CSS
This commit is contained in:
Codex
2026-05-21 07:12:40 +00:00
parent 422274f0e3
commit 2fcdc26ce4
3 changed files with 874 additions and 2 deletions
@@ -0,0 +1,159 @@
import { readFileSync } from "node:fs";
type JsonRecord = Record<string, unknown>;
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<DocTypeCode, string>",
]),
"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`);
}
+309
View File
@@ -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;
}
}
+406 -2
View File
@@ -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<DocTypeCode, string> = {
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<DocTypeCode, any[]>();
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<string, any[]>();
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<void> {
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" }, "今天"),
) },