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";
import { useMobileLayout } from '../useMobileLayout';
import { mobileBlockGroups, mobileResultSummary } from './mobile-presentation';
import { ResultActions } from './ResultActions';
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 mobile = useMobileLayout();
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 (
<>
{mobile ?
{data.slice(currentPage * 8, currentPage * 8 + 8).map((row, index) => {
const action = presentationAction(interaction, row);
const vehicle = block.dataset === 'rows' && columns.some(column => column.field === 'plate');
const primary = vehicle ? columns.filter(column => ['plate', 'status', 'event', 'vehicleClass', 'type', 'city'].includes(column.field)) : columns.slice(0, 3);
const extra = columns.filter(column => !primary.includes(column));
return
{vehicle ? <>{presentationValue(row.plate, 'plate')}{primary.filter(column => ['status', 'event'].includes(column.field)).map(column => presentationValue(row[column.field], column.field)).join(' · ')}
{primary.filter(column => !['plate', 'status', 'event'].includes(column.field)).map(column => presentationValue(row[column.field], column.field)).join(' · ')}
> : {primary.map(column =>
{column.label}{presentationValue(row[column.field], column.field)}
)}
}
{(extra.length > 0 || action) && {extra.length > 0 &&
更多字段
{extra.map(column => - {column.label}
- {presentationValue(row[column.field], column.field)}
)}
}{action &&
}
}
;
})}
:
{block.title || "分析数据"}
{columns.map((column) => (
|
{column.label}
|
))}
{interaction && 继续分析 | }
{data
.slice(currentPage * 8, currentPage * 8 + 8)
.map((row, index) => {
const action = presentationAction(interaction, row);
return (
{columns.map((column) => (
|
{presentationValue(row[column.field], column.field)}
|
))}
{interaction && (
{action ? (
) : (
"—"
)}
|
)}
);
})}
}
{data.length > 8 && (
第 {currentPage * 8 + 1}–
{Math.min(data.length, currentPage * 8 + 8)} 条,共 {data.length} 条
)}
>
);
}
function DataChart(props: DataBlockProps) {
const mobile = useMobileLayout();
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 ;
const common = { data, margin: { top: 8, right: 16, bottom: 0, left: 0 } };
if (mobile && (block.chart === 'bar' || (block.chart === 'donut' && block.dataset === 'groups')) && series.length === 1 && allData.every(row => typeof row[series[0].field] === 'number' && Number(row[series[0].field]) >= 0)) {
const field = series[0].field;
const max = Math.max(1, ...allData.map(row => typeof row[field] === 'number' ? Math.abs(row[field] as number) : 0));
return {(expanded ? allData : allData.slice(0, 5)).map((row, index) => {
const action = presentationAction(interaction, row);
const content = <>
{datumName(row)}{presentationValue(row[field], field)}{action && }>;
return action ?
:
{content}
;
})}{allData.length > 5 &&
}
;
}
return (
<>
{block.chart === "donut" ? (
explore(entry.payload as PresentationDatum)}
>
{data.map((_, index) => (
|
))}
presentationValue(value, series[0].field)}
/>
) : block.chart === "line" ? (
["rate", "share"].includes(item.field))} />
{series.map((item, index) => (
))}
) : (
["rate", "share"].includes(item.field))} />
{series.map((item, index) => (
))}
)}
{interaction && (
{data.map((row, index) => (
))}
)}
{allData.length > 5 &&
block.chart !== "line" &&
block.chart !== "donut" && (
)}
{!mobile &&
查看图表数据
}
>
);
}
function Block({
block,
result,
operate,
disabled,
}: Omit) {
const mobile = useMobileLayout();
const [expandedMetrics, setExpandedMetrics] = useState(false);
const metrics = presentationMetrics(result, block.metrics);
const interaction = result.presentation?.interactions.find(
(item) => item.id === block.interactionId,
);
const data = presentationData(result, block.dataset);
return (
{block.title && {block.title}
}
{block.kind === "summary" ? (
item.kind === "summary")
?.id === block.id
? `aq-answer-${result.resultId}`
: undefined
}
>
{block.text || result.summary}
) : block.kind === "narrative" ? (
{block.text}
) : block.kind === "metrics" ? (
<>
{(mobile && !expandedMetrics ? metrics.slice(0, 4) : metrics).map((metric) => (
{metric.label}
{metric.value.toLocaleString()}
{metric.unit}
))}
{mobile && metrics.length > 4 && }>
) : (
<>
{interaction && !mobile && (
点击{block.dataset === "daily" ? "日期" : "数据项"} →{" "}
{interaction.label}
)}
{data.length ? (
block.kind === "chart" ? (
) : (
)
) : (
当前范围暂无匹配数据。
)}
>
)}
);
}
function MobileDataBlock({ block, table, ...props }: Omit & { table?: PresentationBlock }) {
const [view, setView] = useState<'chart' | 'table'>('chart');
const tableBlock: PresentationBlock = table || { ...block, kind: 'table', columns: block.columns || [{ field: block.dataset === 'daily' ? 'date' : block.dataset === 'comparison' ? 'label' : 'name', label: block.dataset === 'daily' ? '日期' : '分组' }, ...presentationSeries(block)] };
return {block.title || '数据分析'}
;
}
function GenerativeResultViewContent({
result,
operate,
restore,
active,
activate,
subject,
disabled,
correctionMessage,
correctionResultId,
showCorrection,
}: ResultViewProps) {
const mobile = useMobileLayout();
const [noticeByResult, setNoticeByResult] = useState<{
key: string;
text: string;
}>();
const [saving, setSaving] = useState(false);
const [flaggedResult, setFlaggedResult] = useState();
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 (
{path.map((item, index) => (
{item}
))}
{scope.company}
{result.plan.kind === "flow" ? ` · ${scope.period}` : ""}
{parent && (
)}
{breadcrumbs.length > 1 && (
分析路径
)}
{correctionMessage && (
此回答已撤回
{correctionMessage}
{correctionResultId && (
)}
)}
{result.correction && (
已纠正上一条回答:{result.correction.message}
)}
{!mobile && presentation.title && (
{presentation.title}
)}
{result.outcome === "empty" && (
当前条件下无匹配数据,可调整范围后继续查询。
)}
{result.outcome === "partial" && (
已返回可查结果,其余问题单独说明。
)}
{result.outcome === "unsupported" && (
本次需求暂不能完整生成。
)}
{mobile ? <>
{mobileResultSummary(result)}
{mobileBlockGroups(presentation.blocks).filter(({ block }) => block.kind !== 'summary' && block.kind !== 'narrative').map(({ block, table }) => block.kind === 'chart' ?
:
)}
分析说明
{result.summary}
{presentation.blocks.filter(block => (block.kind === 'summary' || block.kind === 'narrative') && block.text && block.text !== result.summary).map(block => {block.text}
)}
> : presentation.blocks.map((block) =>
)}
{!!result.unanswered?.length && (
尚未回答
{result.unanswered.map((item) => (
{item.question}:{item.reason}
))}
)}
{result.warning && (
{result.warning}
)}
{result.presentationWarning && (
{result.presentationWarning}
)}
{result.gap && (
{result.gap.recorded
? "未满足的需求已记录。"
: "未满足的需求暂未保存。"}
)}
{!!followups.length && (
继续分析
{followups.map((item) => (
))}
)}
);
}
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,
);