feat(frontend): upgrade decision center workbench
This commit is contained in:
@@ -6833,6 +6833,92 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.requirement-workspace {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.requirement-switcher,
|
||||
.editor-mode-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.requirement-switcher button,
|
||||
.editor-mode-grid button {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line-soft);
|
||||
color: var(--muted);
|
||||
background: rgba(0,0,0,0.18);
|
||||
}
|
||||
.requirement-switcher button.active,
|
||||
.requirement-switcher button:hover,
|
||||
.editor-mode-grid button.active,
|
||||
.editor-mode-grid button:hover {
|
||||
border-color: rgba(215, 161, 58, 0.56);
|
||||
color: var(--text);
|
||||
background: rgba(215, 161, 58, 0.12);
|
||||
}
|
||||
.requirement-lanes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(210px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.requirement-lane {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 210px;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(0,0,0,0.13);
|
||||
}
|
||||
.requirement-lane-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
.requirement-lane-head strong {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.record-editor-form,
|
||||
.diary-editor-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.decision-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.2fr) repeat(3, minmax(120px, 0.6fr));
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
.decision-form-grid label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.decision-form-grid textarea {
|
||||
min-height: 140px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.diary-editor-grid {
|
||||
grid-template-columns: 150px minmax(220px, 1fr) minmax(180px, 0.6fr) minmax(150px, 0.5fr);
|
||||
}
|
||||
.diary-editor-grid textarea {
|
||||
min-height: 220px;
|
||||
}
|
||||
.decision-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -6997,9 +7083,13 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.decision-hero,
|
||||
.decision-filter-bar,
|
||||
.decision-default-grid,
|
||||
.decision-form-grid,
|
||||
.diary-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.requirement-lanes {
|
||||
grid-template-columns: repeat(5, minmax(240px, 1fr));
|
||||
}
|
||||
.diary-entry-list,
|
||||
.diary-markdown {
|
||||
max-height: none;
|
||||
|
||||
@@ -14,6 +14,24 @@ const useState: any = React.useState;
|
||||
const recordTypes = ["all", "meeting", "decision", "goal", "blocker", "debt", "experiment"];
|
||||
const recordLevels = ["all", "G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"];
|
||||
const recordStatuses = ["all", "active", "blocked", "parked", "done"];
|
||||
const requirementViews = [
|
||||
{ id: "all", label: "全部需求" },
|
||||
{ id: "external-goal", label: "外部目标" },
|
||||
{ id: "internal-goal", label: "内部目标" },
|
||||
{ id: "blocker", label: "阻塞" },
|
||||
{ id: "parked", label: "停放事项" },
|
||||
{ id: "authority", label: "决议/实验/债务" },
|
||||
];
|
||||
const recordCategories = [
|
||||
{ id: "external-goal", label: "外部目标", type: "goal", level: "G0", status: "active", tags: ["external-goal", "requirement"] },
|
||||
{ id: "internal-goal", label: "内部目标", type: "goal", level: "G1", status: "active", tags: ["internal-goal", "requirement"] },
|
||||
{ id: "blocker", label: "阻塞", type: "blocker", level: "P0", status: "blocked", tags: ["blocker", "requirement"] },
|
||||
{ id: "parked", label: "停放事项", type: "goal", level: "G3", status: "parked", tags: ["parked", "requirement"] },
|
||||
{ id: "decision", label: "决议", type: "decision", level: "none", status: "active", tags: ["decision"] },
|
||||
{ id: "experiment", label: "实验", type: "experiment", level: "G2", status: "active", tags: ["experiment"] },
|
||||
{ id: "debt", label: "债务", type: "debt", level: "P2", status: "active", tags: ["debt"] },
|
||||
];
|
||||
const defaultDiarySourceFile = "frontend-work-diary.md";
|
||||
|
||||
function StatusBadge({ status, children }: AnyRecord) {
|
||||
const normalized = String(status || "unknown").toLowerCase();
|
||||
@@ -84,6 +102,13 @@ function statusTone(status: string): string {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function browserDateOnly(value = new Date()): string {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function fmtRecordTime(value: any): string {
|
||||
return fmtDate(value) || "--";
|
||||
}
|
||||
@@ -93,10 +118,118 @@ function shortText(value: any, max = 220): string {
|
||||
return text.length > max ? `${text.slice(0, max - 1)}...` : text;
|
||||
}
|
||||
|
||||
function RecordCard({ record, onRaw, compact }: AnyRecord) {
|
||||
function stableTestId(value: any): string {
|
||||
return String(value || "").replace(/[^A-Za-z0-9_-]+/g, "-") || "item";
|
||||
}
|
||||
|
||||
function parseCsv(value: any): string[] {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
function tagsText(value: any): string {
|
||||
return Array.isArray(value) ? value.join(", ") : String(value || "");
|
||||
}
|
||||
|
||||
function recordFormFromCategory(categoryId: string): AnyRecord {
|
||||
const category = recordCategories.find((item) => item.id === categoryId) || recordCategories[0];
|
||||
return {
|
||||
id: "",
|
||||
category: category?.id || "external-goal",
|
||||
type: category?.type || "goal",
|
||||
level: category?.level || "G0",
|
||||
status: category?.status || "active",
|
||||
title: "",
|
||||
body: "",
|
||||
linkedGoalId: "",
|
||||
tags: tagsText(category?.tags || []),
|
||||
evidenceLinks: "",
|
||||
sourceSession: "frontend",
|
||||
taskId: "",
|
||||
commitId: "",
|
||||
};
|
||||
}
|
||||
|
||||
function recordFormFromRecord(record: any): AnyRecord {
|
||||
return {
|
||||
id: record?.id || "",
|
||||
category: "custom",
|
||||
type: record?.type || "meeting",
|
||||
level: record?.level || "none",
|
||||
status: record?.status || "active",
|
||||
title: record?.title || "",
|
||||
body: record?.body || record?.summary || "",
|
||||
linkedGoalId: record?.linkedGoalId || "",
|
||||
tags: tagsText(record?.tags),
|
||||
evidenceLinks: tagsText(record?.evidenceLinks),
|
||||
sourceSession: record?.sourceSession || "frontend",
|
||||
taskId: record?.taskId || "",
|
||||
commitId: record?.commitId || "",
|
||||
};
|
||||
}
|
||||
|
||||
function recordPayloadFromForm(form: AnyRecord): AnyRecord {
|
||||
return {
|
||||
type: form.type,
|
||||
level: form.level,
|
||||
status: form.status,
|
||||
title: String(form.title || "").trim(),
|
||||
body: String(form.body || "").trim(),
|
||||
linkedGoalId: String(form.linkedGoalId || "").trim(),
|
||||
tags: parseCsv(form.tags),
|
||||
evidenceLinks: parseCsv(form.evidenceLinks),
|
||||
sourceSession: String(form.sourceSession || "").trim(),
|
||||
taskId: String(form.taskId || "").trim(),
|
||||
commitId: String(form.commitId || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function isExternalGoal(record: any): boolean {
|
||||
const tags = Array.isArray(record.tags) ? record.tags.map((tag: string) => tag.toLowerCase()) : [];
|
||||
return record.type === "goal" && record.status !== "blocked" && record.status !== "parked" && (record.level === "G0" || tags.includes("external-goal"));
|
||||
}
|
||||
|
||||
function isInternalGoal(record: any): boolean {
|
||||
const tags = Array.isArray(record.tags) ? record.tags.map((tag: string) => tag.toLowerCase()) : [];
|
||||
return record.type === "goal" && record.status !== "blocked" && record.status !== "parked" && !isExternalGoal(record) && (["G1", "G2", "G3"].includes(record.level) || tags.includes("internal-goal"));
|
||||
}
|
||||
|
||||
function recordMatchesRequirementView(record: any, view: string): boolean {
|
||||
if (view === "external-goal") return isExternalGoal(record);
|
||||
if (view === "internal-goal") return isInternalGoal(record);
|
||||
if (view === "blocker") return record.type === "blocker" || record.status === "blocked";
|
||||
if (view === "parked") return record.status === "parked";
|
||||
if (view === "authority") return ["decision", "experiment", "debt"].includes(record.type);
|
||||
return true;
|
||||
}
|
||||
|
||||
function diaryFormFromEntry(entry: any): AnyRecord {
|
||||
const date = entry?.date || browserDateOnly();
|
||||
return {
|
||||
date,
|
||||
title: entry?.title || `${date} 工作日记`,
|
||||
body: entry?.body || "",
|
||||
sourceFile: entry?.sourceFile || defaultDiarySourceFile,
|
||||
tags: tagsText(entry?.tags || ["frontend"]),
|
||||
};
|
||||
}
|
||||
|
||||
function diaryImportMarkdown(form: AnyRecord): string {
|
||||
const date = String(form.date || browserDateOnly()).trim();
|
||||
const title = String(form.title || `${date} 工作日记`).trim();
|
||||
const body = String(form.body || "").trim();
|
||||
const firstLine = body.split("\n")[0]?.trim() || "";
|
||||
if (firstLine === `# ${date}` || firstLine.startsWith(`# ${date} `) || firstLine === `# ${title}`) return body;
|
||||
return `# ${date}\n\n${body || `## ${title}\n`}`.trim();
|
||||
}
|
||||
|
||||
function RecordCard({ record, onRaw, compact, onEdit }: AnyRecord) {
|
||||
const tags = Array.isArray(record.tags) ? record.tags : [];
|
||||
const evidence = Array.isArray(record.evidenceLinks) ? record.evidenceLinks : [];
|
||||
return h("article", { className: `decision-record-card ${compact ? "compact" : ""}`, "data-testid": `decision-record-${String(record.id || "").replace(/[^A-Za-z0-9_-]+/g, "-")}` },
|
||||
return h("article", { className: `decision-record-card ${compact ? "compact" : ""}`, "data-testid": `decision-record-${stableTestId(record.id)}` },
|
||||
h("div", { className: "decision-record-head" },
|
||||
h("div", null,
|
||||
h("div", { className: "decision-record-meta" },
|
||||
@@ -106,7 +239,10 @@ function RecordCard({ record, onRaw, compact }: AnyRecord) {
|
||||
),
|
||||
h("strong", null, record.title || "--"),
|
||||
),
|
||||
h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw }),
|
||||
h("div", { className: "inline-actions" },
|
||||
onEdit ? h("button", { type: "button", className: "ghost-btn", onClick: () => onEdit(record), "data-testid": `edit-record-${stableTestId(record.id)}` }, "编辑") : null,
|
||||
h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw }),
|
||||
),
|
||||
),
|
||||
compact
|
||||
? h("p", { className: "decision-summary" }, shortText(record.summary || record.body))
|
||||
@@ -122,7 +258,7 @@ function RecordCard({ record, onRaw, compact }: AnyRecord) {
|
||||
);
|
||||
}
|
||||
|
||||
function RecordTable({ records, onRaw }: AnyRecord) {
|
||||
function RecordTable({ records, onRaw, onEdit }: AnyRecord) {
|
||||
if (!records.length) return h(EmptyState, { title: "暂无记录", text: "通过 CLI 上传会议记录或决议后会显示在这里。" });
|
||||
return h("div", { className: "table-wrap" },
|
||||
h("table", { className: "decision-table", "data-testid": "decision-center-record-table" },
|
||||
@@ -144,7 +280,10 @@ function RecordTable({ records, onRaw }: AnyRecord) {
|
||||
h("td", null, shortText(record.summary || record.body, 180)),
|
||||
h("td", null, Array.isArray(record.evidenceLinks) ? record.evidenceLinks.length : 0),
|
||||
h("td", null, fmtRecordTime(record.updatedAt)),
|
||||
h("td", null, h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw })),
|
||||
h("td", null, h("div", { className: "inline-actions" },
|
||||
onEdit ? h("button", { type: "button", className: "ghost-btn", onClick: () => onEdit(record), "data-testid": `table-edit-record-${stableTestId(record.id)}` }, "编辑") : null,
|
||||
h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw }),
|
||||
)),
|
||||
))),
|
||||
),
|
||||
);
|
||||
@@ -174,7 +313,7 @@ function diaryQuery(filters: AnyRecord): string {
|
||||
}
|
||||
|
||||
function DiaryEntryCard({ entry, selected, onSelect, onRaw }: AnyRecord) {
|
||||
return h("article", { className: `diary-entry-card ${selected ? "selected" : ""}`, "data-testid": `diary-entry-${String(entry.date || entry.id || "").replace(/[^A-Za-z0-9_-]+/g, "-")}` },
|
||||
return h("article", { className: `diary-entry-card ${selected ? "selected" : ""}`, "data-testid": `diary-entry-${stableTestId(entry.date || entry.id)}` },
|
||||
h("button", { type: "button", className: "diary-entry-main", onClick: () => onSelect(entry) },
|
||||
h("span", { className: "diary-date" }, entry.date || "--"),
|
||||
h("strong", null, entry.title || entry.markdownPath || "--"),
|
||||
@@ -188,13 +327,108 @@ function DiaryEntryCard({ entry, selected, onSelect, onRaw }: AnyRecord) {
|
||||
);
|
||||
}
|
||||
|
||||
function RequirementBoard({ records, activeView, onView, onEdit, onRaw }: AnyRecord) {
|
||||
const groups = [
|
||||
{ id: "external-goal", title: "外部目标", eyebrow: "G0 / user-facing", records: records.filter(isExternalGoal) },
|
||||
{ id: "internal-goal", title: "内部目标", eyebrow: "G1-G3 / delivery", records: records.filter(isInternalGoal) },
|
||||
{ id: "blocker", title: "阻塞", eyebrow: "P0-P1 / blocked", records: records.filter((record: any) => record.type === "blocker" || record.status === "blocked") },
|
||||
{ id: "parked", title: "停放事项", eyebrow: "Parked", records: records.filter((record: any) => record.status === "parked") },
|
||||
{ id: "authority", title: "决议/实验/债务", eyebrow: "Decision / Experiment / Debt", records: records.filter((record: any) => ["decision", "experiment", "debt"].includes(record.type)) },
|
||||
];
|
||||
return h("div", { className: "requirement-workspace", "data-testid": "requirement-workspace" },
|
||||
h("div", { className: "requirement-switcher", role: "tablist", "aria-label": "需求视图" },
|
||||
requirementViews.map((item) => h("button", {
|
||||
key: item.id,
|
||||
type: "button",
|
||||
className: activeView === item.id ? "active" : "",
|
||||
onClick: () => onView(item.id),
|
||||
"data-testid": `requirement-filter-${item.id}`,
|
||||
}, item.label)),
|
||||
),
|
||||
h("div", { className: "requirement-lanes" },
|
||||
groups.map((group) => {
|
||||
const visibleRecords = group.records.filter((record: any) => recordMatchesRequirementView(record, activeView)).slice(0, 10);
|
||||
return h("section", { key: group.id, className: "requirement-lane", "data-testid": `requirement-lane-${group.id}` },
|
||||
h("div", { className: "requirement-lane-head" },
|
||||
h("div", null,
|
||||
h("p", { className: "panel-eyebrow" }, group.eyebrow),
|
||||
h("strong", null, group.title),
|
||||
),
|
||||
h(StatusBadge, { status: group.records.length > 0 ? "unknown" : "warn" }, group.records.length),
|
||||
),
|
||||
visibleRecords.length === 0
|
||||
? h(EmptyState, { title: "暂无条目", text: "用右侧录入台补齐该类需求或筛选条件。" })
|
||||
: h("div", { className: "decision-card-list" }, visibleRecords.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, onEdit, compact: true }))),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function RecordEditor({ form, saving, message, error, onChange, onCategory, onSubmit, onReset }: AnyRecord) {
|
||||
return h("form", { className: "record-editor-form", onSubmit, "data-testid": "record-editor" },
|
||||
h("div", { className: "editor-mode-grid" },
|
||||
recordCategories.map((item) => h("button", {
|
||||
key: item.id,
|
||||
type: "button",
|
||||
className: form.category === item.id ? "active" : "",
|
||||
onClick: () => onCategory(item.id),
|
||||
"data-testid": `record-template-${item.id}`,
|
||||
}, item.label)),
|
||||
),
|
||||
h("div", { className: "decision-form-grid" },
|
||||
h("label", null, "标题", h("input", { value: form.title, onChange: (event: any) => onChange("title", event.target.value), placeholder: "需求、阻塞、决议或实验标题", "data-testid": "record-title-input" })),
|
||||
h("label", null, "类型", h("select", { value: form.type, onChange: (event: any) => onChange("type", event.target.value), "data-testid": "record-type-select" }, selectOptions(recordTypes.filter((item) => item !== "all")))),
|
||||
h("label", null, "等级", h("select", { value: form.level, onChange: (event: any) => onChange("level", event.target.value), "data-testid": "record-level-select" }, selectOptions(recordLevels.filter((item) => item !== "all")))),
|
||||
h("label", null, "状态", h("select", { value: form.status, onChange: (event: any) => onChange("status", event.target.value), "data-testid": "record-status-select" }, selectOptions(recordStatuses.filter((item) => item !== "all")))),
|
||||
h("label", null, "Linked Goal", h("input", { value: form.linkedGoalId, onChange: (event: any) => onChange("linkedGoalId", event.target.value), placeholder: "关联目标 id" })),
|
||||
h("label", null, "Tags", h("input", { value: form.tags, onChange: (event: any) => onChange("tags", event.target.value), placeholder: "external-goal, requirement" })),
|
||||
h("label", { className: "wide" }, "正文 Markdown", h("textarea", { value: form.body, onChange: (event: any) => onChange("body", event.target.value), placeholder: "- 背景\n- 验收标准\n- 下一步", "data-testid": "record-body-editor" })),
|
||||
h("label", null, "证据链接", h("input", { value: form.evidenceLinks, onChange: (event: any) => onChange("evidenceLinks", event.target.value), placeholder: "逗号分隔 URL" })),
|
||||
h("label", null, "Task ID", h("input", { value: form.taskId, onChange: (event: any) => onChange("taskId", event.target.value), placeholder: "可选" })),
|
||||
h("label", null, "Commit ID", h("input", { value: form.commitId, onChange: (event: any) => onChange("commitId", event.target.value), placeholder: "可选" })),
|
||||
),
|
||||
h("div", { className: "dispatch-actions" },
|
||||
h("button", { type: "submit", disabled: saving || !String(form.title || "").trim(), "data-testid": "save-record-button" }, saving ? "保存中" : form.id ? "保存记录" : "创建记录"),
|
||||
h("button", { type: "button", className: "ghost-btn", disabled: saving, onClick: onReset }, "新建"),
|
||||
form.id ? h("code", null, form.id) : null,
|
||||
),
|
||||
message ? h("p", { className: "muted paragraph", "data-testid": "record-editor-message" }, message) : null,
|
||||
h(UniDeskErrorBanner, { error, title: "记录保存失败", wide: true }),
|
||||
);
|
||||
}
|
||||
|
||||
function DiaryEditor({ form, saving, message, error, onChange, onToday, onSubmit }: AnyRecord) {
|
||||
return h("form", { className: "diary-editor-form", onSubmit, "data-testid": "diary-editor" },
|
||||
h("div", { className: "decision-form-grid diary-editor-grid" },
|
||||
h("label", null, "日期", h("input", { type: "date", value: form.date, onChange: (event: any) => onChange("date", event.target.value), "data-testid": "diary-date-input" })),
|
||||
h("label", null, "标题", h("input", { value: form.title, onChange: (event: any) => onChange("title", event.target.value), placeholder: "工作日记标题" })),
|
||||
h("label", null, "Source File", h("input", { value: form.sourceFile, onChange: (event: any) => onChange("sourceFile", event.target.value), placeholder: defaultDiarySourceFile })),
|
||||
h("label", null, "Tags", h("input", { value: form.tags, onChange: (event: any) => onChange("tags", event.target.value), placeholder: "frontend, daily" })),
|
||||
h("label", { className: "wide" }, "Markdown", h("textarea", { value: form.body, onChange: (event: any) => onChange("body", event.target.value), placeholder: "## 今日进展\n\n## 阻塞\n\n## 下一步", "data-testid": "diary-body-editor" })),
|
||||
),
|
||||
h("div", { className: "dispatch-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: onToday, disabled: saving, "data-testid": "today-diary-button" }, "今天"),
|
||||
h("button", { type: "submit", disabled: saving || !String(form.date || "").trim(), "data-testid": "save-diary-button" }, saving ? "保存中" : "保存日记"),
|
||||
h("code", null, `${String(form.date || browserDateOnly()).slice(0, 7)}/${form.date || browserDateOnly()}.md`),
|
||||
),
|
||||
message ? h("p", { className: "muted paragraph", "data-testid": "diary-editor-message" }, message) : null,
|
||||
h(UniDeskErrorBanner, { error, title: "日记保存失败", wide: true }),
|
||||
);
|
||||
}
|
||||
|
||||
export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
|
||||
const service = microservices.find((item: any) => item.id === "decision-center") || null;
|
||||
const [state, setState] = useState({ loading: false, error: "", health: null, records: [], refreshedAt: null });
|
||||
const [filters, setFilters] = useState({ type: "all", status: "all", level: "all", linkedGoalId: "" });
|
||||
const [activeView, setActiveView] = useState("records");
|
||||
const [requirementView, setRequirementView] = useState("all");
|
||||
const [recordForm, setRecordForm] = useState(recordFormFromCategory("external-goal"));
|
||||
const [recordSaveState, setRecordSaveState] = useState({ saving: false, error: "", message: "" });
|
||||
const [diaryState, setDiaryState] = useState({ loading: false, error: "", entries: [], months: [], selected: null, refreshedAt: null });
|
||||
const [diaryFilters, setDiaryFilters] = useState({ month: "all", from: "", to: "" });
|
||||
const [diaryForm, setDiaryForm] = useState(diaryFormFromEntry({ date: browserDateOnly(), title: `${browserDateOnly()} 工作日记`, tags: ["frontend"] }));
|
||||
const [diarySaveState, setDiarySaveState] = useState({ saving: false, error: "", message: "" });
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (!service) return;
|
||||
@@ -224,14 +458,16 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
const entriesResponse = await requestJson(decisionApi(apiBaseUrl, `/api/diary/entries?${diaryQuery(diaryFilters)}`));
|
||||
const monthsResponse = await requestJson(decisionApi(apiBaseUrl, "/api/diary/months"));
|
||||
const entries = Array.isArray(entriesResponse.entries) ? entriesResponse.entries : [];
|
||||
const nextSelected = prevSelectedDiary(entries, diaryState.selected);
|
||||
setDiaryState((prev: any) => ({
|
||||
loading: false,
|
||||
error: "",
|
||||
entries,
|
||||
months: Array.isArray(monthsResponse.months) ? monthsResponse.months : prev.months,
|
||||
selected: prev.selected && entries.some((entry: any) => entry.id === prev.selected?.id) ? prev.selected : entries[0] || null,
|
||||
selected: nextSelected,
|
||||
refreshedAt: new Date(),
|
||||
}));
|
||||
if (nextSelected && !nextSelected.body) void selectDiaryEntry(nextSelected);
|
||||
} catch (err) {
|
||||
setDiaryState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "日记加载失败") }));
|
||||
}
|
||||
@@ -241,12 +477,120 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
setDiaryState((prev: any) => ({ ...prev, selected: entry }));
|
||||
try {
|
||||
const response = await requestJson(decisionApi(apiBaseUrl, `/api/diary/entries/${encodeURIComponent(entry.date || entry.id)}`));
|
||||
setDiaryState((prev: any) => ({ ...prev, selected: response.entry || entry }));
|
||||
const selected = response.entry || entry;
|
||||
setDiaryState((prev: any) => ({ ...prev, selected }));
|
||||
setDiaryForm(diaryFormFromEntry(selected));
|
||||
} catch (err) {
|
||||
setDiaryState((prev: any) => ({ ...prev, error: errorMessage(err, "日记正文加载失败") }));
|
||||
}
|
||||
}
|
||||
|
||||
function resetRecordForm(category = "external-goal"): void {
|
||||
setRecordForm(recordFormFromCategory(category));
|
||||
setRecordSaveState({ saving: false, error: "", message: "" });
|
||||
}
|
||||
|
||||
function changeRecordForm(field: string, value: any): void {
|
||||
setRecordForm((prev: any) => ({ ...prev, [field]: value, category: field === "type" || field === "level" || field === "status" ? "custom" : prev.category }));
|
||||
}
|
||||
|
||||
function selectRecordCategory(category: string): void {
|
||||
const next = recordFormFromCategory(category);
|
||||
setRecordForm((prev: any) => ({
|
||||
...prev,
|
||||
...next,
|
||||
id: prev.id || "",
|
||||
title: prev.title || "",
|
||||
body: prev.body || "",
|
||||
linkedGoalId: prev.linkedGoalId || "",
|
||||
evidenceLinks: prev.evidenceLinks || "",
|
||||
sourceSession: prev.sourceSession || "frontend",
|
||||
taskId: prev.taskId || "",
|
||||
commitId: prev.commitId || "",
|
||||
}));
|
||||
}
|
||||
|
||||
function editRecord(record: any): void {
|
||||
setRecordForm(recordFormFromRecord(record));
|
||||
setRecordSaveState({ saving: false, error: "", message: `正在编辑 ${record?.id || ""}` });
|
||||
setActiveView("records");
|
||||
}
|
||||
|
||||
async function saveRecord(event: any): Promise<void> {
|
||||
event.preventDefault();
|
||||
const payload = recordPayloadFromForm(recordForm);
|
||||
if (!payload.title) {
|
||||
setRecordSaveState({ saving: false, error: "标题不能为空", message: "" });
|
||||
return;
|
||||
}
|
||||
setRecordSaveState({ saving: true, error: "", message: "" });
|
||||
try {
|
||||
const editingId = String(recordForm.id || "").trim();
|
||||
const response = await requestJson(
|
||||
decisionApi(apiBaseUrl, editingId ? `/api/records/${encodeURIComponent(editingId)}` : "/api/records"),
|
||||
{ method: editingId ? "PUT" : "POST", body: payload },
|
||||
);
|
||||
const saved = response.record;
|
||||
setRecordForm(recordFormFromRecord(saved || { ...payload, id: editingId }));
|
||||
setRecordSaveState({ saving: false, error: "", message: `${editingId ? "已保存" : "已创建"} ${saved?.id || editingId || ""}` });
|
||||
await load();
|
||||
} catch (err) {
|
||||
setRecordSaveState({ saving: false, error: errorMessage(err, "记录保存失败"), message: "" });
|
||||
}
|
||||
}
|
||||
|
||||
function changeDiaryForm(field: string, value: any): void {
|
||||
setDiaryForm((prev: any) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
async function openTodayDiary(): Promise<void> {
|
||||
const today = browserDateOnly();
|
||||
setActiveView("diary");
|
||||
setDiarySaveState({ saving: false, error: "", message: "" });
|
||||
try {
|
||||
const response = await requestJson(decisionApi(apiBaseUrl, `/api/diary/entries/${encodeURIComponent(today)}`));
|
||||
const entry = response.entry;
|
||||
setDiaryState((prev: any) => ({ ...prev, selected: entry || prev.selected }));
|
||||
setDiaryForm(diaryFormFromEntry(entry || { date: today, title: `${today} 工作日记`, tags: ["frontend"] }));
|
||||
setDiarySaveState({ saving: false, error: "", message: entry ? `已打开今天 ${today}` : `已准备今天 ${today}` });
|
||||
} catch {
|
||||
setDiaryForm(diaryFormFromEntry({ date: today, title: `${today} 工作日记`, body: `## 今日进展\n\n## 阻塞\n\n## 下一步\n`, sourceFile: defaultDiarySourceFile, tags: ["frontend"] }));
|
||||
setDiarySaveState({ saving: false, error: "", message: `今天 ${today} 尚未存在,保存后自动创建` });
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDiary(event: any): Promise<void> {
|
||||
event.preventDefault();
|
||||
const date = String(diaryForm.date || "").trim();
|
||||
if (!date) {
|
||||
setDiarySaveState({ saving: false, error: "日期不能为空", message: "" });
|
||||
return;
|
||||
}
|
||||
setDiarySaveState({ saving: true, error: "", message: "" });
|
||||
try {
|
||||
const markdown = diaryImportMarkdown(diaryForm);
|
||||
const response = await requestJson(decisionApi(apiBaseUrl, "/api/diary/import"), {
|
||||
method: "POST",
|
||||
body: {
|
||||
markdown,
|
||||
sourceFile: String(diaryForm.sourceFile || defaultDiarySourceFile).trim() || defaultDiarySourceFile,
|
||||
tags: parseCsv(diaryForm.tags),
|
||||
},
|
||||
});
|
||||
const entry = Array.isArray(response.entries) ? response.entries.find((item: any) => item.date === date) || response.entries[0] : null;
|
||||
setDiarySaveState({ saving: false, error: "", message: `已保存 ${date} / ${response.pathPattern || "YYYY-MM/YYYY-MM-DD.md"}` });
|
||||
await loadDiary();
|
||||
if (entry) await selectDiaryEntry(entry);
|
||||
} catch (err) {
|
||||
setDiarySaveState({ saving: false, error: errorMessage(err, "日记保存失败"), message: "" });
|
||||
}
|
||||
}
|
||||
|
||||
function prevSelectedDiary(entries: any[], selected: any): any {
|
||||
if (selected && entries.some((entry: any) => entry.id === selected.id)) return selected;
|
||||
return entries[0] || null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [service?.id, service?.runtime?.providerStatus]);
|
||||
@@ -268,10 +612,13 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
const repository = microserviceRepository(service);
|
||||
const backend = microserviceBackend(service);
|
||||
const records = Array.isArray(state.records) ? state.records : [];
|
||||
const goals = records.filter((record: any) => record.type === "goal" && ["G0", "G1"].includes(record.level) && record.status !== "done").slice(0, 8);
|
||||
const blockers = records.filter((record: any) => record.type === "blocker" && ["P0", "P1"].includes(record.level) && record.status !== "done").slice(0, 8);
|
||||
const externalGoals = records.filter((record: any) => isExternalGoal(record) && record.status !== "done");
|
||||
const internalGoals = records.filter((record: any) => isInternalGoal(record) && record.status !== "done");
|
||||
const blockers = records.filter((record: any) => (record.type === "blocker" || record.status === "blocked") && record.status !== "done").slice(0, 8);
|
||||
const parked = records.filter((record: any) => record.status === "parked").slice(0, 8);
|
||||
const authorityRecords = records.filter((record: any) => ["decision", "experiment", "debt"].includes(record.type)).slice(0, 12);
|
||||
const recentMeetings = records.filter((record: any) => record.type === "meeting" || record.type === "decision").slice(0, 12);
|
||||
const requirementFilteredRecords = records.filter((record: any) => recordMatchesRequirementView(record, requirementView));
|
||||
const diaryEntries = Array.isArray(diaryState.entries) ? diaryState.entries : [];
|
||||
const diaryMonths = Array.isArray(diaryState.months) ? diaryState.months : [];
|
||||
const selectedDiary = diaryState.selected;
|
||||
@@ -286,7 +633,7 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
h("div", { className: "metric-grid" },
|
||||
h(MetricCard, { label: "记录数", value: records.length, hint: `PostgreSQL / ${state.health?.storage || "postgres"}`, tone: "ok" }),
|
||||
h(MetricCard, { label: "日记", value: diaryCount, hint: "按月 Markdown", tone: diaryCount > 0 ? "ok" : "warn" }),
|
||||
h(MetricCard, { label: "G0/G1 目标", value: goals.length, hint: "active authority goals", tone: "ok" }),
|
||||
h(MetricCard, { label: "外部/内部目标", value: `${externalGoals.length}/${internalGoals.length}`, hint: "G0-facing / G1-G3 delivery", tone: "ok" }),
|
||||
h(MetricCard, { label: "P0/P1 Blocker", value: blockers.length, hint: "requires decision", tone: blockers.length > 0 ? "warn" : "ok" }),
|
||||
),
|
||||
h("div", { className: "microservice-ref-card" },
|
||||
@@ -299,10 +646,23 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
h(UniDeskErrorBanner, { error: state.error, title: "Decision Center 请求失败" }),
|
||||
),
|
||||
h("div", { className: "decision-tabs", role: "tablist" },
|
||||
h("button", { type: "button", className: activeView === "records" ? "active" : "", onClick: () => setActiveView("records") }, "权威记录"),
|
||||
h("button", { type: "button", className: activeView === "diary" ? "active" : "", onClick: () => setActiveView("diary") }, "工作日记"),
|
||||
h("button", { type: "button", className: activeView === "records" ? "active" : "", onClick: () => setActiveView("records"), "data-testid": "decision-tab-requirements" }, "需求管理"),
|
||||
h("button", { type: "button", className: activeView === "diary" ? "active" : "", onClick: () => setActiveView("diary"), "data-testid": "decision-tab-diary" }, "工作日记"),
|
||||
),
|
||||
activeView === "diary" ? h(React.Fragment, null,
|
||||
h(Panel, { title: "工作日记编辑台", eyebrow: "Daily Markdown", loading: diarySaveState.saving, actions: h("div", { className: "inline-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => void openTodayDiary(), disabled: diarySaveState.saving, "data-testid": "today-diary" }, "今天"),
|
||||
) },
|
||||
h(DiaryEditor, {
|
||||
form: diaryForm,
|
||||
saving: diarySaveState.saving,
|
||||
message: diarySaveState.message,
|
||||
error: diarySaveState.error,
|
||||
onChange: changeDiaryForm,
|
||||
onToday: () => void openTodayDiary(),
|
||||
onSubmit: (event: any) => void saveDiary(event),
|
||||
}),
|
||||
),
|
||||
h(Panel, { title: "日记筛选", eyebrow: "Markdown by Month", loading: diaryState.loading, actions: h("div", { className: "inline-actions" },
|
||||
h("button", { type: "button", className: "ghost-btn", onClick: () => void loadDiary(), disabled: diaryState.loading }, diaryState.loading ? "刷新中" : "刷新"),
|
||||
h(RawButton, { title: "Diary Months", data: diaryMonths, onOpen: onRaw, testId: "raw-decision-center-diary-months" }),
|
||||
@@ -321,7 +681,7 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
h("div", { className: "diary-layout" },
|
||||
h(Panel, { title: "按天条目", eyebrow: `${diaryEntries.length} Entries`, loading: diaryState.loading },
|
||||
diaryEntries.length === 0
|
||||
? h(EmptyState, { title: "暂无日记", text: "使用 CLI 导入按日期标题拆分的工作日志 Markdown。" })
|
||||
? h(EmptyState, { title: "暂无日记", text: "点击“今天”后可自动创建当天 Markdown 日记。" })
|
||||
: h("div", { className: "diary-entry-list" }, diaryEntries.map((entry: any) => h(DiaryEntryCard, { key: entry.id, entry, selected: selectedDiary?.id === entry.id, onSelect: selectDiaryEntry, onRaw }))),
|
||||
),
|
||||
h(Panel, { title: selectedDiary?.title || "日记正文", eyebrow: selectedDiary?.markdownPath || "Daily Markdown", actions: selectedDiary ? h(RawButton, { title: `Diary ${selectedDiary.date}`, data: selectedDiary, onOpen: onRaw, testId: "raw-decision-center-diary-selected" }) : null },
|
||||
@@ -331,6 +691,21 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
),
|
||||
),
|
||||
) : h(React.Fragment, null,
|
||||
h(Panel, { title: "需求管理工作区", eyebrow: `${requirementFilteredRecords.length} Filtered`, loading: state.loading },
|
||||
h(RequirementBoard, { records, activeView: requirementView, onView: setRequirementView, onEdit: editRecord, onRaw }),
|
||||
),
|
||||
h(Panel, { title: recordForm.id ? "编辑需求记录" : "录入需求记录", eyebrow: "Record Editor", loading: recordSaveState.saving },
|
||||
h(RecordEditor, {
|
||||
form: recordForm,
|
||||
saving: recordSaveState.saving,
|
||||
message: recordSaveState.message,
|
||||
error: recordSaveState.error,
|
||||
onChange: changeRecordForm,
|
||||
onCategory: selectRecordCategory,
|
||||
onSubmit: (event: any) => void saveRecord(event),
|
||||
onReset: () => resetRecordForm(),
|
||||
}),
|
||||
),
|
||||
h(Panel, { title: "筛选", eyebrow: "Type / Status / Level" },
|
||||
h("div", { className: "decision-filter-bar", "data-testid": "decision-center-filters" },
|
||||
h("label", null, "类型", h("select", { value: filters.type, onChange: (event: any) => setFilters((prev: any) => ({ ...prev, type: event.target.value })) }, selectOptions(recordTypes))),
|
||||
@@ -340,25 +715,33 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
),
|
||||
),
|
||||
h("div", { className: "decision-default-grid" },
|
||||
h(Panel, { title: "G0/G1 目标", eyebrow: `${goals.length} Goals` },
|
||||
goals.length === 0 ? h(EmptyState, { title: "暂无当前目标", text: "目标记录使用 type=goal 且 level=G0/G1。" }) :
|
||||
h("div", { className: "decision-card-list" }, goals.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, compact: true }))),
|
||||
h(Panel, { title: "外部目标", eyebrow: `${externalGoals.length} External Goals` },
|
||||
externalGoals.length === 0 ? h(EmptyState, { title: "暂无外部目标", text: "外部目标使用 G0 或 external-goal tag。" }) :
|
||||
h("div", { className: "decision-card-list" }, externalGoals.slice(0, 8).map((record: any) => h(RecordCard, { key: record.id, record, onRaw, onEdit: editRecord, compact: true }))),
|
||||
),
|
||||
h(Panel, { title: "内部目标", eyebrow: `${internalGoals.length} Internal Goals` },
|
||||
internalGoals.length === 0 ? h(EmptyState, { title: "暂无内部目标", text: "内部目标使用 G1/G2/G3 或 internal-goal tag。" }) :
|
||||
h("div", { className: "decision-card-list" }, internalGoals.slice(0, 8).map((record: any) => h(RecordCard, { key: record.id, record, onRaw, onEdit: editRecord, compact: true }))),
|
||||
),
|
||||
h(Panel, { title: "P0/P1 Blocker", eyebrow: `${blockers.length} Blockers` },
|
||||
blockers.length === 0 ? h(EmptyState, { title: "暂无高优先级阻塞", text: "阻塞记录使用 type=blocker 且 level=P0/P1。" }) :
|
||||
h("div", { className: "decision-card-list" }, blockers.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, compact: true }))),
|
||||
h("div", { className: "decision-card-list" }, blockers.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, onEdit: editRecord, compact: true }))),
|
||||
),
|
||||
h(Panel, { title: "停放事项", eyebrow: `${parked.length} Parked` },
|
||||
parked.length === 0 ? h(EmptyState, { title: "暂无停放事项", text: "status=parked 的记录会集中展示。" }) :
|
||||
h("div", { className: "decision-card-list" }, parked.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, compact: true }))),
|
||||
h("div", { className: "decision-card-list" }, parked.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, onEdit: editRecord, compact: true }))),
|
||||
),
|
||||
h(Panel, { title: "决议/实验/债务", eyebrow: `${authorityRecords.length} Authority` },
|
||||
authorityRecords.length === 0 ? h(EmptyState, { title: "暂无权威事项", text: "决议、实验和债务会集中展示。" }) :
|
||||
h("div", { className: "decision-card-list" }, authorityRecords.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, onEdit: editRecord, compact: true }))),
|
||||
),
|
||||
h(Panel, { title: "最近会议/决议", eyebrow: `${recentMeetings.length} Recent` },
|
||||
recentMeetings.length === 0 ? h(EmptyState, { title: "暂无会议或决议", text: "使用 CLI 上传 Markdown 会议记录后会显示。" }) :
|
||||
h("div", { className: "decision-card-list" }, recentMeetings.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, compact: true }))),
|
||||
h("div", { className: "decision-card-list" }, recentMeetings.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, onEdit: editRecord, compact: true }))),
|
||||
),
|
||||
),
|
||||
h(Panel, { title: "全部记录", eyebrow: `${records.length} Records`, actions: state.refreshedAt ? h("span", { className: "muted" }, `刷新 ${fmtClock(state.refreshedAt)}`) : null },
|
||||
h(RecordTable, { records, onRaw }),
|
||||
h(RecordTable, { records, onRaw, onEdit: editRecord }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user