683 lines
21 KiB
TypeScript
683 lines
21 KiB
TypeScript
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,
|
||
);
|