Files
fa-agent/frontend/src/dialogs.tsx
T
Jayway Liu dd8912f7ae
Verify / verify (pull_request) Failing after 0s
AI Review / review (pull_request) Successful in 9s
Verify / verify (push) Failing after 1s
feat(ai): 页面可配置云端或本地 OpenAI 兼容模型
- 新增 AIConfig 模型(每组织一份,迁移 0005)与 /api/ai-config、
  /api/ai-models 接口;密钥只存后端、读取脱敏、修改记审计
- 支持 OpenAI 及本地 OpenAI 兼容服务(Ollama/LM Studio/vLLM),
  可从 /v1/models 一键获取模型列表多选一;数据库配置优先于环境变量
- 助手调用走生效配置,Copilot 文案同步更新
- 全权限管理员可在「管理 → AI 模型」配置;补接口级测试
2026-10-11 09:37:05 +08:00

1101 lines
31 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 AIConfig,
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 AIConfigDialog({ onClose }: { onClose: () => void }) {
const [value, setValue] = useState<AIConfig>({
enabled: false,
provider: "local",
base_url: "",
model: "",
api_key_set: false,
updated_at: null,
}),
[key, setKey] = useState(""),
[models, setModels] = useState<string[]>([]),
[error, setError] = useState(""),
[busy, setBusy] = useState(false),
[listing, setListing] = useState(false);
useEffect(() => {
api<AIConfig>("/ai-config")
.then((r) => setValue(r))
.catch((e) => setError((e as Error).message));
}, []);
async function fetchModels() {
setListing(true);
setError("");
try {
const r = await api<{ items: string[] }>("/ai-models");
setModels(r.items);
if (!r.items.length) setError("该服务没有返回可用模型");
} catch (e) {
setError((e as Error).message);
} finally {
setListing(false);
}
}
async function save() {
setBusy(true);
setError("");
try {
await api("/ai-config", "PUT", {
enabled: value.enabled,
provider: value.provider,
base_url: value.base_url,
model: value.model,
api_key: key,
});
setKey("");
setValue((v) => ({ ...v, api_key_set: v.api_key_set || Boolean(key) }));
if (value.base_url) await fetchModels();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<Modal title="AI 模型配置" onClose={onClose} wide>
<div className="dialog-body">
<p className="muted">
配置财务分析助手使用的模型服务。数据库配置优先;未配置时回退到部署环境变量。
</p>
<label className="check">
<input
type="checkbox"
checked={value.enabled}
onChange={(e) => setValue({ ...value, enabled: e.target.checked })}
/>
启用 AI 模型理解问题
</label>
<label>
服务商
<select
value={value.provider}
onChange={(e) =>
setValue({
...value,
provider: e.target.value as AIConfig["provider"],
})
}
>
<option value="openai">OpenAI</option>
<option value="local">
本地 OpenAI 兼容服务(Ollama / LM Studio / vLLM)
</option>
</select>
</label>
<label>
Base URL
<input
value={value.base_url}
placeholder={
value.provider === "local"
? "http://127.0.0.1:11434/v1"
: "https://api.openai.com/v1"
}
onChange={(e) => setValue({ ...value, base_url: e.target.value })}
/>
</label>
<label>
API Key
<input
type="password"
value={key}
placeholder={
value.api_key_set ? "已配置,留空保持不变" : "本地服务通常可留空"
}
onChange={(e) => setKey(e.target.value)}
/>
</label>
<label>
模型
<input
list="ai-model-list"
value={value.model}
placeholder="模型名,如 qwen2.5:7b"
onChange={(e) => setValue({ ...value, model: e.target.value })}
/>
<datalist id="ai-model-list">
{models.map((m) => (
<option key={m} value={m} />
))}
</datalist>
</label>
<footer>
<button onClick={onClose}>取消</button>
<button
type="button"
disabled={listing || busy}
onClick={fetchModels}
>
{listing ? "获取中…" : "获取可用模型"}
</button>
<button className="primary" disabled={busy} onClick={save}>
{busy ? "保存中…" : "保存配置"}
</button>
</footer>
{error && (
<p role="alert" className="error">
{error}
</p>
)}
<p className="muted">
只向模型服务发送问题与维度/当前筛选,原始数据行不出服务器;密钥仅保存在后端,
配置修改会记入操作审计。
</p>
</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>
);
}