import { useEffect, useRef, useState } from "react"; import { ChevronLeft, ChevronRight, Download, Save, Trash2, X, } from "lucide-react"; import { api } from "./api"; import { labels, roleLabels, type AIConfig, type Context, type Dataset, type FormulaColumn, type FormulaExpression, type SavedFormulaDefinition, type Metric, type Preferences, type Session, } from "./types"; type User = NonNullable; function describeExpression(expression: FormulaExpression): string { if ("metric" in expression) return labels[expression.metric]; if ("number" in expression) return String(expression.number); const operators = { add: "+", subtract: "−", multiply: "×", divide: "÷" }; return `(${describeExpression(expression.left)} ${operators[expression.operator]} ${describeExpression(expression.right)})`; } export function Modal({ title, onClose, wide = false, children, }: { title: string; onClose: () => void; wide?: boolean; children: React.ReactNode; }) { const ref = useRef(null); useEffect(() => { const dialog = ref.current; if (!dialog) return; dialog.showModal(); return () => dialog.close(); }, []); return (

{title}

{children}
); } export function ImportDialog({ ds, onClose, onDone, }: { ds: Dataset; onClose: () => void; onDone: () => void; }) { const [mapping, setMapping] = useState(ds.mapping), [error, setError] = useState(""), [busy, setBusy] = useState(false); async function submit() { setBusy(true); setError(""); try { await api(`/datasets/${ds.id}/mapping`, "POST", mapping); onDone(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } } function update( index: number, patch: Partial<(typeof mapping.columns)[number]>, ) { setMapping({ ...mapping, columns: mapping.columns.map((c) => c.index === index ? { ...c, ...patch } : c, ), }); } return (

{ds.name} · 检查识别字段及财务口径后确认导入。

{mapping.columns.map((c) => ( ))}
源字段 角色 维度键 说明
{c.name} {c.role === "dimension" && ( update(c.index, { key: e.target.value }) } /> )} update(c.index, { description: e.target.value }) } />
{error && (

{error}

)}
导入后该版本映射将锁定。
); } export function SettingsDialog({ ds, onClose, onSave, }: { ds: Dataset; onClose: () => void; onSave: (value: Preferences, revision: number) => void; }) { const [value, setValue] = useState(ds.preferences), [revision, setRevision] = useState(ds.revision || 0), [error, setError] = useState(""), [busy, setBusy] = useState(false); useEffect(() => { api<{ value: Preferences; revision: number }>(`/datasets/${ds.id}/settings`) .then((r) => { setValue(r.value); setRevision(r.revision); }) .catch((e) => setError(e.message)); }, [ds.id]); async function save() { setBusy(true); setError(""); try { const result = await api<{ value: Preferences; revision: number }>( `/datasets/${ds.id}/settings`, "PUT", { value, revision }, ); onSave(result.value, result.revision); onClose(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } } return (
{error && (

{error}

)}
); } export function EvidenceDialog({ ds, ctx, user, onClose, }: { ds: Dataset; ctx: Context; user: NonNullable; onClose: () => void; }) { const pageSize = 50; const [result, setResult] = useState<{ rows: Record[]; total: number; sheet: string; }>(), [side, setSide] = useState<"current" | "baseline">("current"), [page, setPage] = useState(0), [error, setError] = useState(""); useEffect(() => { let active = true; setResult(undefined); setError(""); api<{ rows: Record[]; total: number; sheet: string }>( `/datasets/${ds.id}/evidence`, "POST", { ...ctx, side, offset: page * pageSize, limit: pageSize }, ) .then((value) => active && setResult(value)) .catch((e) => active && setError(e.message)); return () => { active = false; }; }, [ds.id, ctx, side, page]); function selectSide(value: "current" | "baseline") { if (value === side && page === 0) return; setResult(undefined); setPage(0); setSide(value); } function selectPage(value: number) { setResult(undefined); setPage(value); } const first = result?.total ? page * pageSize + 1 : 0; const last = result ? Math.min((page + 1) * pageSize, result.total) : 0; return (

来源工作表:{result?.sheet || "加载中"} · {result?.total ?? 0} 条

{user.role !== "admin" &&

客户原始标识不包含在明细中。

} {error && (

{error}

)}
{ds.dimensions.map((d) => ( ))} {result?.rows.map((r, i) => ( {ds.dimensions.map((d) => ( ))} ))}
来源行 期间{d.name}收入 成本 毛利
{String(r.source_row)} {String(r.period)} {String( (r.dimensions as Record)[d.key] || "", )} {String(r.revenue)} {String(r.cost ?? "")} {String(r.gross ?? "")}
{result ? `第 ${first}-${last} 条,共 ${result.total} 条` : error ? "加载失败" : "加载中"}
); } export function AIConfigDialog({ onClose, onSaved, }: { onClose: () => void; onSaved: (config: AIConfig) => void; }) { const [value, setValue] = useState({ enabled: false, provider: "local", base_url: "", model: "", api_key_set: false, updated_at: null, }), [savedConfig, setSavedConfig] = useState(null), [key, setKey] = useState(""), [models, setModels] = useState([]), [error, setError] = useState(""), [message, setMessage] = useState(""), [modelsError, setModelsError] = useState(""), [loading, setLoading] = useState(true), [busy, setBusy] = useState(false), [listing, setListing] = useState(false); const modelRequest = useRef(0); const connectionChanged = Boolean( savedConfig && (value.provider !== savedConfig.provider || value.base_url !== savedConfig.base_url || key), ); const availableModels = [ ...new Set([savedConfig?.model || "", ...models]), ].filter(Boolean); const canListModels = Boolean( savedConfig?.updated_at && (savedConfig.base_url || savedConfig.provider === "openai"), ); useEffect(() => { let active = true; api("/ai-config") .then((r) => { if (!active) return; setValue(r); setSavedConfig(r); if (r.updated_at && (r.base_url || r.provider === "openai")) void fetchModels(); }) .catch((e) => { if (active) setError(`加载配置失败:${(e as Error).message},请关闭后重试。`); }) .finally(() => { if (active) setLoading(false); }); return () => { active = false; modelRequest.current++; }; }, []); function updateValue(patch: Partial) { setValue((v) => ({ ...v, ...patch })); setMessage(""); setError(""); } async function fetchModels() { const ticket = ++modelRequest.current; setListing(true); setModelsError(""); try { const r = await api<{ items: string[] }>("/ai-models"); if (ticket !== modelRequest.current) return; setModels(r.items); } catch (e) { if (ticket === modelRequest.current) setModelsError( `获取模型列表失败:${(e as Error).message}。已保存的配置不受影响,可重试或手动填写模型名。`, ); } finally { if (ticket === modelRequest.current) setListing(false); } } async function save() { setBusy(true); setError(""); setMessage(""); try { const config = await api("/ai-config", "PUT", { enabled: value.enabled, provider: value.provider, base_url: value.base_url, model: value.model, api_key: key, }); setValue(config); setSavedConfig(config); setKey(""); setMessage("配置已保存"); onSaved(config); modelRequest.current++; setListing(false); setModelsError(""); if (connectionChanged) setModels([]); if (config.base_url || config.provider === "openai") void fetchModels(); } catch (e) { setError(`保存失败:${(e as Error).message}`); } finally { setBusy(false); } } return (

配置财务分析助手使用的模型服务。数据库配置优先;未配置时回退到部署环境变量。

{loading &&

正在加载已保存配置…

}
{savedConfig && ( <>

已保存的配置

{savedConfig.updated_at ? ( <>

{savedConfig.enabled ? "当前使用的模型:" : "已保存的模型:"} {savedConfig.model || "尚未选择"} {savedConfig.enabled ? "已启用" : "未启用"}

{savedConfig.provider === "openai" ? "OpenAI" : "OpenAI 兼容服务"} {" · "} {savedConfig.base_url || (savedConfig.provider === "openai" ? "https://api.openai.com/v1" : "未设置地址")} {" · "}API Key{" "} {savedConfig.api_key_set ? "已配置" : "未配置"}

) : (

尚未保存配置。请先保存服务地址和 API Key,再选择模型并启用。

)}

可选模型

选择后点击“保存配置”生效;也可以在上方手动填写模型名。

{connectionChanged ? (

服务配置已修改,请先保存后获取可选模型。

) : ( <> {listing &&

正在获取可用模型…

} {modelsError && (

{modelsError}

)} {!listing && !modelsError && !models.length && canListModels && (

该服务没有返回可用模型,可重试或手动填写模型名。

)} {savedConfig.updated_at && !canListModels && (

填写并保存 Base URL 后可获取模型列表。

)}
{availableModels.map((model) => ( ))}
)} {value.model !== savedConfig.model && (

待保存的模型:{value.model || "尚未选择"}

)}
)}

只向模型服务发送问题与维度/当前筛选,原始数据行不出服务器;密钥仅保存在后端, 配置修改会记入操作审计。

{message && (

{message}

)} {error && (

{error}

)}
); } export function Copilot({ ds, ctx, ai, onClose, onApply, }: { ds: Dataset; ctx: Context; ai: boolean; onClose: () => void; onApply: (reply: { context: Context; intent: { action: string } }) => void; }) { const [question, setQuestion] = useState(""), [consent, setConsent] = useState(false), [useModel, setUseModel] = useState(false), [answer, setAnswer] = useState(""), [error, setError] = useState(""), [busy, setBusy] = useState(false); async function submit(event: React.FormEvent) { event.preventDefault(); setBusy(true); setError(""); try { const r = await api<{ answer: string; context: Context; intent: { action: string }; }>(`/datasets/${ds.id}/assistant`, "POST", { question, context: ctx, use_model: useModel, consent, }); setAnswer(r.answer); onApply(r); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } } return (