feat(analysis): 网页汇总表自定义列
- analyze 支持 custom_columns:白名单表达式树由服务端 Decimal 求值, 自定义列进入汇总、分组行与明细(缺失保持不可用,不转零) - 新增「选择网页自定义列」弹窗:从定义库(含共享)多选最多 10 列, 应用后进入主分析表格与热力图;公式工作簿可一键导出网页已选列 - 请求去重纳入自定义列;Playwright 覆盖选择后显示与数值断言
This commit is contained in:
+43
-6
@@ -35,6 +35,7 @@ import { Chart } from "./Chart";
|
||||
import {
|
||||
AdminDialog,
|
||||
AIConfigDialog,
|
||||
FormulaColumnsDialog,
|
||||
FormulaExportDialog,
|
||||
Copilot,
|
||||
EvidenceDialog,
|
||||
@@ -49,6 +50,8 @@ import {
|
||||
type Analysis,
|
||||
type Context,
|
||||
type Dataset,
|
||||
type FormulaColumn,
|
||||
type SavedFormulaDefinition,
|
||||
type Metric,
|
||||
type Preferences,
|
||||
type Row,
|
||||
@@ -89,6 +92,7 @@ export function App() {
|
||||
| "ai"
|
||||
| null
|
||||
| "formula"
|
||||
| "columns"
|
||||
>(null),
|
||||
[evidenceCtx, setEvidenceCtx] = useState<Context>(),
|
||||
[chat, setChat] = useState(false),
|
||||
@@ -98,6 +102,7 @@ export function App() {
|
||||
[note, setNote] = useState(""),
|
||||
[status, setStatus] = useState("open"),
|
||||
[saved, setSaved] = useState(false);
|
||||
const [formulaDefinitions, setFormulaDefinitions] = useState<SavedFormulaDefinition[]>([]);
|
||||
const upload = useRef<HTMLInputElement>(null),
|
||||
requestVersion = useRef(0);
|
||||
const report = useCallback(
|
||||
@@ -226,6 +231,8 @@ export function App() {
|
||||
c.comparison === current.comparison &&
|
||||
c.metric === current.metric &&
|
||||
c.group === current.group &&
|
||||
JSON.stringify(c.custom_columns || []) ===
|
||||
JSON.stringify(current.custom_columns || []) &&
|
||||
Object.keys(c.filters).length ===
|
||||
Object.keys(current.filters).length &&
|
||||
Object.entries(c.filters).every(
|
||||
@@ -324,8 +331,11 @@ export function App() {
|
||||
const available = candidates.filter(
|
||||
(m) => m === "revenue" || (result?.rows || []).some((r) => r.values[m]?.current != null),
|
||||
);
|
||||
return available.length ? available : (["revenue"] as Metric[]);
|
||||
const custom = (ctx?.custom_columns || []).map((_, index) => `custom_${index}`);
|
||||
return [...(available.length ? available : ["revenue"]), ...custom];
|
||||
}, [result, ctx]);
|
||||
const metricLabel = (metric: string) =>
|
||||
labels[metric as Metric] || ctx?.custom_columns?.[Number(metric.slice(7))]?.name || metric;
|
||||
const columns = useMemo<ColDef<Row>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -347,7 +357,7 @@ export function App() {
|
||||
},
|
||||
...metricKeys.map((metric) => ({
|
||||
colId: metric,
|
||||
headerName: labels[metric],
|
||||
headerName: metricLabel(metric),
|
||||
minWidth: 130,
|
||||
valueGetter: (p: { data?: Row }) =>
|
||||
p.data?.values[metric]?.current == null
|
||||
@@ -1056,9 +1066,9 @@ export function App() {
|
||||
}
|
||||
</th>
|
||||
{metricKeys
|
||||
.filter((m) => m !== "customers")
|
||||
.filter((m) => m !== "customers" && m in labels)
|
||||
.map((m) => (
|
||||
<th key={m}>{labels[m]}</th>
|
||||
<th key={m}>{metricLabel(m)}</th>
|
||||
))}
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
@@ -1068,7 +1078,7 @@ export function App() {
|
||||
<tr key={r.label}>
|
||||
<th>{r.label}</th>
|
||||
{metricKeys
|
||||
.filter((m) => m !== "customers")
|
||||
.filter((m) => m !== "customers" && m in labels)
|
||||
.map((m) => {
|
||||
const v = r.values[m]?.change;
|
||||
return (
|
||||
@@ -1115,7 +1125,7 @@ export function App() {
|
||||
<div className="investigation-band">
|
||||
<section>
|
||||
<div className="section-heading">
|
||||
<h2>{labels[metric]}变化分解</h2>
|
||||
<h2>{metricLabel(metric)}变化分解</h2>
|
||||
<span>
|
||||
{ctx.start} 至 {ctx.end}
|
||||
</span>
|
||||
@@ -1224,6 +1234,22 @@ export function App() {
|
||||
>
|
||||
<Calculator />
|
||||
</button>
|
||||
{!user.departments.length && (
|
||||
<button
|
||||
aria-label="选择网页自定义列"
|
||||
title="选择网页自定义列"
|
||||
onClick={() =>
|
||||
api<{ items: SavedFormulaDefinition[] }>(`/datasets/${ds.id}/formula-definitions`)
|
||||
.then((data) => {
|
||||
setFormulaDefinitions(data.items);
|
||||
setModal("columns");
|
||||
})
|
||||
.catch(report)
|
||||
}
|
||||
>
|
||||
自定义列
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="icon"
|
||||
aria-label="导出当前范围"
|
||||
@@ -1443,6 +1469,17 @@ export function App() {
|
||||
{modal === "formula" && ds && ctx && (
|
||||
<FormulaExportDialog ds={ds} ctx={ctx} onClose={() => setModal(null)} />
|
||||
)}
|
||||
{modal === "columns" && ds && ctx && (
|
||||
<FormulaColumnsDialog
|
||||
definitions={formulaDefinitions}
|
||||
selected={ctx.custom_columns || []}
|
||||
onClose={() => setModal(null)}
|
||||
onApply={(columns: FormulaColumn[]) => {
|
||||
setCtx({ ...ctx, custom_columns: columns });
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modal === "settings" && ds && (
|
||||
<SettingsDialog
|
||||
ds={ds}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronLeft, ChevronRight, Download, Save, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Save,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "./api";
|
||||
import {
|
||||
labels,
|
||||
roleLabels,
|
||||
type AIConfig,
|
||||
type Context,
|
||||
type Dataset,
|
||||
type FormulaColumn,
|
||||
type FormulaExpression,
|
||||
type SavedFormulaDefinition,
|
||||
type Dataset,
|
||||
type Metric,
|
||||
type Preferences,
|
||||
type Session,
|
||||
@@ -1158,3 +1165,55 @@ export function FormulaExportDialog({
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormulaColumnsDialog({
|
||||
definitions,
|
||||
selected,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
definitions: SavedFormulaDefinition[];
|
||||
selected: FormulaColumn[];
|
||||
onApply: (columns: FormulaColumn[]) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const selectedNames = new Set(selected.map((column) => column.name));
|
||||
const [checked, setChecked] = useState(
|
||||
() => new Set(definitions.filter((item) => selectedNames.has(item.definition.name)).map((item) => item.id)),
|
||||
);
|
||||
function toggle(id: string) {
|
||||
setChecked((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else if (next.size < 10) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Modal title="选择网页自定义列" onClose={onClose}>
|
||||
<div className="dialog-body">
|
||||
<p>已保存定义会由服务端统一计算并显示在分析表格中,最多选择 10 列。</p>
|
||||
{!definitions.length && <p className="muted">暂无已保存定义,请先在公式工作簿中保存。</p>}
|
||||
{definitions.map((item) => (
|
||||
<label className="check" key={item.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.has(item.id)}
|
||||
onChange={() => toggle(item.id)}
|
||||
/>
|
||||
{item.definition.name}:{item.definition.meaning}{item.owned ? "" : `(${item.owner}共享)`}
|
||||
</label>
|
||||
))}
|
||||
<footer>
|
||||
<button onClick={onClose}>取消</button>
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() => onApply(definitions.filter((item) => checked.has(item.id)).map((item) => item.definition))}
|
||||
>
|
||||
应用
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ export interface Value {
|
||||
}
|
||||
export interface Row {
|
||||
label: string;
|
||||
values: Record<Metric, Value>;
|
||||
values: Record<string, Value>;
|
||||
rows: number;
|
||||
baseline_rows: number;
|
||||
anomaly: boolean;
|
||||
|
||||
Reference in New Issue
Block a user