This commit is contained in:
@@ -0,0 +1,952 @@
|
||||
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)})`;
|
||||
}
|
||||
|
||||
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("");
|
||||
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]);
|
||||
function selectSide(value: "current" | "baseline") {
|
||||
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}
|
||||
</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>
|
||||
))}
|
||||
</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) return;
|
||||
setColumn(saved.definition);
|
||||
setMessages([]);
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user