feat: add decision center service and cli

This commit is contained in:
Codex
2026-05-17 06:49:42 +00:00
parent 2b2a327301
commit 4d9eed6513
32 changed files with 1758 additions and 48 deletions
+115 -2
View File
@@ -1422,7 +1422,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.result-card dd { margin: 0; }
.result-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.microservice-page, .findjob-page, .pipeline-page, .met-page, .code-queue-page, .baidu-netdisk-page, .filebrowser-page, .oa-event-flow-page {
.microservice-page, .findjob-page, .pipeline-page, .met-page, .code-queue-page, .baidu-netdisk-page, .filebrowser-page, .oa-event-flow-page, .decision-center-page {
display: grid;
gap: 10px;
}
@@ -6617,8 +6617,121 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
overflow-wrap: anywhere;
}
.decision-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 0.36fr);
gap: 10px;
align-items: stretch;
}
.decision-filter-bar {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 10px;
}
.decision-filter-bar label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.decision-default-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
align-items: start;
}
.decision-card-list {
display: grid;
gap: 8px;
}
.decision-record-card {
display: grid;
gap: 8px;
min-width: 0;
padding: 10px;
border: 1px solid var(--line-soft);
background: rgba(0,0,0,0.16);
}
.decision-record-card.compact {
padding: 9px;
}
.decision-record-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
}
.decision-record-head strong {
display: block;
min-width: 0;
margin-top: 4px;
overflow-wrap: anywhere;
font-size: 14px;
}
.decision-record-meta,
.decision-record-foot,
.decision-tags,
.decision-evidence {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.decision-record-meta span:first-child {
color: var(--accent);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.decision-summary {
margin: 0;
color: var(--muted);
line-height: 1.45;
overflow-wrap: anywhere;
}
.decision-markdown {
max-height: 280px;
overflow: auto;
padding-right: 4px;
}
.decision-record-foot {
color: var(--muted);
font-size: 11px;
}
.decision-record-foot code {
white-space: normal;
overflow-wrap: anywhere;
}
.decision-tags span {
padding: 2px 6px;
border: 1px solid rgba(78, 183, 168, 0.34);
color: var(--accent-2);
background: rgba(78, 183, 168, 0.08);
font-size: 11px;
}
.decision-evidence a {
color: var(--text);
text-decoration: none;
overflow-wrap: anywhere;
}
.decision-table th,
.decision-table td {
vertical-align: top;
}
.decision-table td strong,
.decision-table td code {
display: block;
min-width: 0;
overflow-wrap: anywhere;
}
@media (max-width: 1100px) {
.mdtodo-layout {
.mdtodo-layout,
.decision-hero,
.decision-filter-bar,
.decision-default-grid {
grid-template-columns: 1fr;
}
}
+3
View File
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
import { BaiduNetdiskPage } from "./baidu-netdisk";
import { ClaudeQqPage } from "./claudeqq";
import { CodeQueuePage } from "./code-queue";
import { DecisionCenterPage } from "./decision-center";
import { FileBrowserPage } from "./filebrowser";
import { FindJobPage } from "./findjob";
import { MetNonlinearPage } from "./met-nonlinear";
@@ -1652,6 +1653,7 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord
service.id === "k3sctl-adapter" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "k3sctl"), "data-testid": "open-k3sctl-button" }, "打开") : null,
service.id === "code-queue" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "code-queue"), "data-testid": "open-code-queue-button" }, "打开") : null,
service.id === "mdtodo" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "mdtodo"), "data-testid": "open-mdtodo-button" }, "打开") : null,
service.id === "decision-center" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "decision-center"), "data-testid": "open-decision-center-button" }, "打开") : null,
service.id === "project-manager" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "project-manager"), "data-testid": "open-project-manager-button" }, "打开") : null,
h(RawButton, { title: `用户服务 ${service.id}`, data: service, onOpen: onRaw }),
),
@@ -2160,6 +2162,7 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
if (activeModule === "apps" && activeTab === "k3sctl") return h(K3sCtlPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl, onNavigate });
if (activeModule === "apps" && activeTab === "code-queue") return h(CodeQueuePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl, initialTasksData: initialCodeQueueOverview });
if (activeModule === "apps" && activeTab === "mdtodo") return h(MdtodoPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "decision-center") return h(DecisionCenterPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "project-manager") return h(ProjectManagerPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "config" && activeTab === "topology") return h(TopologyPage, { data });
if (activeModule === "config" && activeTab === "auth") return h(AuthPage, { session });
@@ -0,0 +1,258 @@
import React from "react";
import { fmtClock, fmtDate } from "./time";
import { LoadingTitle } from "./loading-indicator";
import { MarkdownBody } from "./markdown";
import { errorMessage, requestJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
type AnyRecord = Record<string, any>;
const h = React.createElement;
const { useEffect } = React;
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"];
function StatusBadge({ status, children }: AnyRecord) {
const normalized = String(status || "unknown").toLowerCase();
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
}
function MetricCard({ label, value, hint, tone }: AnyRecord) {
return h("article", { className: `metric-card ${tone || ""}` },
h("div", { className: "metric-label" }, label),
h("div", { className: "metric-value" }, value),
h("div", { className: "metric-hint" }, hint),
);
}
function Panel({ title, eyebrow, actions, children, className, loading }: AnyRecord) {
return h("section", { className: `panel ${className || ""}` },
h("div", { className: "panel-head" },
h("div", null,
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
h(LoadingTitle, { title, loading }),
),
actions ? h("div", { className: "panel-actions" }, actions) : null,
),
h("div", { className: "panel-body" }, children),
);
}
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
return h("button", {
type: "button",
className: "ghost-btn",
"data-testid": testId,
onClick: () => onOpen(title, data),
}, "查看原始JSON");
}
function EmptyState({ title, text }: AnyRecord) {
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
}
function microserviceRuntime(service: any): AnyRecord {
return service?.runtime && typeof service.runtime === "object" && !Array.isArray(service.runtime) ? service.runtime : {};
}
function microserviceBackend(service: any): AnyRecord {
return service?.backend && typeof service.backend === "object" && !Array.isArray(service.backend) ? service.backend : {};
}
function microserviceRepository(service: any): AnyRecord {
return service?.repository && typeof service.repository === "object" && !Array.isArray(service.repository) ? service.repository : {};
}
function decisionApi(apiBaseUrl: string, path: string): string {
return `${apiBaseUrl}/microservices/decision-center/proxy${path}`;
}
function levelTone(level: string): string {
if (level === "G0" || level === "G1") return "online";
if (level === "P0" || level === "P1") return "failed";
if (level === "none") return "unknown";
return "warn";
}
function statusTone(status: string): string {
if (status === "done") return "online";
if (status === "blocked") return "failed";
if (status === "parked") return "warn";
return "unknown";
}
function fmtRecordTime(value: any): string {
return fmtDate(value) || "--";
}
function shortText(value: any, max = 220): string {
const text = String(value || "").replace(/\s+/gu, " ").trim();
return text.length > max ? `${text.slice(0, max - 1)}...` : text;
}
function RecordCard({ record, onRaw, compact }: 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, "-")}` },
h("div", { className: "decision-record-head" },
h("div", null,
h("div", { className: "decision-record-meta" },
h("span", null, record.type || "--"),
h(StatusBadge, { status: levelTone(record.level) }, record.level || "none"),
h(StatusBadge, { status: statusTone(record.status) }, record.status || "--"),
),
h("strong", null, record.title || "--"),
),
h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw }),
),
compact
? h("p", { className: "decision-summary" }, shortText(record.summary || record.body))
: h(MarkdownBody, { markdown: record.body || record.summary || "", className: "decision-markdown" }),
h("div", { className: "decision-record-foot" },
record.linkedGoalId ? h("code", null, `goal:${record.linkedGoalId}`) : null,
record.taskId ? h("code", null, `task:${record.taskId}`) : null,
record.commitId ? h("code", null, record.commitId.slice(0, 12)) : null,
h("span", null, fmtRecordTime(record.updatedAt)),
),
tags.length > 0 ? h("div", { className: "decision-tags" }, tags.slice(0, 8).map((tag: string) => h("span", { key: tag }, tag))) : null,
evidence.length > 0 ? h("div", { className: "decision-evidence" }, evidence.slice(0, 4).map((link: string) => h("a", { key: link, href: link, target: "_blank", rel: "noreferrer" }, shortText(link, 58)))) : null,
);
}
function RecordTable({ records, onRaw }: 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" },
h("thead", null, h("tr", null,
h("th", null, "等级"),
h("th", null, "状态"),
h("th", null, "类型"),
h("th", null, "标题"),
h("th", null, "摘要"),
h("th", null, "证据"),
h("th", null, "更新"),
h("th", null, "操作"),
)),
h("tbody", null, records.map((record: any) => h("tr", { key: record.id },
h("td", null, h(StatusBadge, { status: levelTone(record.level) }, record.level || "none")),
h("td", null, h(StatusBadge, { status: statusTone(record.status) }, record.status || "--")),
h("td", null, record.type || "--"),
h("td", null, h("strong", null, record.title || "--"), record.linkedGoalId ? h("code", null, record.linkedGoalId) : null),
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 })),
))),
),
);
}
function selectOptions(values: string[]): any[] {
return values.map((value) => h("option", { key: value, value }, value));
}
function filteredQuery(filters: AnyRecord): string {
const params = new URLSearchParams();
if (filters.type !== "all") params.set("type", filters.type);
if (filters.status !== "all") params.set("status", filters.status);
if (filters.level !== "all") params.set("level", filters.level);
if (filters.linkedGoalId.trim()) params.set("linkedGoalId", filters.linkedGoalId.trim());
params.set("limit", "240");
return params.toString();
}
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: "" });
async function load(): Promise<void> {
if (!service) return;
setState((prev: any) => ({ ...prev, loading: true, error: "" }));
try {
const query = filteredQuery(filters);
const [health, records] = await Promise.all([
requestJson(`${apiBaseUrl}/microservices/decision-center/health`),
requestJson(decisionApi(apiBaseUrl, `/api/records?${query}`)),
]);
setState({ loading: false, error: "", health, records: Array.isArray(records.records) ? records.records : [], refreshedAt: new Date() });
} catch (err) {
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Decision Center 加载失败") }));
}
}
useEffect(() => {
load();
}, [service?.id, service?.runtime?.providerStatus]);
useEffect(() => {
const timer = setTimeout(() => void load(), 120);
return () => clearTimeout(timer);
}, [filters.type, filters.status, filters.level, filters.linkedGoalId]);
if (!service) return h(EmptyState, { title: "Decision Center 未登记", text: "请在 config.json 的 microservices 中登记用户服务 id=decision-center" });
const runtime = microserviceRuntime(service);
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 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);
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" },
h("button", { type: "button", className: "ghost-btn", onClick: () => void load(), disabled: state.loading }, state.loading ? "刷新中" : "刷新"),
h(RawButton, { title: "Decision Center Health", data: state.health, onOpen: onRaw, testId: "raw-decision-center-health" }),
) },
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: "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"),
h("strong", null, runtime.orchestrator || service.deployment?.mode || "k3sctl"),
h("code", null, `${service.providerId} / ${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`),
h("code", null, repository.commitId || "--"),
),
),
h(UniDeskErrorBanner, { error: state.error, title: "Decision Center 请求失败" }),
),
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))),
h("label", null, "状态", h("select", { value: filters.status, onChange: (event: any) => setFilters((prev: any) => ({ ...prev, status: event.target.value })) }, selectOptions(recordStatuses))),
h("label", null, "等级", h("select", { value: filters.level, onChange: (event: any) => setFilters((prev: any) => ({ ...prev, level: event.target.value })) }, selectOptions(recordLevels))),
h("label", null, "Linked Goal", h("input", { value: filters.linkedGoalId, onChange: (event: any) => setFilters((prev: any) => ({ ...prev, linkedGoalId: event.target.value })), placeholder: "goal id" })),
),
),
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: "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(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(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(Panel, { title: "全部记录", eyebrow: `${records.length} Records`, actions: state.refreshedAt ? h("span", { className: "muted" }, `刷新 ${fmtClock(state.refreshedAt)}`) : null },
h(RecordTable, { records, onRaw }),
),
);
}
+13 -1
View File
@@ -14,6 +14,12 @@ interface RuntimeConfig {
sessionSecret: string;
sessionTtlSeconds: number;
logFile: string;
deploy: {
serviceId: string;
repo: string;
commit: string;
requestedCommit: string;
};
}
interface SessionPayload {
@@ -143,6 +149,12 @@ function readConfig(): RuntimeConfig {
sessionSecret: requiredEnv("SESSION_SECRET"),
sessionTtlSeconds: readNumberEnv("SESSION_TTL_SECONDS"),
logFile: requiredEnv("LOG_FILE"),
deploy: {
serviceId: process.env.UNIDESK_DEPLOY_SERVICE_ID || "frontend",
repo: process.env.UNIDESK_DEPLOY_REPO || "",
commit: process.env.UNIDESK_DEPLOY_COMMIT || "",
requestedCommit: process.env.UNIDESK_DEPLOY_REQUESTED_COMMIT || "",
},
};
}
@@ -711,7 +723,7 @@ async function handleRequest(req: Request): Promise<Response> {
logger("debug", "request", { path: url.pathname });
try {
if (url.pathname === "/health") {
return jsonResponse({ ok: true, service: "unidesk-frontend", frontendPublicUrl: config.frontendPublicUrl });
return jsonResponse({ ok: true, service: "unidesk-frontend", frontendPublicUrl: config.frontendPublicUrl, deploy: config.deploy });
}
if (url.pathname === "/login" && req.method === "POST") return login(req);
if (url.pathname === "/logout" && req.method === "POST") return logout();
@@ -73,6 +73,7 @@ export const MODULES: UniDeskModuleDefinition[] = [
{ id: "k3sctl", label: "k3s Control" },
{ id: "code-queue", label: "Code Queue" },
{ id: "mdtodo", label: "MDTODO" },
{ id: "decision-center", label: "Decision Center" },
{ id: "project-manager", label: "Project Manager" },
] },
{ id: "config", label: "系统配置", code: "CFG", tabs: [