Files
ln-bi/src/modules/assets/assistant/GenerativeResultView.tsx
T
2026-09-23 17:52:33 +08:00

683 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { memo, useState } from "react";
import {
ArrowDownToLine,
ArrowUpRight,
ChevronLeft,
ChevronRight,
Copy,
} from "lucide-react";
import {
ResponsiveContainer,
BarChart,
Bar,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
Legend,
PieChart,
Pie,
Cell,
LineChart,
Line,
} from "recharts";
import type {
AssistantResult,
PresentationBlock,
PresentationInteraction,
} from "../../../shared/assets/assistant";
import { fetchAuthenticated } from "../../../auth/api-client";
import type { ResultViewProps } from "./ResultView";
import {
compactAnalysisPath,
copyResultText,
displayTime,
queryPath,
resultScope,
} from "./result-presentation";
import {
presentationAction,
presentationData,
presentationFields,
presentationMetrics,
presentationSeries,
presentationValue,
type PresentationDatum,
} from "./generative-presentation";
const COLORS = [
"#4e806a",
"#527da5",
"#b18439",
"#8870a4",
"#39948e",
"#ae6970",
];
const datumName = (row: PresentationDatum) =>
String(row.name ?? row.date ?? row.label ?? row.plate ?? "");
type DataBlockProps = {
block: PresentationBlock;
result: AssistantResult;
interaction?: PresentationInteraction;
operate: ResultViewProps["operate"];
disabled: boolean;
};
function DataTable({
block,
result,
interaction,
operate,
disabled,
}: DataBlockProps) {
const [page, setPage] = useState(0);
const data = presentationData(result, block.dataset);
const columns = presentationFields(block);
const currentPage = Math.min(
page,
Math.max(0, Math.ceil(data.length / 8) - 1),
);
return (
<>
<div className="aq-table-scroll">
<table>
<caption className="aq-sr-only">{block.title || "分析数据"}</caption>
<thead>
<tr>
{columns.map((column) => (
<th key={column.field} scope="col">
{column.label}
</th>
))}
{interaction && <th scope="col">继续分析</th>}
</tr>
</thead>
<tbody>
{data
.slice(currentPage * 8, currentPage * 8 + 8)
.map((row, index) => {
const action = presentationAction(interaction, row);
return (
<tr
key={String(
row.id ?? row.name ?? row.date ?? row.label ?? index,
)}
>
{columns.map((column) => (
<td key={column.field}>
{presentationValue(row[column.field], column.field)}
</td>
))}
{interaction && (
<td>
{action ? (
<button
disabled={disabled}
onClick={() =>
operate(
action,
`${datumName(row)} · ${interaction.label}`,
)
}
>
{interaction.label}
<ArrowUpRight size={12} />
</button>
) : (
"—"
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
{data.length > 8 && (
<div className="aq-generated-pagination">
<span>
第 {currentPage * 8 + 1}–
{Math.min(data.length, currentPage * 8 + 8)} 条,共 {data.length} 条
</span>
<button
aria-label="上一页"
disabled={currentPage === 0}
onClick={() => setPage(currentPage - 1)}
>
<ChevronLeft size={15} />
</button>
<button
aria-label="下一页"
disabled={(currentPage + 1) * 8 >= data.length}
onClick={() => setPage(currentPage + 1)}
>
<ChevronRight size={15} />
</button>
</div>
)}
</>
);
}
function DataChart(props: DataBlockProps) {
const { block, result, interaction, operate, disabled } = props;
const [expanded, setExpanded] = useState(false);
const allData = presentationData(result, block.dataset);
const data =
expanded || block.chart === "line" || block.chart === "donut"
? allData
: allData.slice(0, 5);
const series = presentationSeries(block);
const labelField =
block.dataset === "daily"
? "date"
: block.dataset === "comparison"
? "label"
: "name";
const explore = (datum: PresentationDatum) => {
const action = presentationAction(interaction, datum);
if (action && !disabled)
operate(action, `${datumName(datum)} · ${interaction!.label}`);
};
const clickChart = (state: {
activeTooltipIndex?: string | number | null;
}) => {
const index = Number(state.activeTooltipIndex);
if (state.activeTooltipIndex != null && data[index]) explore(data[index]);
};
if (!series.length) return <DataTable {...props} />;
const common = { data, margin: { top: 8, right: 16, bottom: 0, left: 0 } };
return (
<>
<div
className="aq-generated-chart"
aria-label={`${block.title || "数据图表"},${allData.length}项数据`}
>
<ResponsiveContainer width="100%" height="100%">
{block.chart === "donut" ? (
<PieChart>
<Pie
data={data}
dataKey={series[0].field}
nameKey={labelField}
innerRadius="48%"
outerRadius="76%"
paddingAngle={2}
isAnimationActive={false}
onClick={(entry) => explore(entry.payload as PresentationDatum)}
>
{data.map((_, index) => (
<Cell key={index} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
formatter={(value) => presentationValue(value, series[0].field)}
/>
<Legend wrapperStyle={{ maxHeight: 54, overflowY: "auto" }} />
</PieChart>
) : block.chart === "line" ? (
<LineChart {...common} onClick={clickChart}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey={labelField}
tick={{ fontSize: 11 }}
minTickGap={25}
/>
<YAxis width={42} tick={{ fontSize: 11 }} allowDecimals={series.some(item => ["rate", "share"].includes(item.field))} />
<Tooltip />
<Legend />
{series.map((item, index) => (
<Line
key={item.field}
type="monotone"
dataKey={item.field}
name={item.label}
stroke={COLORS[index % COLORS.length]}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
isAnimationActive={false}
/>
))}
</LineChart>
) : (
<BarChart {...common} onClick={clickChart}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey={labelField}
tick={{ fontSize: 11 }}
interval={0}
/>
<YAxis width={42} tick={{ fontSize: 11 }} allowDecimals={series.some(item => ["rate", "share"].includes(item.field))} />
<Tooltip />
<Legend />
{series.map((item, index) => (
<Bar
key={item.field}
dataKey={item.field}
name={item.label}
fill={COLORS[index % COLORS.length]}
radius={[3, 3, 0, 0]}
maxBarSize={42}
isAnimationActive={false}
/>
))}
</BarChart>
)}
</ResponsiveContainer>
</div>
{interaction && (
<div
className="aq-generated-drill-targets"
aria-label={interaction.label}
>
{data.map((row, index) => (
<button
key={index}
disabled={disabled || !presentationAction(interaction, row)}
onClick={() => explore(row)}
>
{datumName(row)}
<ArrowUpRight size={12} />
</button>
))}
</div>
)}
{allData.length > 5 &&
block.chart !== "line" &&
block.chart !== "donut" && (
<button
className="aq-generated-expand"
onClick={() => setExpanded(!expanded)}
>
{expanded
? "收起分组"
: `当前展示前5项 · 查看全部${allData.length}项`}
</button>
)}
<details className="aq-generated-data">
<summary>查看图表数据</summary>
<DataTable
{...props}
block={{
...block,
columns: block.columns || [
{
field: labelField,
label: block.dataset === "daily" ? "日期" : "项目",
},
...series,
],
}}
/>
</details>
</>
);
}
function Block({
block,
result,
operate,
disabled,
}: Omit<DataBlockProps, "interaction">) {
const interaction = result.presentation?.interactions.find(
(item) => item.id === block.interactionId,
);
const data = presentationData(result, block.dataset);
return (
<section
className={`aq-generated-block aq-generated-block-${block.kind}`}
data-block-id={block.id}
>
{block.title && <h4>{block.title}</h4>}
{block.kind === "summary" ? (
<p
className="aq-answer"
id={
result.resultId &&
result.presentation?.blocks.find((item) => item.kind === "summary")
?.id === block.id
? `aq-answer-${result.resultId}`
: undefined
}
>
{block.text || result.summary}
</p>
) : block.kind === "narrative" ? (
<p className="aq-generated-narrative">{block.text}</p>
) : block.kind === "metrics" ? (
<div className="aq-generated-metrics">
{presentationMetrics(result, block.metrics).map((metric) => (
<div key={metric.label}>
<span>{metric.label}</span>
<strong>
{metric.value.toLocaleString()}
<small>{metric.unit}</small>
</strong>
</div>
))}
</div>
) : (
<>
{interaction && (
<p className="aq-generated-interaction">
点击{block.dataset === "daily" ? "日期" : "数据项"} →{" "}
{interaction.label}
</p>
)}
{data.length ? (
block.kind === "chart" ? (
<DataChart
block={block}
result={result}
interaction={interaction}
operate={operate}
disabled={disabled}
/>
) : (
<DataTable
block={block}
result={result}
interaction={interaction}
operate={operate}
disabled={disabled}
/>
)
) : (
<p className="aq-generated-empty">当前范围暂无匹配数据。</p>
)}
</>
)}
</section>
);
}
function GenerativeResultViewContent({
result,
operate,
restore,
active,
activate,
subject,
disabled,
correctionMessage,
correctionResultId,
showCorrection,
}: ResultViewProps) {
const [noticeByResult, setNoticeByResult] = useState<{
key: string;
text: string;
}>();
const [saving, setSaving] = useState(false);
const [flaggedResult, setFlaggedResult] = useState<string>();
const resultKey = result.resultId || result.fetchedAt;
const notice = noticeByResult?.key === resultKey ? noticeByResult.text : "";
const flagged = flaggedResult === resultKey;
const setNotice = (text: string) =>
setNoticeByResult({ key: resultKey, text });
const scope = resultScope(result, subject);
const path = queryPath(result.plan);
const breadcrumbs = compactAnalysisPath(result.breadcrumbs);
const currentLabel = result.breadcrumbs?.find(
(item) => item.resultId === result.resultId,
)?.label;
const parent = breadcrumbs
.filter(
(item) =>
item.resultId !== result.resultId && item.label !== currentLabel,
)
.at(-1);
const presentation = result.presentation!;
const followups = presentation.interactions.filter(
(item) => item.kind === "followup",
);
const save = async (kind: "feedback" | "export") => {
setSaving(true);
setNotice("");
try {
const response = await fetchAuthenticated(
`/api/asset-assistant/${kind}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: result.sessionId,
resultId: result.resultId,
subject,
}),
},
);
if (!response.ok) throw new Error();
if (kind === "feedback") {
setFlaggedResult(resultKey);
setNotice("已记录,我们会据此完善问答能力。");
} else {
const url = URL.createObjectURL(await response.blob());
const link = document.createElement("a");
link.href = url;
link.download = "资产分析明细.csv";
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
setNotice("已按当前范围导出明细。");
}
} catch {
setNotice(
kind === "feedback"
? "需求未保存,请稍后重试。"
: "导出失败,请重新查询后重试。",
);
} finally {
setSaving(false);
}
};
return (
<div className="aq-result aq-generative-result">
<header
className="aq-query-heading"
id={result.resultId ? `aq-query-${result.resultId}` : undefined}
>
<div className="aq-current-path" aria-label="当前查询范围">
{path.map((item, index) => (
<span
key={index}
className={
index === path.length - 1 ? "aq-path-current" : undefined
}
>
{item}
</span>
))}
</div>
<div className="aq-query-navigation">
<span>
{scope.company}
{result.plan.kind === "flow" ? ` · ${scope.period}` : ""}
</span>
{parent && (
<button
disabled={disabled}
onClick={() => restore(parent.resultId)}
>
<ChevronLeft size={13} />
返回上层
</button>
)}
{breadcrumbs.length > 1 && (
<details className="aq-path-history">
<summary>分析路径</summary>
<nav>
{breadcrumbs.map((item) => (
<button
key={item.resultId}
disabled={disabled || item.resultId === result.resultId}
onClick={() => restore(item.resultId)}
>
{item.label}
</button>
))}
</nav>
</details>
)}
</div>
</header>
{correctionMessage && (
<div className="aq-correction" role="status">
<strong>此回答已撤回</strong>
<p>{correctionMessage}</p>
{correctionResultId && (
<button onClick={() => showCorrection(correctionResultId)}>
查看纠正后的结果
<ArrowUpRight size={13} />
</button>
)}
</div>
)}
{result.correction && (
<p className="aq-generated-notice">
已纠正上一条回答:{result.correction.message}
</p>
)}
{presentation.title && (
<h3 className="aq-generated-title">{presentation.title}</h3>
)}
{result.outcome === "empty" && (
<p className="aq-generated-notice">
当前条件下无匹配数据,可调整范围后继续查询。
</p>
)}
{result.outcome === "partial" && (
<p className="aq-generated-notice">
已返回可查结果,其余问题单独说明。
</p>
)}
{result.outcome === "unsupported" && (
<p className="aq-generated-notice">本次需求暂不能完整生成。</p>
)}
{presentation.blocks.map((block) => (
<Block
key={block.id}
block={block}
result={result}
operate={operate}
disabled={disabled || !!correctionMessage}
/>
))}
{!!result.unanswered?.length && (
<div className="aq-compound-unanswered">
<strong>尚未回答</strong>
{result.unanswered.map((item) => (
<p key={item.question}>
{item.question}:{item.reason}
</p>
))}
</div>
)}
{result.warning && (
<p className="aq-generated-notice" role="status">
{result.warning}
</p>
)}
{result.presentationWarning && (
<p className="aq-generated-notice" role="status">
{result.presentationWarning}
</p>
)}
{result.gap && (
<p className="aq-generated-notice">
{result.gap.recorded
? "未满足的需求已记录。"
: "未满足的需求暂未保存。"}
</p>
)}
<footer className="aq-result-footer">
<details className="aq-result-definitions">
<summary>统计口径、权限与数据时间</summary>
{!presentation.blocks.some((block) => block.kind === "summary") && (
<p>{result.summary}</p>
)}
<p>
公司:{scope.company} · 权限:{scope.permission}
</p>
<p>生效条件:{scope.filters.join(" · ") || "无额外筛选"}</p>
<p>
分组:{scope.grouping} · 期间:{scope.period}
</p>
<p>
源数据更新时间:{displayTime(scope.sourceUpdatedAt)} · 查询时间:
{displayTime(result.fetchedAt)}
</p>
{scope.definitions.map((item) => (
<p key={item}>{item}</p>
))}
{result.contextNote && <p>{result.contextNote}</p>}
<p>来源:{result.source}</p>
</details>
<div className="aq-answer-actions">
<button
disabled={disabled || saving}
onClick={() => void save("export")}
>
<ArrowDownToLine size={14} />
导出明细
</button>
<button
disabled={disabled || active || !!correctionMessage}
onClick={activate}
>
{active ? "正在基于此结果追问" : "基于此结果追问"}
</button>
<button
disabled={saving || flagged}
onClick={() => void save("feedback")}
>
{flagged ? "已记录" : "未解决我的问题"}
</button>
<button
onClick={() => {
void navigator.clipboard
.writeText(copyResultText(result, subject, correctionMessage))
.then(() => setNotice("已复制完整结果与统计口径。"))
.catch(() => setNotice("复制失败,请手动选择文字。"));
}}
>
<Copy size={14} />
复制完整结果
</button>
</div>
{notice && (
<p className="aq-notice" role="status">
{notice}
</p>
)}
</footer>
{!!followups.length && (
<div className="aq-followups">
<span>继续分析</span>
{followups.map((item) => (
<button
key={item.id}
disabled={disabled || !!correctionMessage}
onClick={() =>
operate({ type: "explore", interactionId: item.id }, item.label)
}
>
{item.label}
<ArrowUpRight size={13} />
</button>
))}
</div>
)}
</div>
);
}
export const GenerativeResultView = memo(
GenerativeResultViewContent,
(previous, next) =>
previous.result === next.result &&
previous.active === next.active &&
previous.subject === next.subject &&
previous.disabled === next.disabled &&
previous.correctionMessage === next.correctionMessage &&
previous.correctionResultId === next.correctionResultId,
);