- 载入定义时以“用户消息+助手确认”形式把名称、含义、范围、缺失处理和表达式 写入对话,后续“把刚才公式改成乘以 100”等指代可关联到已载入定义; 生成前仍需显式确认,不静默沿用旧定义 - 选择空选项时同步清理当前定义与上下文,选择状态与预览保持一致 - 服务端单条消息长度上限 1000→2000 以容纳完整定义上下文,超长仍拒绝 - E2E 覆盖:载入后请求包含原定义、修改后表达式为 (收入−成本)×100、 空选项清理;单测覆盖 2000/2001 边界
1005 lines
29 KiB
TypeScript
1005 lines
29 KiB
TypeScript
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>
|
||
);
|
||
}
|