This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as echarts from "echarts/core";
|
||||
import { BarChart, LineChart, HeatmapChart } from "echarts/charts";
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
VisualMapComponent,
|
||||
AriaComponent,
|
||||
} from "echarts/components";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
echarts.use([
|
||||
BarChart,
|
||||
LineChart,
|
||||
HeatmapChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
VisualMapComponent,
|
||||
AriaComponent,
|
||||
CanvasRenderer,
|
||||
]);
|
||||
export function Chart({
|
||||
option,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
option: echarts.EChartsCoreOption;
|
||||
label: string;
|
||||
onClick?: (name: string) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const chart = echarts.init(ref.current!);
|
||||
chart.setOption({ ...option, aria: { enabled: true }, animation: false });
|
||||
if (onClick) chart.on("click", (p) => onClick(p.name));
|
||||
const observer = new ResizeObserver(() => chart.resize());
|
||||
observer.observe(ref.current!);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
chart.dispose();
|
||||
};
|
||||
}, [option, onClick]);
|
||||
return <div className="chart" ref={ref} role="img" aria-label={label} />;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
let csrf = "";
|
||||
export function setCsrf(value: string) {
|
||||
csrf = value;
|
||||
}
|
||||
export async function api<T>(
|
||||
path: string,
|
||||
method = "GET",
|
||||
data?: unknown,
|
||||
): Promise<T> {
|
||||
const form = data instanceof FormData;
|
||||
const response = await fetch("/api" + path, {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
...(method !== "GET" ? { "X-CSRFToken": csrf } : {}),
|
||||
...(!form && data ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
body: data ? (form ? data : JSON.stringify(data)) : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ error: `请求失败 (${response.status})` }));
|
||||
throw new Error(error.error || "请求失败");
|
||||
}
|
||||
if (response.headers.get("Content-Disposition")?.includes("attachment"))
|
||||
return (await response.blob()) as T;
|
||||
return response.json();
|
||||
}
|
||||
export async function download(path: string, data: unknown, kind = "csv") {
|
||||
const blob = await api<Blob>(path, "POST", data);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `traceiq-evidence.${kind}`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ModuleRegistry, AllCommunityModule } from "ag-grid-community";
|
||||
import { App } from "./App";
|
||||
import "./style.css";
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
if (import.meta.env.VITE_AG_GRID_LICENSE_KEY) {
|
||||
const { AllEnterpriseModule, LicenseManager } =
|
||||
await import("ag-grid-enterprise");
|
||||
LicenseManager.setLicenseKey(import.meta.env.VITE_AG_GRID_LICENSE_KEY);
|
||||
ModuleRegistry.registerModules([AllEnterpriseModule]);
|
||||
}
|
||||
class Boundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false };
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
render() {
|
||||
return this.state.failed ? (
|
||||
<main className="fatal">
|
||||
<h1>页面出现错误</h1>
|
||||
<button onClick={() => location.reload()}>重新加载</button>
|
||||
</main>
|
||||
) : (
|
||||
this.props.children
|
||||
);
|
||||
}
|
||||
}
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<Boundary>
|
||||
<App />
|
||||
</Boundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
export type Numeric = string | number | null;
|
||||
export type Metric =
|
||||
| "revenue"
|
||||
| "cost"
|
||||
| "gross"
|
||||
| "roi"
|
||||
| "customers"
|
||||
| "basket"
|
||||
| "margin"
|
||||
| "discount_rate"
|
||||
| "self_revenue"
|
||||
| "upgrade_rate"
|
||||
| "list_basket";
|
||||
export const labels: Record<Metric, string> = {
|
||||
revenue: "实际收入",
|
||||
cost: "成本",
|
||||
gross: "毛利贡献",
|
||||
roi: "ROI",
|
||||
customers: "消费人数",
|
||||
basket: "客单价",
|
||||
margin: "毛利率",
|
||||
discount_rate: "产品扣率",
|
||||
self_revenue: "自营收入",
|
||||
upgrade_rate: "升单率",
|
||||
list_basket: "原价客单价",
|
||||
};
|
||||
export interface Column {
|
||||
index: number;
|
||||
name: string;
|
||||
description: string;
|
||||
role: string;
|
||||
key: string;
|
||||
confidence: number;
|
||||
}
|
||||
export interface Mapping {
|
||||
sheet: string;
|
||||
header: number;
|
||||
columns: Column[];
|
||||
margin_rates: Record<string, number>;
|
||||
self_operated_products?: string[];
|
||||
roi_formula: string;
|
||||
description: string;
|
||||
}
|
||||
export interface Preferences {
|
||||
entry: "overview" | "anomalies";
|
||||
comparison: "yoy" | "mom";
|
||||
hierarchy: string[];
|
||||
metric: Metric;
|
||||
threshold: number;
|
||||
min_impact: number;
|
||||
min_samples: number;
|
||||
show_heatmap: boolean;
|
||||
show_queue: boolean;
|
||||
show_copilot: boolean;
|
||||
alerts: boolean;
|
||||
}
|
||||
export interface Dataset {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
version: string;
|
||||
row_count: number | null;
|
||||
periods: string[];
|
||||
error: string;
|
||||
quality: Record<string, number>;
|
||||
dimensions: { key: string; name: string; description: string }[];
|
||||
profile?: {
|
||||
sheets: {
|
||||
name: string;
|
||||
rows: number;
|
||||
columns: number;
|
||||
preview: string[][];
|
||||
}[];
|
||||
};
|
||||
mapping: Mapping;
|
||||
preferences: Preferences;
|
||||
revision: number;
|
||||
}
|
||||
export interface Context {
|
||||
start: string;
|
||||
end: string;
|
||||
comparison: string;
|
||||
baseline_end?: string;
|
||||
filters: Record<string, string>;
|
||||
group: string;
|
||||
metric: Metric;
|
||||
}
|
||||
export interface Value {
|
||||
current: Numeric;
|
||||
baseline: Numeric;
|
||||
delta: Numeric;
|
||||
change: Numeric;
|
||||
}
|
||||
export interface Row {
|
||||
label: string;
|
||||
values: Record<Metric, Value>;
|
||||
rows: number;
|
||||
baseline_rows: number;
|
||||
anomaly: boolean;
|
||||
impact: Numeric;
|
||||
contribution: Numeric;
|
||||
filters: Record<string, string>;
|
||||
}
|
||||
export interface Analysis {
|
||||
context: Context & {
|
||||
missing: string[];
|
||||
current: string[];
|
||||
baseline: string[];
|
||||
};
|
||||
summary: Record<Metric, Value>;
|
||||
rows: Row[];
|
||||
trend: ({ period: string } & Record<Metric, Numeric>)[];
|
||||
row_count: number;
|
||||
anomaly_count: number;
|
||||
comparable: boolean;
|
||||
notice: string;
|
||||
version: string;
|
||||
}
|
||||
export interface Session {
|
||||
csrf: string;
|
||||
user: {
|
||||
id: number;
|
||||
name: string;
|
||||
role: string;
|
||||
organization: string;
|
||||
departments: string[];
|
||||
} | null;
|
||||
ai_enabled: boolean;
|
||||
}
|
||||
export const format = (v: Numeric | undefined, metric?: string) =>
|
||||
v == null
|
||||
? "不可用"
|
||||
: [
|
||||
"roi",
|
||||
"margin",
|
||||
"discount_rate",
|
||||
"upgrade_rate",
|
||||
"change",
|
||||
"contribution",
|
||||
].includes(metric || "")
|
||||
? `${(Number(v) * 100).toFixed(1)}%`
|
||||
: Number(v).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
Reference in New Issue
Block a user