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:
@@ -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`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }, "今天"),
|
||||
) },
|
||||
|
||||
Reference in New Issue
Block a user