feat: add decision center diary imports

This commit is contained in:
Codex
2026-05-18 02:36:40 +00:00
parent feb2550fac
commit 54c1f8e165
10 changed files with 736 additions and 95 deletions
File diff suppressed because one or more lines are too long
+80 -1
View File
@@ -6672,6 +6672,80 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
gap: 10px;
align-items: start;
}
.decision-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.decision-tabs button {
min-height: 34px;
padding: 0 12px;
border: 1px solid var(--line-soft);
color: var(--muted);
background: var(--panel-3);
cursor: pointer;
}
.decision-tabs button.active,
.decision-tabs button:hover {
border-color: rgba(78, 183, 168, 0.5);
color: var(--text);
background: rgba(78, 183, 168, 0.12);
}
.diary-layout {
display: grid;
grid-template-columns: minmax(320px, 0.42fr) minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.diary-entry-list {
display: grid;
gap: 8px;
max-height: calc(100vh - 250px);
overflow: auto;
padding-right: 2px;
}
.diary-entry-card {
display: grid;
gap: 8px;
min-width: 0;
padding: 9px;
border: 1px solid var(--line-soft);
background: rgba(0,0,0,0.14);
}
.diary-entry-card.selected {
border-color: rgba(78, 183, 168, 0.55);
background: rgba(78, 183, 168, 0.09);
}
.diary-entry-main {
display: grid;
gap: 4px;
width: 100%;
padding: 0;
border: 0;
color: inherit;
background: transparent;
text-align: left;
cursor: pointer;
}
.diary-date {
color: var(--accent-2);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.diary-entry-main strong {
min-width: 0;
overflow-wrap: anywhere;
font-size: 14px;
}
.diary-entry-main span:last-child {
color: var(--muted);
line-height: 1.45;
}
.diary-markdown {
max-height: calc(100vh - 250px);
}
.decision-card-list {
display: grid;
gap: 8px;
@@ -6761,7 +6835,12 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.mdtodo-layout,
.decision-hero,
.decision-filter-bar,
.decision-default-grid {
.decision-default-grid,
.diary-layout {
grid-template-columns: 1fr;
}
.diary-entry-list,
.diary-markdown {
max-height: none;
}
}
+104 -1
View File
@@ -164,10 +164,37 @@ function filteredQuery(filters: AnyRecord): string {
return params.toString();
}
function diaryQuery(filters: AnyRecord): string {
const params = new URLSearchParams();
if (filters.month !== "all") params.set("month", filters.month);
if (filters.from.trim()) params.set("from", filters.from.trim());
if (filters.to.trim()) params.set("to", filters.to.trim());
params.set("limit", "180");
return params.toString();
}
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, "-")}` },
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 || "--"),
h("span", null, shortText(entry.summary || entry.body, 180)),
),
h("div", { className: "decision-record-foot" },
h("code", null, entry.markdownPath || "--"),
h("span", null, fmtRecordTime(entry.updatedAt || entry.importedAt)),
h(RawButton, { title: `Diary ${entry.date || entry.id}`, data: entry, onOpen: onRaw }),
),
);
}
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 [diaryState, setDiaryState] = useState({ loading: false, error: "", entries: [], months: [], selected: null, refreshedAt: null });
const [diaryFilters, setDiaryFilters] = useState({ month: "all", from: "", to: "" });
async function load(): Promise<void> {
if (!service) return;
@@ -175,6 +202,7 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
try {
const query = filteredQuery(filters);
const records = await requestJson(decisionApi(apiBaseUrl, `/api/records?${query}`));
const months = await requestJson(decisionApi(apiBaseUrl, "/api/diary/months"));
let health: any = state.health;
let healthError = "";
try {
@@ -183,11 +211,42 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
healthError = errorMessage(err, "Decision Center health 检查失败");
}
setState({ loading: false, error: healthError, health, records: Array.isArray(records.records) ? records.records : [], refreshedAt: new Date() });
setDiaryState((prev: any) => ({ ...prev, months: Array.isArray(months.months) ? months.months : [] }));
} catch (err) {
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Decision Center 加载失败") }));
}
}
async function loadDiary(): Promise<void> {
if (!service) return;
setDiaryState((prev: any) => ({ ...prev, loading: true, error: "" }));
try {
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 : [];
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,
refreshedAt: new Date(),
}));
} catch (err) {
setDiaryState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "日记加载失败") }));
}
}
async function selectDiaryEntry(entry: any): Promise<void> {
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 }));
} catch (err) {
setDiaryState((prev: any) => ({ ...prev, error: errorMessage(err, "日记正文加载失败") }));
}
}
useEffect(() => {
load();
}, [service?.id, service?.runtime?.providerStatus]);
@@ -197,6 +256,12 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
return () => clearTimeout(timer);
}, [filters.type, filters.status, filters.level, filters.linkedGoalId]);
useEffect(() => {
if (activeView !== "diary") return;
const timer = setTimeout(() => void loadDiary(), 120);
return () => clearTimeout(timer);
}, [activeView, diaryFilters.month, diaryFilters.from, diaryFilters.to, service?.id, service?.runtime?.providerStatus]);
if (!service) return h(EmptyState, { title: "Decision Center 未登记", text: "请在 config.json 的 microservices 中登记用户服务 id=decision-center" });
const runtime = microserviceRuntime(service);
@@ -207,6 +272,10 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
const blockers = records.filter((record: any) => record.type === "blocker" && ["P0", "P1"].includes(record.level) && record.status !== "done").slice(0, 8);
const parked = records.filter((record: any) => record.status === "parked").slice(0, 8);
const recentMeetings = records.filter((record: any) => record.type === "meeting" || record.type === "decision").slice(0, 12);
const diaryEntries = Array.isArray(diaryState.entries) ? diaryState.entries : [];
const diaryMonths = Array.isArray(diaryState.months) ? diaryState.months : [];
const selectedDiary = diaryState.selected;
const diaryCount = Number(state.health?.diaryEntryCount ?? 0);
return h("div", { className: "decision-center-page", "data-testid": "decision-center-page" },
h(Panel, { title: "Decision Center", eyebrow: "Authority Records", loading: state.loading, actions: h("div", { className: "inline-actions" },
@@ -216,9 +285,9 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
h("div", { className: "decision-hero" },
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: "P0/P1 Blocker", value: blockers.length, hint: "requires decision", tone: blockers.length > 0 ? "warn" : "ok" }),
h(MetricCard, { label: "Parked", value: parked.length, hint: "停放事项", tone: parked.length > 0 ? "warn" : "ok" }),
),
h("div", { className: "microservice-ref-card" },
h("span", null, "Runtime"),
@@ -229,6 +298,39 @@ 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") }, "工作日记"),
),
activeView === "diary" ? h(React.Fragment, null,
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" }),
) },
h("div", { className: "decision-filter-bar" },
h("label", null, "月份", h("select", { value: diaryFilters.month, onChange: (event: any) => setDiaryFilters((prev: any) => ({ ...prev, month: event.target.value })) },
h("option", { value: "all" }, "all"),
diaryMonths.map((month: any) => h("option", { key: month.month, value: month.month }, `${month.month} (${month.count})`)),
)),
h("label", null, "开始日期", h("input", { type: "date", value: diaryFilters.from, onChange: (event: any) => setDiaryFilters((prev: any) => ({ ...prev, from: event.target.value })) })),
h("label", null, "结束日期", h("input", { type: "date", value: diaryFilters.to, onChange: (event: any) => setDiaryFilters((prev: any) => ({ ...prev, to: event.target.value })) })),
h("label", null, "存储", h("input", { value: "PostgreSQL / YYYY-MM/YYYY-MM-DD.md", readOnly: true })),
),
h(UniDeskErrorBanner, { error: diaryState.error, title: "日记请求失败" }),
),
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("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 },
selectedDiary
? h(MarkdownBody, { markdown: selectedDiary.body || selectedDiary.summary || "", className: "decision-markdown diary-markdown" })
: h(EmptyState, { title: "未选择日记", text: "从左侧选择一天查看 Markdown 正文。" }),
),
),
) : h(React.Fragment, null,
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))),
@@ -258,5 +360,6 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
h(Panel, { title: "全部记录", eyebrow: `${records.length} Records`, actions: state.refreshedAt ? h("span", { className: "muted" }, `刷新 ${fmtClock(state.refreshedAt)}`) : null },
h(RecordTable, { records, onRaw }),
),
),
);
}