feat(ui): Copilot 多轮对话、列定义预览确认与来源标识
This commit is contained in:
+33
-6
@@ -48,6 +48,7 @@ import {
|
|||||||
labels,
|
labels,
|
||||||
roleLabels,
|
roleLabels,
|
||||||
type Analysis,
|
type Analysis,
|
||||||
|
type AssistantReply,
|
||||||
type Context,
|
type Context,
|
||||||
type Dataset,
|
type Dataset,
|
||||||
type FormulaColumn,
|
type FormulaColumn,
|
||||||
@@ -514,6 +515,9 @@ export function App() {
|
|||||||
if (!session.user) return <Login onLogin={loadSession} />;
|
if (!session.user) return <Login onLogin={loadSession} />;
|
||||||
const user = session.user;
|
const user = session.user;
|
||||||
const canUpload = user.role !== "viewer" && !user.departments.length;
|
const canUpload = user.role !== "viewer" && !user.departments.length;
|
||||||
|
const canDesign =
|
||||||
|
(user.role === "admin" || user.role === "analyst") &&
|
||||||
|
!user.departments.length;
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<aside className={`sidebar ${mobileNav ? "open" : ""}`}>
|
<aside className={`sidebar ${mobileNav ? "open" : ""}`}>
|
||||||
@@ -1428,15 +1432,29 @@ export function App() {
|
|||||||
ds={ds}
|
ds={ds}
|
||||||
ctx={ctx}
|
ctx={ctx}
|
||||||
ai={session.ai_enabled}
|
ai={session.ai_enabled}
|
||||||
|
canDesign={canDesign}
|
||||||
onClose={() => setChat(false)}
|
onClose={() => setChat(false)}
|
||||||
onApply={(reply) => {
|
onApply={(reply: AssistantReply) => {
|
||||||
setCtx(reply.context);
|
// 普通回答与列设计不改变页面;只有显式查询动作才导航。
|
||||||
|
if (reply.kind === "column_design" || !reply.context) return;
|
||||||
|
if (
|
||||||
|
!["overview", "anomalies", "drill", "evidence"].includes(
|
||||||
|
reply.intent?.action,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const next: Context = {
|
||||||
|
...reply.context,
|
||||||
|
custom_columns:
|
||||||
|
reply.context?.custom_columns ?? ctx.custom_columns,
|
||||||
|
};
|
||||||
|
setCtx(next);
|
||||||
setPath(
|
setPath(
|
||||||
Object.entries(reply.context.filters).map(
|
Object.entries(next.filters).map(
|
||||||
([key, value], index, entries) => ({
|
([key, value], index, entries) => ({
|
||||||
label: value,
|
label: value,
|
||||||
ctx: {
|
ctx: {
|
||||||
...reply.context,
|
...next,
|
||||||
group: key,
|
group: key,
|
||||||
filters: Object.fromEntries(entries.slice(0, index)),
|
filters: Object.fromEntries(entries.slice(0, index)),
|
||||||
},
|
},
|
||||||
@@ -1450,8 +1468,17 @@ export function App() {
|
|||||||
? "anomalies"
|
? "anomalies"
|
||||||
: "investigate",
|
: "investigate",
|
||||||
);
|
);
|
||||||
if (reply.intent.action === "evidence")
|
if (reply.intent.action === "evidence") setEvidenceCtx(next);
|
||||||
setEvidenceCtx(reply.context);
|
}}
|
||||||
|
onColumnsApplied={(columns: FormulaColumn[]) => {
|
||||||
|
setCtx((current) =>
|
||||||
|
current ? { ...current, custom_columns: columns } : current,
|
||||||
|
);
|
||||||
|
api<{ items: SavedFormulaDefinition[] }>(
|
||||||
|
`/datasets/${ds.id}/formula-definitions`,
|
||||||
|
)
|
||||||
|
.then((data) => setFormulaDefinitions(data.items))
|
||||||
|
.catch(report);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+438
-31
@@ -12,6 +12,10 @@ import {
|
|||||||
labels,
|
labels,
|
||||||
roleLabels,
|
roleLabels,
|
||||||
type AIConfig,
|
type AIConfig,
|
||||||
|
type AssistantDesign,
|
||||||
|
type AssistantMessage,
|
||||||
|
type AssistantMode,
|
||||||
|
type AssistantReply,
|
||||||
type Context,
|
type Context,
|
||||||
type Dataset,
|
type Dataset,
|
||||||
type FormulaColumn,
|
type FormulaColumn,
|
||||||
@@ -24,6 +28,67 @@ import {
|
|||||||
|
|
||||||
type User = NonNullable<Session["user"]>;
|
type User = NonNullable<Session["user"]>;
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
history?: string;
|
||||||
|
source?: "model" | "rules";
|
||||||
|
model?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigationActions = new Set([
|
||||||
|
"overview",
|
||||||
|
"anomalies",
|
||||||
|
"drill",
|
||||||
|
"evidence",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function scopeKeyOf(ds: Dataset, ctx: Context) {
|
||||||
|
return [
|
||||||
|
ds.id,
|
||||||
|
ds.version,
|
||||||
|
ctx.start,
|
||||||
|
ctx.end,
|
||||||
|
ctx.baseline_end || "",
|
||||||
|
ctx.comparison,
|
||||||
|
ctx.group,
|
||||||
|
ctx.metric,
|
||||||
|
JSON.stringify(Object.entries(ctx.filters || {}).sort()),
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只提交已完成的 user→assistant 轮次,最多 5 轮,当前问题不进入 history。
|
||||||
|
function completedHistory(messages: ChatMessage[]): AssistantMessage[] {
|
||||||
|
const history: AssistantMessage[] = [];
|
||||||
|
for (let index = 0; index + 1 < messages.length; index += 2) {
|
||||||
|
const user = messages[index];
|
||||||
|
const assistant = messages[index + 1];
|
||||||
|
if (user.role === "user" && assistant.role === "assistant")
|
||||||
|
history.push(
|
||||||
|
{ role: "user", content: user.content },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: assistant.history || assistant.content,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return history.slice(-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replyContent(reply: AssistantReply) {
|
||||||
|
const answer = (reply.answer || "").trim();
|
||||||
|
if (reply.kind !== "column_design") return answer || "本轮没有返回内容。";
|
||||||
|
const questions = (reply.design?.questions || [])
|
||||||
|
.map((question) => question.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (
|
||||||
|
questions.length &&
|
||||||
|
!questions.every((question) => answer.includes(question))
|
||||||
|
)
|
||||||
|
return [answer, questions.join("\n")].filter(Boolean).join("\n");
|
||||||
|
return answer || "请补充列名、计算含义和适用范围。";
|
||||||
|
}
|
||||||
|
|
||||||
function describeExpression(expression: FormulaExpression): string {
|
function describeExpression(expression: FormulaExpression): string {
|
||||||
if ("metric" in expression) return labels[expression.metric];
|
if ("metric" in expression) return labels[expression.metric];
|
||||||
if ("number" in expression) return String(expression.number);
|
if ("number" in expression) return String(expression.number);
|
||||||
@@ -796,57 +861,374 @@ export function Copilot({
|
|||||||
ds,
|
ds,
|
||||||
ctx,
|
ctx,
|
||||||
ai,
|
ai,
|
||||||
|
canDesign,
|
||||||
onClose,
|
onClose,
|
||||||
onApply,
|
onApply,
|
||||||
|
onColumnsApplied,
|
||||||
}: {
|
}: {
|
||||||
ds: Dataset;
|
ds: Dataset;
|
||||||
ctx: Context;
|
ctx: Context;
|
||||||
ai: boolean;
|
ai: boolean;
|
||||||
|
canDesign: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onApply: (reply: { context: Context; intent: { action: string } }) => void;
|
onApply: (reply: AssistantReply) => void;
|
||||||
|
onColumnsApplied: (columns: FormulaColumn[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const [question, setQuestion] = useState(""),
|
const [question, setQuestion] = useState(""),
|
||||||
[consent, setConsent] = useState(false),
|
[consent, setConsent] = useState(false),
|
||||||
[useModel, setUseModel] = useState(false),
|
[useModel, setUseModel] = useState(false),
|
||||||
[answer, setAnswer] = useState(""),
|
[mode, setMode] = useState<AssistantMode>("auto"),
|
||||||
|
[messages, setMessages] = useState<ChatMessage[]>([]),
|
||||||
|
[inFlight, setInFlight] = useState<string>(""),
|
||||||
|
[design, setDesign] = useState<AssistantDesign | null>(null),
|
||||||
|
[notice, setNotice] = useState(""),
|
||||||
[error, setError] = useState(""),
|
[error, setError] = useState(""),
|
||||||
[busy, setBusy] = useState(false);
|
[busy, setBusy] = useState(false),
|
||||||
|
[saving, setSaving] = useState(false);
|
||||||
|
const epoch = useRef(0);
|
||||||
|
const savingRef = useRef(false);
|
||||||
|
const scopeRef = useRef<string | null>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const currentScope = scopeKeyOf(ds, ctx);
|
||||||
|
const completedTurns = messages.filter((item) => item.role === "user").length;
|
||||||
|
const limitReached = completedTurns >= 6;
|
||||||
|
const canSend =
|
||||||
|
!busy &&
|
||||||
|
!saving &&
|
||||||
|
!!question.trim() &&
|
||||||
|
!limitReached &&
|
||||||
|
(!useModel || consent) &&
|
||||||
|
(mode !== "column_design" || (useModel && consent));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scopeRef.current === currentScope) return;
|
||||||
|
const initial = scopeRef.current === null;
|
||||||
|
scopeRef.current = currentScope;
|
||||||
|
if (initial) return;
|
||||||
|
// 数据/范围/映射版本变化:历史与待确认定义立即失效,迟到响应按 epoch 丢弃。
|
||||||
|
epoch.current += 1;
|
||||||
|
savingRef.current = false;
|
||||||
|
setBusy(false);
|
||||||
|
setSaving(false);
|
||||||
|
setInFlight("");
|
||||||
|
setMessages([]);
|
||||||
|
setDesign(null);
|
||||||
|
setMode("auto");
|
||||||
|
setError("");
|
||||||
|
setNotice("数据范围已变更,已开始新的对话。");
|
||||||
|
}, [currentScope]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const list = listRef.current;
|
||||||
|
if (list) list.scrollTop = list.scrollHeight;
|
||||||
|
}, [messages, inFlight, design, notice, error]);
|
||||||
|
|
||||||
async function submit(event: React.FormEvent) {
|
async function submit(event: React.FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
const text = question.trim();
|
||||||
|
if (!canSend) return;
|
||||||
|
const nextMode: AssistantMode = useModel ? mode : "auto";
|
||||||
|
const history = completedHistory(messages);
|
||||||
|
const ticket = ++epoch.current;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
// 新消息立即让旧预览失效。
|
||||||
|
setDesign(null);
|
||||||
|
setInFlight(text);
|
||||||
try {
|
try {
|
||||||
const r = await api<{
|
const reply = await api<AssistantReply>(
|
||||||
answer: string;
|
`/datasets/${ds.id}/assistant`,
|
||||||
context: Context;
|
"POST",
|
||||||
intent: { action: string };
|
{
|
||||||
}>(`/datasets/${ds.id}/assistant`, "POST", {
|
question: text,
|
||||||
question,
|
context: ctx,
|
||||||
context: ctx,
|
use_model: useModel,
|
||||||
use_model: useModel,
|
consent,
|
||||||
consent,
|
history,
|
||||||
});
|
mode: nextMode,
|
||||||
setAnswer(r.answer);
|
},
|
||||||
onApply(r);
|
);
|
||||||
|
if (ticket !== epoch.current) return;
|
||||||
|
const assistantContent = replyContent(reply);
|
||||||
|
// 完整列定义进入后续对话历史,便于“继续修改”沿用上一版口径。
|
||||||
|
const historyContent =
|
||||||
|
reply.kind === "column_design" && reply.design?.column
|
||||||
|
? `${assistantContent}\n列定义:${JSON.stringify(reply.design.column)}`
|
||||||
|
: undefined;
|
||||||
|
setMessages((items) => [
|
||||||
|
...items,
|
||||||
|
{ role: "user", content: text },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: assistantContent,
|
||||||
|
history: historyContent,
|
||||||
|
source: reply.source,
|
||||||
|
model: reply.model,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
if (reply.kind === "column_design") {
|
||||||
|
setMode("column_design");
|
||||||
|
setDesign(reply.design || null);
|
||||||
|
if (!reply.design)
|
||||||
|
setNotice("列设计未返回可用预览,请补充描述后重试。");
|
||||||
|
} else {
|
||||||
|
setMode(nextMode === "column_design" ? "auto" : mode);
|
||||||
|
setDesign(null);
|
||||||
|
}
|
||||||
|
setQuestion((current) => (current === text ? "" : current));
|
||||||
|
if (
|
||||||
|
navigationActions.has(reply.intent?.action) &&
|
||||||
|
reply.context &&
|
||||||
|
scopeKeyOf(ds, reply.context) !== currentScope
|
||||||
|
) {
|
||||||
|
// 本轮由助手主动更新范围,保留刚生成的回答;页面变化由 App 应用。
|
||||||
|
scopeRef.current = scopeKeyOf(ds, reply.context);
|
||||||
|
}
|
||||||
|
onApply(reply);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
if (ticket === epoch.current) setError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
if (ticket === epoch.current) {
|
||||||
|
setBusy(false);
|
||||||
|
setInFlight("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function newConversation() {
|
||||||
|
epoch.current += 1;
|
||||||
|
savingRef.current = false;
|
||||||
|
setMessages([]);
|
||||||
|
setDesign(null);
|
||||||
|
setInFlight("");
|
||||||
|
setMode("auto");
|
||||||
|
setBusy(false);
|
||||||
|
setSaving(false);
|
||||||
|
setError("");
|
||||||
|
setNotice("已开始新对话。");
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitDesign() {
|
||||||
|
setMode("auto");
|
||||||
|
setDesign(null);
|
||||||
|
setNotice("已退出列设计,可继续普通问答。");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function continueEditing() {
|
||||||
|
setDesign(null);
|
||||||
|
setNotice("请补充或修改列定义后重新发送。");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDesign() {
|
||||||
|
const column = design?.complete ? design.column : null;
|
||||||
|
if (!column || !canDesign || savingRef.current || busy) return;
|
||||||
|
const nameKey = column.name.trim().toLowerCase();
|
||||||
|
const columns = ctx.custom_columns || [];
|
||||||
|
const position = columns.findIndex(
|
||||||
|
(item) => item.name.trim().toLowerCase() === nameKey,
|
||||||
|
);
|
||||||
|
if (position < 0 && columns.length >= 10) {
|
||||||
|
setError("当前已有 10 个分析列,请先移除一列后再添加。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ticket = epoch.current;
|
||||||
|
savingRef.current = true;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
let definitions: SavedFormulaDefinition[];
|
||||||
|
try {
|
||||||
|
definitions = (
|
||||||
|
await api<{ items: SavedFormulaDefinition[] }>(
|
||||||
|
`/datasets/${ds.id}/formula-definitions`,
|
||||||
|
)
|
||||||
|
).items;
|
||||||
|
} catch (e) {
|
||||||
|
if (ticket === epoch.current)
|
||||||
|
setError(`无法读取已保存定义:${(e as Error).message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ticket !== epoch.current) return;
|
||||||
|
const duplicates = definitions.filter(
|
||||||
|
(item) => item.definition.name.trim().toLowerCase() === nameKey,
|
||||||
|
);
|
||||||
|
const shared = duplicates.find((item) => !item.owned);
|
||||||
|
if (shared) {
|
||||||
|
if (ticket === epoch.current)
|
||||||
|
setError(
|
||||||
|
`已存在由 ${shared.owner} 共享的同名列「${shared.definition.name}」,请先更换列名。`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const owned = duplicates.find((item) => item.owned);
|
||||||
|
const saved = await api<SavedFormulaDefinition>(
|
||||||
|
`/datasets/${ds.id}/formula-definitions`,
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
definition: column,
|
||||||
|
...(owned ? { revision: owned.revision } : {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (ticket !== epoch.current) return;
|
||||||
|
const latest = ctx.custom_columns || [];
|
||||||
|
const index = latest.findIndex(
|
||||||
|
(item) => item.name.trim().toLowerCase() === nameKey,
|
||||||
|
);
|
||||||
|
if (index < 0 && latest.length >= 10) {
|
||||||
|
setError(
|
||||||
|
`已保存定义「${saved.definition.name}」,但当前分析列已达 10 列,请先移除一列后再确认。`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next =
|
||||||
|
index >= 0
|
||||||
|
? latest.map((item, i) => (i === index ? saved.definition : item))
|
||||||
|
: [...latest, saved.definition];
|
||||||
|
onColumnsApplied(next);
|
||||||
|
setDesign(null);
|
||||||
|
setMode("auto");
|
||||||
|
setNotice(`已添加分析列:${saved.definition.name},分析结果已刷新。`);
|
||||||
|
} catch (e) {
|
||||||
|
if (ticket === epoch.current)
|
||||||
|
setError(`保存定义失败:${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
if (ticket === epoch.current) {
|
||||||
|
savingRef.current = false;
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="copilot">
|
<aside className="copilot" aria-label="财务分析助手">
|
||||||
<header>
|
<header>
|
||||||
<strong>财务分析助手</strong>
|
<div className="copilot-title">
|
||||||
<button className="icon" aria-label="关闭" onClick={onClose}>
|
<strong>财务分析助手</strong>
|
||||||
<X />
|
<small>
|
||||||
</button>
|
{mode === "column_design" ? "列设计模式" : "普通问答"} · 已完成{" "}
|
||||||
|
{completedTurns}/6 轮
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="copilot-actions">
|
||||||
|
<button className="ghost" onClick={newConversation}>
|
||||||
|
新对话
|
||||||
|
</button>
|
||||||
|
{mode === "column_design" && (
|
||||||
|
<button className="ghost" disabled={saving} onClick={exitDesign}>
|
||||||
|
退出列设计
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="icon" aria-label="关闭" onClick={onClose}>
|
||||||
|
<X />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="context-tag">
|
<div className="context-tag">
|
||||||
当前范围:{Object.values(ctx.filters).join(" / ") || "全部"} · {ds.name}
|
当前范围:{Object.values(ctx.filters).join(" / ") || "全部"} · {ds.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-messages" aria-live="polite">
|
<div className="chat-messages" aria-live="polite" ref={listRef}>
|
||||||
{answer && <p className="message">{answer}</p>}
|
{!messages.length && !inFlight && !design && (
|
||||||
|
<p className="muted">
|
||||||
|
可以询问当前范围的经营问题,或描述要新增的分析列。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{messages.map((message, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={message.role === "user" ? "message-user" : "message assistant"}
|
||||||
|
>
|
||||||
|
<strong>{message.role === "user" ? "我" : "助手"}</strong>
|
||||||
|
<p className={message.role === "user" ? "question" : undefined}>
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
|
{message.role === "assistant" && message.source && (
|
||||||
|
<span className="message-source">
|
||||||
|
来源:
|
||||||
|
{message.source === "model"
|
||||||
|
? `模型回答 · ${message.model || "未命名模型"}`
|
||||||
|
: "规则摘要"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{inFlight && (
|
||||||
|
<div className="message-user pending">
|
||||||
|
<strong>我</strong>
|
||||||
|
<p className="question">{inFlight}</p>
|
||||||
|
<span className="message-source">发送中…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{design && (
|
||||||
|
<section className="design-preview" aria-label="列定义预览">
|
||||||
|
{design.complete && design.column ? (
|
||||||
|
<>
|
||||||
|
<h3>{design.column.name}</h3>
|
||||||
|
<p>{design.column.meaning}</p>
|
||||||
|
<dl>
|
||||||
|
<dt>计算表达式</dt>
|
||||||
|
<dd>
|
||||||
|
{describeExpression(
|
||||||
|
design.column.expression as FormulaExpression,
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
<dt>适用范围</dt>
|
||||||
|
<dd>{design.column.scope}</dd>
|
||||||
|
<dt>缺失/零分母</dt>
|
||||||
|
<dd>{design.column.missing}</dd>
|
||||||
|
</dl>
|
||||||
|
<div className="design-actions">
|
||||||
|
{canDesign ? (
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
disabled={saving || busy}
|
||||||
|
onClick={confirmDesign}
|
||||||
|
>
|
||||||
|
{saving ? "添加中…" : "确认并添加分析列"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="muted">
|
||||||
|
当前账号无列设计权限,无法添加分析列。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button disabled={saving || busy} onClick={continueEditing}>
|
||||||
|
继续修改
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{saving && (
|
||||||
|
<p role="status" className="muted">
|
||||||
|
正在保存定义并刷新分析结果…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h3>列设计待补充</h3>
|
||||||
|
{design.questions.length ? (
|
||||||
|
<ul>
|
||||||
|
{design.questions.map((item, index) => (
|
||||||
|
<li key={index}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p>请补充列名、计算含义和适用范围。</p>
|
||||||
|
)}
|
||||||
|
<button disabled={saving || busy} onClick={exitDesign}>
|
||||||
|
退出列设计
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{notice && (
|
||||||
|
<p role="status" className="notice">
|
||||||
|
{notice}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="error">
|
<p role="alert" className="error">
|
||||||
{error}
|
{error}
|
||||||
@@ -857,10 +1239,17 @@ export function Copilot({
|
|||||||
<label>
|
<label>
|
||||||
描述要分析的问题
|
描述要分析的问题
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
required
|
required
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
value={question}
|
value={question}
|
||||||
onChange={(e) => setQuestion(e.target.value)}
|
onChange={(e) => setQuestion(e.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.form?.requestSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{ai && (
|
{ai && (
|
||||||
@@ -869,7 +1258,14 @@ export function Copilot({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={useModel}
|
checked={useModel}
|
||||||
onChange={(e) => setUseModel(e.target.checked)}
|
disabled={busy || saving}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUseModel(e.target.checked);
|
||||||
|
if (!e.target.checked) {
|
||||||
|
setMode("auto");
|
||||||
|
setDesign(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
使用已配置的 AI 模型理解问题
|
使用已配置的 AI 模型理解问题
|
||||||
</label>
|
</label>
|
||||||
@@ -878,18 +1274,29 @@ export function Copilot({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={consent}
|
checked={consent}
|
||||||
|
disabled={busy || saving}
|
||||||
onChange={(e) => setConsent(e.target.checked)}
|
onChange={(e) => setConsent(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
同意发送问题和筛选上下文
|
同意将本次对话、当前筛选、指标定义和权限范围内的汇总分析结果发送到已配置的模型服务
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
{busy && (
|
||||||
className="primary"
|
<p role="status" className="copilot-status">
|
||||||
disabled={busy || !question.trim() || (useModel && !consent)}
|
正在分析…
|
||||||
>
|
</p>
|
||||||
{busy ? "分析中…" : "发送"}
|
)}
|
||||||
|
{limitReached && (
|
||||||
|
<p role="status" className="notice">
|
||||||
|
已达到 6 轮对话上限,请开始新对话。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{mode === "column_design" && !(useModel && consent) && (
|
||||||
|
<p className="notice">列设计需要启用模型并同意发送。</p>
|
||||||
|
)}
|
||||||
|
<button className="primary" disabled={!canSend}>
|
||||||
|
{busy ? "发送中…" : "发送"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
+140
-4
@@ -1204,6 +1204,7 @@ dialog footer > span {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
padding: 17px 18px;
|
padding: 17px 18px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
@@ -1211,6 +1212,34 @@ dialog footer > span {
|
|||||||
display: block;
|
display: block;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
}
|
}
|
||||||
|
.copilot-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.copilot-title strong {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.copilot-title small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.copilot-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.copilot-actions .ghost {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 4px 7px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.copilot-actions .icon {
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -1232,17 +1261,106 @@ dialog footer > span {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-bottom: 7px;
|
margin-bottom: 7px;
|
||||||
}
|
}
|
||||||
.message {
|
.message,
|
||||||
|
.message-user {
|
||||||
padding-block: 10px;
|
padding-block: 10px;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.9;
|
line-height: 1.9;
|
||||||
}
|
}
|
||||||
.message p {
|
.message > strong,
|
||||||
|
.message-user > strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #7b9282;
|
||||||
|
}
|
||||||
|
.message.assistant > strong {
|
||||||
|
color: #1d584a;
|
||||||
|
}
|
||||||
|
.message-user.pending .question {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.message-source {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #7b9282;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.message-user.pending .message-source {
|
||||||
|
color: #9a7a2e;
|
||||||
|
}
|
||||||
|
.copilot-status {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.copilot .notice,
|
||||||
|
.copilot .error {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.design-preview {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8faf9;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.design-preview h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.design-preview p {
|
||||||
|
margin: 0 0 6px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.message .question {
|
.design-preview ul {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
.design-preview li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.design-preview dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 4px 10px;
|
||||||
|
margin: 8px 0 10px;
|
||||||
|
}
|
||||||
|
.design-preview dt {
|
||||||
|
color: #7b9282;
|
||||||
|
}
|
||||||
|
.design-preview dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.design-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.design-actions button {
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: normal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.message p,
|
||||||
|
.message-user p {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.message .question,
|
||||||
|
.message-user .question {
|
||||||
background: #e9f2ec;
|
background: #e9f2ec;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -1258,8 +1376,17 @@ dialog footer > span {
|
|||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.copilot .check {
|
.copilot .check {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 0 0 10px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
margin-bottom: 10px;
|
line-height: 1.5;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.copilot .check input {
|
||||||
|
flex: none;
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
.chat-input {
|
.chat-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1585,6 +1712,15 @@ details {
|
|||||||
width: calc(100% - 20px);
|
width: calc(100% - 20px);
|
||||||
height: calc(100dvh - 75px);
|
height: calc(100dvh - 75px);
|
||||||
}
|
}
|
||||||
|
.copilot header {
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.copilot .chat-messages {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.design-actions button {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
dialog,
|
dialog,
|
||||||
.dialog.wide {
|
.dialog.wide {
|
||||||
width: calc(100% - 16px);
|
width: calc(100% - 16px);
|
||||||
|
|||||||
@@ -125,6 +125,34 @@ export interface SavedFormulaDefinition {
|
|||||||
owner: string;
|
owner: string;
|
||||||
history: { revision: number; definition: FormulaColumn; updated_at: string }[];
|
history: { revision: number; definition: FormulaColumn; updated_at: string }[];
|
||||||
}
|
}
|
||||||
|
export interface AssistantMessage {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
export type AssistantMode = "auto" | "column_design";
|
||||||
|
export interface AssistantDesign {
|
||||||
|
complete: boolean;
|
||||||
|
questions: string[];
|
||||||
|
column: FormulaColumn | null;
|
||||||
|
}
|
||||||
|
export interface AssistantIntent {
|
||||||
|
action: string;
|
||||||
|
metric?: Metric;
|
||||||
|
group?: string;
|
||||||
|
filters?: Record<string, string>;
|
||||||
|
}
|
||||||
|
export interface AssistantReply {
|
||||||
|
answer: string;
|
||||||
|
intent: AssistantIntent;
|
||||||
|
context: Context;
|
||||||
|
version: string;
|
||||||
|
provider: string;
|
||||||
|
evidence?: unknown[];
|
||||||
|
kind: "answer" | "column_design";
|
||||||
|
source: "model" | "rules";
|
||||||
|
model: string | null;
|
||||||
|
design?: AssistantDesign;
|
||||||
|
}
|
||||||
export interface Value {
|
export interface Value {
|
||||||
current: Numeric;
|
current: Numeric;
|
||||||
baseline: Numeric;
|
baseline: Numeric;
|
||||||
@@ -174,6 +202,8 @@ export interface Session {
|
|||||||
departments: string[];
|
departments: string[];
|
||||||
} | null;
|
} | null;
|
||||||
ai_enabled: boolean;
|
ai_enabled: boolean;
|
||||||
|
ai_model?: string | null;
|
||||||
|
ai_provider?: "openai" | "local" | null;
|
||||||
}
|
}
|
||||||
export const format = (v: Numeric | undefined, metric?: string) =>
|
export const format = (v: Numeric | undefined, metric?: string) =>
|
||||||
v == null
|
v == null
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const password =
|
||||||
|
process.env.E2E_PASSWORD ||
|
||||||
|
fs.readFileSync("var/local-access.txt", "utf8").match(/密码:(.+)/)![1];
|
||||||
|
|
||||||
|
const csv = (customerCode: string) =>
|
||||||
|
`期间,科室,学科,医生等级,医生,产品一级分类,实际收入,成本,毛利贡献,客户编码\n2025-08,测试儿保,普儿,高级,医生A,医疗服务,10000,4000,6000,${customerCode}\n2026-08,测试儿保,普儿,高级,医生A,医疗服务,5000,4000,3000,${customerCode}\n`;
|
||||||
|
|
||||||
|
// 模型请求由 route mock,登录、导入、保存定义与查询走真实后端。
|
||||||
|
async function forceAiEnabled(page: Page) {
|
||||||
|
await page.route("**/api/session", async (route) => {
|
||||||
|
const response = await route.fetch();
|
||||||
|
const json = await response.json();
|
||||||
|
const headers = Object.fromEntries(
|
||||||
|
Object.entries(response.headers()).filter(
|
||||||
|
([name]) => name.toLowerCase() !== "content-length",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await route.fulfill({
|
||||||
|
status: response.status(),
|
||||||
|
headers,
|
||||||
|
json: { ...json, ai_enabled: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page: Page) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByLabel("用户名", { exact: true }).fill("admin");
|
||||||
|
await page.getByLabel("密码", { exact: true }).fill(password);
|
||||||
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "导入新表格" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadCsv(page: Page) {
|
||||||
|
const uploaded = page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().endsWith("/api/datasets") &&
|
||||||
|
response.request().method() === "POST",
|
||||||
|
);
|
||||||
|
await page.getByLabel("上传财务表格").setInputFiles({
|
||||||
|
name: `assistant-e2e-${Date.now()}.csv`,
|
||||||
|
mimeType: "text/csv",
|
||||||
|
buffer: Buffer.from(csv(`E2E-${Date.now()}`)),
|
||||||
|
});
|
||||||
|
await uploaded;
|
||||||
|
await page
|
||||||
|
.getByRole("dialog", { name: "确认导入字段" })
|
||||||
|
.getByRole("button", { name: "确认并导入" })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "集团经营总览", exact: true }),
|
||||||
|
).toBeVisible({ timeout: 60000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCopilot(page: Page) {
|
||||||
|
await page.getByRole("button", { name: "Copilot", exact: true }).click();
|
||||||
|
const copilot = page.locator(".copilot");
|
||||||
|
await copilot
|
||||||
|
.getByRole("checkbox", { name: "使用已配置的 AI 模型理解问题" })
|
||||||
|
.check();
|
||||||
|
await copilot.getByRole("checkbox", { name: /同意将本次对话/ }).check();
|
||||||
|
return copilot;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("动态回答:展示模型标记与实际模型名,普通问答不改变页面", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 1512, height: 982 });
|
||||||
|
await forceAiEnabled(page);
|
||||||
|
const requests: Array<Record<string, unknown>> = [];
|
||||||
|
await page.route("**/api/datasets/*/assistant", async (route) => {
|
||||||
|
const payload = route.request().postDataJSON();
|
||||||
|
requests.push(payload);
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
answer: "动态分析标记 A:本期收入 5,000,成本 4,000。",
|
||||||
|
intent: {
|
||||||
|
action: "answer",
|
||||||
|
metric: payload.context.metric,
|
||||||
|
group: payload.context.group,
|
||||||
|
filters: payload.context.filters,
|
||||||
|
},
|
||||||
|
context: payload.context,
|
||||||
|
version: "assistant-e2e",
|
||||||
|
provider: "local",
|
||||||
|
evidence: [],
|
||||||
|
kind: "answer",
|
||||||
|
source: "model",
|
||||||
|
model: "deepseek-chat-e2e",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await login(page);
|
||||||
|
await uploadCsv(page);
|
||||||
|
const copilot = await openCopilot(page);
|
||||||
|
await copilot.getByLabel("描述要分析的问题").fill("本期收入有哪些变化?");
|
||||||
|
await copilot.getByRole("button", { name: "发送", exact: true }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
copilot.getByText("动态分析标记 A", { exact: false }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(copilot).toContainText("来源:模型回答 · deepseek-chat-e2e");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "集团经营总览", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: /异常调查/ })).toHaveCount(0);
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0]).toMatchObject({
|
||||||
|
question: "本期收入有哪些变化?",
|
||||||
|
use_model: true,
|
||||||
|
consent: true,
|
||||||
|
mode: "auto",
|
||||||
|
history: [],
|
||||||
|
});
|
||||||
|
expect(requests[0].context).toMatchObject({ filters: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("对话新增列:追问、预览、确认后真实保存并出现列头", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||||
|
await forceAiEnabled(page);
|
||||||
|
const requests: Array<Record<string, unknown>> = [];
|
||||||
|
await page.route("**/api/datasets/*/assistant", async (route) => {
|
||||||
|
const payload = route.request().postDataJSON();
|
||||||
|
requests.push(payload);
|
||||||
|
const first = requests.length === 1;
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
answer: first ? "请补充列的定义。" : "已整理列定义。",
|
||||||
|
intent: {
|
||||||
|
action: "design_column",
|
||||||
|
metric: payload.context.metric,
|
||||||
|
group: payload.context.group,
|
||||||
|
filters: payload.context.filters,
|
||||||
|
},
|
||||||
|
context: payload.context,
|
||||||
|
version: "assistant-e2e",
|
||||||
|
provider: "local",
|
||||||
|
evidence: [],
|
||||||
|
kind: "column_design",
|
||||||
|
source: "model",
|
||||||
|
model: "deepseek-chat-e2e",
|
||||||
|
design: first
|
||||||
|
? {
|
||||||
|
complete: false,
|
||||||
|
questions: ["缺失值和零分母如何处理?"],
|
||||||
|
column: null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
complete: true,
|
||||||
|
questions: [],
|
||||||
|
column: {
|
||||||
|
name: "收入成本差",
|
||||||
|
meaning: "实际收入减成本",
|
||||||
|
scope: "所选分组与期间",
|
||||||
|
missing: "结果不可用(#N/A);零分母不可用",
|
||||||
|
expression: {
|
||||||
|
operator: "subtract",
|
||||||
|
left: { metric: "revenue" },
|
||||||
|
right: { metric: "cost" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await login(page);
|
||||||
|
await uploadCsv(page);
|
||||||
|
const copilot = await openCopilot(page);
|
||||||
|
await copilot.getByLabel("描述要分析的问题").fill("我想加一个新的分析列。");
|
||||||
|
await copilot.getByRole("button", { name: "发送", exact: true }).click();
|
||||||
|
|
||||||
|
const preview = copilot.getByRole("region", { name: "列定义预览" });
|
||||||
|
await expect(preview).toContainText("缺失值和零分母如何处理?");
|
||||||
|
await expect(
|
||||||
|
preview.getByRole("button", { name: "确认并添加分析列" }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
copilot.getByRole("button", { name: "退出列设计", exact: true }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await copilot
|
||||||
|
.getByLabel("描述要分析的问题")
|
||||||
|
.fill(
|
||||||
|
"列名收入成本差,用实际收入减成本,应用当前筛选的本期和基期,缺失显示不可用。",
|
||||||
|
);
|
||||||
|
await copilot.getByRole("button", { name: "发送", exact: true }).click();
|
||||||
|
|
||||||
|
await expect(preview).toContainText("收入成本差");
|
||||||
|
await expect(preview).toContainText("实际收入减成本");
|
||||||
|
await expect(preview).toContainText("计算表达式");
|
||||||
|
await expect(preview).toContainText("(实际收入 − 成本)");
|
||||||
|
await expect(preview).toContainText("适用范围");
|
||||||
|
await expect(preview).toContainText("缺失/零分母");
|
||||||
|
expect(requests).toHaveLength(2);
|
||||||
|
expect(requests[1].mode).toBe("column_design");
|
||||||
|
expect(requests[1].question).toBe(
|
||||||
|
"列名收入成本差,用实际收入减成本,应用当前筛选的本期和基期,缺失显示不可用。",
|
||||||
|
);
|
||||||
|
expect(requests[1].history).toEqual([
|
||||||
|
{ role: "user", content: "我想加一个新的分析列。" },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: expect.stringContaining("缺失值和零分母如何处理?"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 保存定义与查询不 mock:走真实后端并检查返回值和表格列头。
|
||||||
|
const savedResponse = page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes("/formula-definitions") &&
|
||||||
|
response.request().method() === "POST",
|
||||||
|
);
|
||||||
|
const customQuery = page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes("/query") &&
|
||||||
|
response.request().method() === "POST" &&
|
||||||
|
((response.request().postDataJSON()?.custom_columns?.length as number) ||
|
||||||
|
0) > 0,
|
||||||
|
);
|
||||||
|
await preview
|
||||||
|
.getByRole("button", { name: "确认并添加分析列", exact: true })
|
||||||
|
.click();
|
||||||
|
const saved = await savedResponse;
|
||||||
|
expect(saved.ok()).toBeTruthy();
|
||||||
|
expect((await saved.json()).definition.name).toBe("收入成本差");
|
||||||
|
const query = await customQuery;
|
||||||
|
const customResult = await query.json();
|
||||||
|
// Decimal 以字符串保留精度(如 "1000.000000000000000000000000"),按数值断言。
|
||||||
|
expect(Number(customResult.summary.custom_0.current)).toBe(1000);
|
||||||
|
expect(Number(customResult.summary.custom_0.baseline)).toBe(6000);
|
||||||
|
expect(Number(customResult.summary.custom_0.delta)).toBe(-5000);
|
||||||
|
expect(Number(customResult.rows[0].values.custom_0.current)).toBe(1000);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
copilot.getByRole("status").filter({ hasText: "已添加分析列" }),
|
||||||
|
).toBeVisible();
|
||||||
|
const header = page.locator(".ag-header-cell-text", {
|
||||||
|
hasText: "收入成本差",
|
||||||
|
});
|
||||||
|
// AG Grid 可能水平虚拟化列头;每次轮询滚到最右再检查是否已渲染。
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
for (const selector of [
|
||||||
|
".ag-body-horizontal-scroll-viewport",
|
||||||
|
".ag-center-cols-viewport",
|
||||||
|
]) {
|
||||||
|
const scroller = page.locator(selector);
|
||||||
|
if (await scroller.count()) {
|
||||||
|
await scroller.evaluate((element) => {
|
||||||
|
const viewport = element as HTMLElement;
|
||||||
|
viewport.scrollLeft = viewport.scrollWidth;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return header.count();
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
await expect(header).toBeVisible({ timeout: 20000 });
|
||||||
|
});
|
||||||
@@ -128,8 +128,9 @@ test("新增列:补充信息触发追问并重新确认后生成公式工作
|
|||||||
);
|
);
|
||||||
await columns.getByRole("button", { name: "应用", exact: true }).click();
|
await columns.getByRole("button", { name: "应用", exact: true }).click();
|
||||||
const customResult = await (await customQuery).json();
|
const customResult = await (await customQuery).json();
|
||||||
expect(customResult.summary.custom_0.current).toBe("1000");
|
// Decimal 以字符串保留存储精度(如 "1000.000000000000000000000000"),按数值断言。
|
||||||
expect(customResult.rows[0].values.custom_0.current).toBe("1000");
|
expect(Number(customResult.summary.custom_0.current)).toBe(1000);
|
||||||
|
expect(Number(customResult.rows[0].values.custom_0.current)).toBe(1000);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "生成公式工作簿" }).click();
|
await page.getByRole("button", { name: "生成公式工作簿" }).click();
|
||||||
const reopened = page.getByRole("dialog", { name: "生成公式工作簿" });
|
const reopened = page.getByRole("dialog", { name: "生成公式工作簿" });
|
||||||
|
|||||||
Reference in New Issue
Block a user