chore: import existing codebase as baseline
Verify / verify (push) Failing after 1m20s

This commit is contained in:
Jayway Liu
2026-09-26 01:12:10 +08:00
commit 0ac0c4eb56
77 changed files with 14155 additions and 0 deletions
+1552
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -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} />;
}
+38
View File
@@ -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);
}
+952
View File
@@ -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>
);
}
+38
View File
@@ -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
+142
View File
@@ -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 });