Files
fa-agent/frontend/src/dialogs.tsx
T
Jayway Liu 9a5ab26014
Verify / verify (push) Failing after 1s
Verify / verify (pull_request) Failing after 1s
fix(dialogs): F04 载入已保存定义后把原定义带入可见对话上下文
- 载入定义时以“用户消息+助手确认”形式把名称、含义、范围、缺失处理和表达式
  写入对话,后续“把刚才公式改成乘以 100”等指代可关联到已载入定义;
  生成前仍需显式确认,不静默沿用旧定义
- 选择空选项时同步清理当前定义与上下文,选择状态与预览保持一致
- 服务端单条消息长度上限 1000→2000 以容纳完整定义上下文,超长仍拒绝
- E2E 覆盖:载入后请求包含原定义、修改后表达式为 (收入−成本)×100、
  空选项清理;单测覆盖 2000/2001 边界
2026-09-26 01:30:34 +08:00

1005 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from "react";
import {
ChevronLeft,
ChevronRight,
Download,
Save,
Trash2,
X,
} from "lucide-react";
import { api } from "./api";
import {
labels,
type Context,
type Dataset,
type Metric,
type Preferences,
type Session,
} from "./types";
type User = NonNullable<Session["user"]>;
type FormulaExpression =
| { metric: Metric }
| { number: number }
| {
operator: "add" | "subtract" | "multiply" | "divide";
left: FormulaExpression;
right: FormulaExpression;
};
type SavedFormulaDefinition = {
id: string;
definition: Record<string, unknown>;
updated_at: string;
};
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)})`;
}
function definitionSummary(definition: Record<string, unknown>): string {
const text = [
`已载入已保存定义「${String(definition.name)}」`,
`含义:${String(definition.meaning)}`,
`适用范围:${String(definition.scope)}`,
`缺失/零分母处理:${String(definition.missing)}`,
`计算表达式:${JSON.stringify(definition.expression)}`,
].join(";");
return text.length <= 2000
? text
: `${text.slice(0, 2000)}…(内容过长已截断)`;
}
export function Modal({
title,
onClose,
wide = false,
children,
}: {
title: string;
onClose: () => void;
wide?: boolean;
children: React.ReactNode;
}) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
dialog.showModal();
return () => dialog.close();
}, []);
return (
<dialog ref={ref} className={wide ? "wide" : ""} aria-label={title}>
<header>
<h2>{title}</h2>
<button className="icon" aria-label="关闭" onClick={onClose}>
<X />
</button>
</header>
{children}
</dialog>
);
}
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 (
<Modal title="确认导入字段" onClose={onClose} wide>
<div className="dialog-body">
<p>{ds.name} · 检查识别字段及财务口径后确认导入。</p>
<label>
工作表
<select
value={mapping.sheet}
onChange={(e) => setMapping({ ...mapping, sheet: e.target.value })}
>
{ds.profile?.sheets.map((s) => (
<option key={s.name}>{s.name}</option>
))}
</select>
</label>
<label>
ROI 口径
<select
value={mapping.roi_formula}
onChange={(e) =>
setMapping({ ...mapping, roi_formula: e.target.value })
}
>
<option value="profit_over_cost">(收入 - 成本)/ 成本</option>
<option value="gross_less_cost_over_cost">
(毛利贡献 - 成本)/ 成本
</option>
</select>
</label>
<div className="table-scroll">
<table>
<thead>
<tr>
<th>源字段</th>
<th>角色</th>
<th>维度键</th>
<th>说明</th>
</tr>
</thead>
<tbody>
{mapping.columns.map((c) => (
<tr key={c.index}>
<td>{c.name}</td>
<td>
<select
value={c.role}
onChange={(e) =>
update(c.index, { role: e.target.value })
}
>
{[
"period",
"year",
"month",
"revenue",
"cost",
"gross",
"quantity",
"list_amount",
"discount",
"customer",
"dimension",
"ignore",
].map((r) => (
<option key={r}>{r}</option>
))}
</select>
</td>
<td>
{c.role === "dimension" && (
<input
value={c.key}
onChange={(e) =>
update(c.index, { key: e.target.value })
}
/>
)}
</td>
<td>
<input
value={c.description}
onChange={(e) =>
update(c.index, { description: e.target.value })
}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{error && (
<p role="alert" className="error">
{error}
</p>
)}
<footer>
<span>导入后该版本映射将锁定。</span>
<button onClick={onClose}>取消</button>
<button className="primary" disabled={busy} onClick={submit}>
{busy ? "提交中…" : "确认并导入"}
</button>
</footer>
</div>
</Modal>
);
}
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 (
<Modal title="分析设置" onClose={onClose} wide>
<div className="dialog-body">
<label>
默认比较
<select
value={value.comparison}
onChange={(e) =>
setValue({
...value,
comparison: e.target.value as Preferences["comparison"],
})
}
>
<option value="yoy">同比</option>
<option value="mom">环比</option>
</select>
</label>
<label>
默认指标
<select
value={value.metric}
onChange={(e) =>
setValue({ ...value, metric: e.target.value as Metric })
}
>
{Object.entries(labels).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
</label>
<label>
异常阈值
<input
type="number"
min="0"
max="10"
step="0.01"
value={value.threshold}
onChange={(e) =>
setValue({ ...value, threshold: Number(e.target.value) })
}
/>
</label>
<label>
最小影响额
<input
type="number"
min="0"
value={value.min_impact}
onChange={(e) =>
setValue({ ...value, min_impact: Number(e.target.value) })
}
/>
</label>
<label>
最小样本数
<input
type="number"
min="1"
value={value.min_samples}
onChange={(e) =>
setValue({ ...value, min_samples: Number(e.target.value) })
}
/>
</label>
{error && (
<p role="alert" className="error">
{error}
</p>
)}
<footer>
<button onClick={onClose}>取消</button>
<button className="primary" disabled={busy} onClick={save}>
{busy ? "保存中…" : "保存设置"}
</button>
</footer>
</div>
</Modal>
);
}
export function EvidenceDialog({
ds,
ctx,
user,
onClose,
}: {
ds: Dataset;
ctx: Context;
user: NonNullable<Session["user"]>;
onClose: () => void;
}) {
const pageSize = 50;
const [result, setResult] = useState<{
rows: Record<string, unknown>[];
total: number;
sheet: string;
}>(),
[side, setSide] = useState<"current" | "baseline">("current"),
[page, setPage] = useState(0),
[error, setError] = useState(""),
[reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let active = true;
setResult(undefined);
setError("");
api<{ rows: Record<string, unknown>[]; 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, reloadKey]);
function selectSide(value: "current" | "baseline") {
if (value === side) {
// 重复点击当前期间:仅在不在第一页时回到第一页触发刷新,
// 同期间同页直接返回,避免清空列表后停在“加载中”。
if (page !== 0) setPage(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 (
<Modal title="证据明细" onClose={onClose} wide>
<div className="dialog-body">
<div className="segmented" role="group" aria-label="证据期间">
<button
className={side === "current" ? "active" : ""}
aria-pressed={side === "current"}
onClick={() => selectSide("current")}
>
本期
</button>
<button
className={side === "baseline" ? "active" : ""}
aria-pressed={side === "baseline"}
onClick={() => selectSide("baseline")}
>
基期
</button>
</div>
<p>
来源工作表:{result?.sheet || "加载中"} · {result?.total ?? 0} 条
</p>
{user.role !== "admin" && <p>客户原始标识不包含在明细中。</p>}
{error && (
<p role="alert" className="error">
{error}{" "}
<button onClick={() => setReloadKey((n) => n + 1)}>重试</button>
</p>
)}
<div className="table-scroll">
<table>
<thead>
<tr>
<th>来源行</th>
<th>期间</th>
{ds.dimensions.map((d) => (
<th key={d.key}>{d.name}</th>
))}
<th>收入</th>
<th>成本</th>
<th>毛利</th>
</tr>
</thead>
<tbody>
{result?.rows.map((r, i) => (
<tr key={i}>
<td>{String(r.source_row)}</td>
<td>{String(r.period)}</td>
{ds.dimensions.map((d) => (
<td key={d.key}>
{String(
(r.dimensions as Record<string, string>)[d.key] || "",
)}
</td>
))}
<td>{String(r.revenue)}</td>
<td>{String(r.cost ?? "")}</td>
<td>{String(r.gross ?? "")}</td>
</tr>
))}
{result && result.rows.length === 0 && (
<tr>
<td colSpan={3 + ds.dimensions.length}>本期范围内没有数据</td>
</tr>
)}
</tbody>
</table>
</div>
<footer>
<span>
{result
? `第 ${first}-${last} 条,共 ${result.total} 条`
: error
? "加载失败"
: "加载中"}
</span>
<button
className="icon"
aria-label="上一页"
title="上一页"
disabled={!result || page === 0}
onClick={() => selectPage(page - 1)}
>
<ChevronLeft />
</button>
<button
className="icon"
aria-label="下一页"
title="下一页"
disabled={!result || (page + 1) * pageSize >= result.total}
onClick={() => selectPage(page + 1)}
>
<ChevronRight />
</button>
<button onClick={onClose}>关闭</button>
</footer>
</div>
</Modal>
);
}
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 (
<aside className="copilot">
<header>
<strong>财务分析助手</strong>
<button className="icon" aria-label="关闭" onClick={onClose}>
<X />
</button>
</header>
<div className="context-tag">
当前范围:{Object.values(ctx.filters).join(" / ") || "全部"} · {ds.name}
</div>
<div className="chat-messages" aria-live="polite">
{answer && <p className="message">{answer}</p>}
{error && (
<p role="alert" className="error">
{error}
</p>
)}
</div>
<form onSubmit={submit}>
<label>
描述要分析的问题
<textarea
required
maxLength={2000}
value={question}
onChange={(e) => setQuestion(e.target.value)}
/>
</label>
{ai && (
<>
<label className="check">
<input
type="checkbox"
checked={useModel}
onChange={(e) => setUseModel(e.target.checked)}
/>
使用外部 AI 理解问题
</label>
{useModel && (
<label className="check">
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
/>
同意发送问题和筛选上下文
</label>
)}
</>
)}
<button
className="primary"
disabled={busy || !question.trim() || (useModel && !consent)}
>
{busy ? "分析中…" : "发送"}
</button>
</form>
</aside>
);
}
export function AdminDialog({
mode,
onClose,
}: {
mode: "users" | "audit";
onClose: () => void;
}) {
const [items, setItems] = useState<Record<string, unknown>[]>([]),
[error, setError] = useState("");
useEffect(() => {
api<{ items: Record<string, unknown>[] }>(
mode === "users" ? "/users" : "/audit",
)
.then((r) => setItems(r.items))
.catch((e) => setError(e.message));
}, [mode]);
return (
<Modal
title={mode === "users" ? "账号与权限" : "操作审计"}
onClose={onClose}
wide
>
<div className="dialog-body">
{error && (
<p role="alert" className="error">
{error}
</p>
)}
<div className="table-scroll">
<table>
<thead>
<tr>
{mode === "users" ? (
<>
<th>账号</th>
<th>角色</th>
<th>状态</th>
<th>科室</th>
</>
) : (
<>
<th>时间</th>
<th>用户</th>
<th>操作</th>
<th>资源</th>
<th>详情</th>
</>
)}
</tr>
</thead>
<tbody>
{items.map((r, i) => (
<tr key={String(r.id ?? i)}>
{mode === "users" ? (
<>
<td>{String(r.username)}</td>
<td>{String(r.role)}</td>
<td>{r.active ? "启用" : "停用"}</td>
<td>
{String(
((r.departments || []) as string[]).join("、") ||
"全部",
)}
</td>
</>
) : (
<>
<td>{String(r.created_at)}</td>
<td>{String(r.user__username)}</td>
<td>{String(r.action)}</td>
<td>{String(r.resource)}</td>
<td>{JSON.stringify(r.details)}</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
<footer>
<button onClick={onClose}>关闭</button>
</footer>
</div>
</Modal>
);
}
export function FormulaExportDialog({
ds,
ctx,
onClose,
}: {
ds: Dataset;
ctx: Context;
onClose: () => void;
}) {
const [messages, setMessages] = useState<
{ role: "user" | "assistant"; content: string }[]
>([]),
[draft, setDraft] = useState(""),
[column, setColumn] = useState<Record<string, unknown> | null>(null),
[questions, setQuestions] = useState<string[]>([]),
[task, setTask] = useState<{
id: string;
status: string;
error?: string;
}>(),
[definitions, setDefinitions] = useState<SavedFormulaDefinition[]>([]),
[selectedDefinition, setSelectedDefinition] = useState(""),
[consent, setConsent] = useState(false),
[busy, setBusy] = useState(false),
[error, setError] = useState("");
useEffect(() => {
api<{ items: SavedFormulaDefinition[] }>(
`/datasets/${ds.id}/formula-definitions`,
)
.then((result) => setDefinitions(result.items))
.catch((e) => setError(e.message));
}, [ds.id]);
useEffect(() => {
if (!task || !["queued", "processing"].includes(task.status)) return;
const timer = window.setInterval(
() =>
api<{ status: string; error: string }>(
`/datasets/${ds.id}/formula-exports/${task.id}`,
)
.then((r) => setTask((old) => old && { ...old, ...r }))
.catch((e) => setError(e.message)),
1500,
);
return () => window.clearInterval(timer);
}, [ds.id, task]);
const userTurns = messages.filter(
(message) => message.role === "user",
).length;
async function send() {
const text = draft.trim();
if (!text || !consent || userTurns >= 6) return;
const next = [...messages, { role: "user" as const, content: text }];
setBusy(true);
setError("");
setQuestions([]);
setColumn(null);
try {
const result = await api<{
complete: boolean;
questions: string[];
column: Record<string, unknown> | null;
}>(`/datasets/${ds.id}/formula-design`, "POST", {
context: ctx,
messages: next,
consent: true,
});
setMessages([
...next,
{
role: "assistant",
content: result.complete
? `已整理列定义:${String(result.column?.name)};含义:${String(result.column?.meaning)};适用范围:${String(result.column?.scope)};缺失处理:${String(result.column?.missing)};表达式:${JSON.stringify(result.column?.expression)}`
: result.questions.join("\n"),
},
]);
setQuestions(result.questions);
setColumn(result.column);
setDraft("");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function create() {
if (!column) return;
setBusy(true);
setError("");
try {
setTask(
await api<{ id: string; status: string }>(
`/datasets/${ds.id}/formula-exports`,
"POST",
{ ...ctx, custom_columns: [column] },
),
);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function saveDefinition() {
if (!column) return;
setBusy(true);
setError("");
try {
const saved = await api<SavedFormulaDefinition>(
`/datasets/${ds.id}/formula-definitions`,
"POST",
{ definition: column },
);
setDefinitions((items) =>
[...items.filter((item) => item.id !== saved.id), saved].sort((a, b) =>
String(a.definition.name).localeCompare(
String(b.definition.name),
"zh-CN",
),
),
);
setSelectedDefinition(saved.id);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
function loadDefinition(id: string) {
setSelectedDefinition(id);
const saved = definitions.find((item) => item.id === id);
if (!saved) {
// 选择空选项时同步清理当前定义,避免下拉选择与预览不一致
setColumn(null);
setMessages([]);
setQuestions([]);
return;
}
setColumn(saved.definition);
// 载入定义后把原定义显式放进可见、经同意的对话上下文,
// 使“把刚才公式改成乘以 100”这类指代能关联到已载入表达式;
// 修改结果仍需重新确认,不会静默沿用旧定义。
setMessages([
{ role: "user", content: definitionSummary(saved.definition) },
{
role: "assistant",
content:
"已载入该定义作为当前基准。请描述要调整的内容,我会在该定义基础上生成新的列定义,仍需你确认后再生成。",
},
]);
setQuestions([]);
setDraft("");
}
async function deleteDefinition() {
if (!selectedDefinition) return;
setBusy(true);
setError("");
try {
await api(
`/datasets/${ds.id}/formula-definitions/${selectedDefinition}`,
"DELETE",
);
setDefinitions((items) =>
items.filter((item) => item.id !== selectedDefinition),
);
setSelectedDefinition("");
setColumn(null);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function getFile() {
try {
const blob = await api<Blob>(
`/datasets/${ds.id}/formula-exports/${task!.id}/download`,
),
url = URL.createObjectURL(blob),
link = document.createElement("a");
link.href = url;
link.download = "traceiq-formula-analysis.xlsx";
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
setError((e as Error).message);
}
}
return (
<Modal title="生成公式工作簿" onClose={onClose} wide>
<div className="dialog-body">
<p>描述新列含义、计算方式、适用范围,以及缺失值或零分母处理方式。</p>
{definitions.length > 0 && !task && (
<div className="form-row">
<label>
已保存定义
<select
value={selectedDefinition}
onChange={(event) => loadDefinition(event.target.value)}
disabled={busy}
>
<option value="">选择定义</option>
{definitions.map((item) => (
<option key={item.id} value={item.id}>
{String(item.definition.name)}
</option>
))}
</select>
</label>
<button
className="icon"
aria-label="删除所选定义"
title="删除所选定义"
disabled={busy || !selectedDefinition}
onClick={deleteDefinition}
>
<Trash2 />
</button>
</div>
)}
<label className="check">
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
/>
同意将描述、指标定义和当前筛选范围发送至已配置的外部 AI 服务
</label>
<div className="chat-messages" aria-live="polite">
{messages.map((m, i) => (
<p key={i}>
<strong>{m.role === "user" ? "我" : "助手"}:</strong>
{m.content}
</p>
))}
</div>
<p className="muted">本轮描述 {userTurns}/6</p>
{!task && (
<>
<textarea
aria-label="列计算描述"
value={draft}
onChange={(e) => {
setDraft(e.target.value);
}}
disabled={busy}
rows={3}
maxLength={1000}
/>
<button disabled={busy || !consent || !draft.trim()} onClick={send}>
{busy ? "处理中…" : userTurns >= 6 ? "已达对话上限" : "发送描述"}
</button>
{column && (
<section>
<h3>{String(column.name)}</h3>
<p>{String(column.meaning)}</p>
<p>
计算表达式:
{describeExpression(column.expression as FormulaExpression)}
</p>
<p>适用范围:{String(column.scope)}</p>
<p>缺失/零分母:{String(column.missing)}</p>
<button
disabled={busy || Boolean(draft.trim())}
onClick={saveDefinition}
>
<Save />
保存到定义库
</button>
<button
disabled={busy || Boolean(draft.trim())}
onClick={create}
>
{draft.trim() ? "先发送补充内容" : "确认并生成公式工作簿"}
</button>
</section>
)}
{questions.length > 0 && (
<p className="notice">补充信息后再生成公式。</p>
)}
</>
)}
{task && (
<p role="status">
{task.status === "queued"
? "排队中"
: task.status === "processing"
? "正在生成"
: task.status === "succeeded"
? "生成完成"
: `生成失败:${task.error || "请重试"}(任务编号:${task.id})`}
</p>
)}
{task?.status === "succeeded" && (
<button onClick={getFile}>
<Download />
下载公式工作簿
</button>
)}
{error && (
<p role="alert" className="error">
{error}
</p>
)}
</div>
</Modal>
);
}