Files
ln-bi/src/modules/assets/assistant/ResultView.tsx
T

960 lines
37 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, useMemo, useState } from "react";
import {
ArrowDownToLine,
ArrowUpRight,
ChevronLeft,
ChevronRight,
Copy,
} from "lucide-react";
import {
DIMENSIONS,
GROUP_LABELS,
type AssistantAction,
type AssistantResult,
type AssistantRow,
type Dimension,
type QueryPlan,
} from "../../../shared/assets/assistant";
import { fetchAuthenticated } from "../../../auth/api-client";
import { Chart } from "./Chart";
import {
copyResultText,
displayTime,
resultScope,
resultDisplay,
queryPath,
compactAnalysisPath,
} from "./result-presentation";
import { useMobileLayout } from '../useMobileLayout';
import { mobileResultSummary } from './mobile-presentation';
import { ResultActions } from './ResultActions';
function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) {
const mobile = useMobileLayout();
const [page, setPage] = useState(0),
[search, setSearch] = useState("");
const filtered = rows.filter((r) =>
[r.plate, r.city, r.type, r.model, r.department, r.abnormalType].some((v) =>
v?.includes(search),
),
);
const pages = Math.max(1, Math.ceil(filtered.length / 8)),
current = Math.min(page, pages - 1);
return (
<>
<div className="aq-record-toolbar">
<label>
<span className="sr-only">筛选明细</span>
<input
value={search}
placeholder="搜索当前明细…"
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
/>
</label>
<span>{filtered.length}条</span>
</div>
{mobile ? <div className="aq-mobile-data-list" aria-label="车辆明细">{filtered.slice(current * 8, current * 8 + 8).map(r => <article className="aq-mobile-data-row" key={r.id}><div className="aq-mobile-record-heading"><strong>{r.plate || '未登记车牌'}</strong><span>{flow ? r.event : r.status}</span></div><p>{r.type} · {r.city || '未标注城市'}</p><details><summary>更多资料</summary><dl>{[['型号', r.model], ['部门', r.department], ...(flow ? [['日期', r.date]] : [['库存子集', r.abnormal ? '异动' : r.pending ? '待交车' : '—'], ['异动类型', r.abnormalType], ['预计完成时间', r.targetCompletionTime]])].map(([label,value]) => <div key={label}><dt>{label}</dt><dd>{value || '—'}</dd></div>)}</dl></details></article>)}</div> : <div className="aq-table-scroll">
<table>
<thead>
<tr>
{[
"车牌",
"车型 / 型号",
"城市",
flow ? "业务类型" : "运营状态",
...(flow ? ["日期"] : ["库存子集", "异动类型", "预计完成时间"]),
].map((h) => (
<th key={h}>{h}</th>
))}
</tr>
</thead>
<tbody>
{filtered.slice(current * 8, current * 8 + 8).map((r) => (
<tr key={r.id}>
<td className="aq-plate">{r.plate || "未登记车牌"}</td>
<td>
{r.type}
<small>{r.model}</small>
</td>
<td>{r.city}</td>
<td>{flow ? r.event : r.status}</td>
{flow ? (
<td>{r.date}</td>
) : (
<>
<td>{r.abnormal ? "异动" : r.pending ? "待交车" : "—"}</td>
<td>{r.abnormalType || "—"}</td>
<td>{r.targetCompletionTime || "—"}</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>}
{!filtered.length && <p className="aq-no-data">没有符合条件的明细</p>}
<div className="aq-pagination">
<span>
第{current + 1}/{pages}页
</span>
<button
aria-label="上一页"
disabled={!current}
onClick={() => setPage(current - 1)}
>
<ChevronLeft size={16} />
</button>
<button
aria-label="下一页"
disabled={current >= pages - 1}
onClick={() => setPage(current + 1)}
>
<ChevronRight size={16} />
</button>
</div>
{search && (
<p className="aq-notice">
此搜索仅筛选当前明细表;继续追问仍使用上方分析条件。
</p>
)}
</>
);
}
export type ResultViewProps = {
result: AssistantResult;
ask: (text: string) => void;
operate: (action: AssistantAction, label: string) => void;
restore: (resultId: string) => void;
active: boolean;
activate: () => void;
subject: string | null;
disabled: boolean;
correctionMessage?: string;
correctionResultId?: string;
showCorrection: (resultId: string) => void;
display?: "metrics" | "chart" | "table" | "list";
};
function ResultViewContent({
result,
ask,
operate,
restore,
active,
activate,
subject,
disabled,
correctionMessage,
correctionResultId,
showCorrection,
display: preferredDisplay,
}: ResultViewProps) {
const mobile = useMobileLayout();
const [expandedGroupResult, setExpandedGroupResult] = useState<string | null>(
null,
),
[notice, setNotice] = useState(""),
[flagged, setFlagged] = useState(false),
[saving, setSaving] = useState(false);
const { plan } = result,
flow = plan.kind === "flow";
const run = (action: AssistantAction, label: string) =>
operate(action, label);
const flag = async () => {
setSaving(true);
setNotice("");
try {
const r = await fetchAuthenticated("/api/asset-assistant/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: result.sessionId,
resultId: result.resultId,
subject,
}),
});
if (!r.ok) throw new Error();
setFlagged(true);
setNotice("已记录,我们会据此完善问答能力。");
} catch {
setNotice("需求未保存,请稍后重试。");
} finally {
setSaving(false);
}
};
const download = async () => {
setSaving(true);
setNotice("");
try {
const r = await fetchAuthenticated("/api/asset-assistant/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: result.sessionId,
resultId: result.resultId,
subject,
}),
});
if (!r.ok) throw new Error();
const url = URL.createObjectURL(await r.blob());
const a = document.createElement("a");
a.href = url;
a.download = "资产分析明细.csv";
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
setNotice("已按当前权限重新查询并导出明细,数量可能随业务更新变化。");
} catch {
setNotice("导出失败,请重新查询后重试。");
} finally {
setSaving(false);
}
};
const showAll = expandedGroupResult === (result.resultId || result.fetchedAt);
const suggestions = [...new Set(result.suggestions || [])];
const currentPath = queryPath(plan);
const analysisPath = compactAnalysisPath(result.breadcrumbs);
const currentPathLabel = result.breadcrumbs?.find(
(crumb) => crumb.resultId === result.resultId,
)?.label;
const ancestors = analysisPath.filter(
(crumb) =>
crumb.resultId !== result.resultId && crumb.label !== currentPathLabel,
);
const parent = ancestors.at(-1);
const scope = resultScope(result, subject);
const briefing = plan.analysis === "briefing";
const display = preferredDisplay || resultDisplay(plan);
const viewLabels = [
!flow && plan.groupBy === "none" && !plan.secondaryGroupBy
? "指标卡"
: "图表",
"统计表",
"明细",
];
const showFourTotals =
briefing ||
(plan.kind === "assets" &&
plan.category === "all" &&
result.metrics.some((metric) => /库存/.test(metric.label)));
const metricPriority = preferredDisplay === "metrics" && plan.category === "inventory"
? ["库存车辆", "待交车占库存", "异动占库存"]
: undefined;
const orderedMetrics = metricPriority
? [...result.metrics].sort((a, b) => {
const rank = (label: string) => metricPriority.includes(label) ? metricPriority.indexOf(label) : metricPriority.length;
return rank(a.label) - rank(b.label);
})
: result.metrics;
const visibleMetrics = orderedMetrics.slice(0, showFourTotals ? 4 : 3);
const extraMetrics = orderedMetrics.slice(visibleMetrics.length);
const outcomeLabels = {
partial: "已回答可查部分",
empty: "暂无符合条件数据",
unsupported: "暂不支持该指标",
clarification: "需要补充一个条件",
help: "使用帮助",
complete: "",
};
const largest = useMemo(
() => Math.max(1, ...result.groups.map((g) => g.count)),
[result.groups],
);
const option = useMemo(
() => ({
color: ["#668874", "#b5bdc4", "#a58b65"],
tooltip: { trigger: "axis", renderMode: "richText" },
legend: { bottom: 0 },
grid: { left: 36, right: 10, top: 12, bottom: 45 },
xAxis: {
type: "category",
data: result.daily?.map((d) => d.date.slice(5)),
},
yAxis: { type: "value", minInterval: 1 },
series: (["delivered", "returned", "replaced"] as const).map(
(key, i) => ({
name: ["交车(含替换)", "还车", "其中替换"][i],
type: "line",
data: result.daily?.map((d) => d[key]),
}),
),
animation: false,
}),
[result.daily],
);
return (
<div className={`aq-result ${active ? "aq-result-active" : ""}`}>
{plan.kind !== "clarify" && !result.conciseCount && (
<header
className="aq-query-heading"
id={result.resultId ? `aq-query-${result.resultId}` : undefined}
>
<div className="aq-current-path" aria-label="当前查询维度">
{currentPath.map((step, index) => (
<span
className={
index === currentPath.length - 1 ? "aq-path-current" : ""
}
key={`${index}-${step}`}
>
{index > 0 && index < currentPath.length - 1 && (
<ChevronRight size={12} aria-hidden="true" />
)}
{step}
</span>
))}
</div>
<div className="aq-query-navigation">
<span>
{scope.company}
{flow ? ` · ${scope.period}` : ""}
</span>
{parent && (
<button
disabled={disabled}
onClick={() => restore(parent.resultId)}
>
<ChevronLeft size={13} />
返回上层
</button>
)}
{analysisPath.length > 1 && (
<details className="aq-path-history">
<summary>完整分析路径</summary>
<nav aria-label="完整分析路径">
{analysisPath.map((crumb) => (
<button
key={crumb.resultId}
disabled={disabled || crumb.resultId === result.resultId}
onClick={() => restore(crumb.resultId)}
>
{crumb.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 && (
<div className="aq-correction aq-correction-current" role="status">
<strong>已纠正上一条回答</strong>
<p>{result.correction.message}</p>
</div>
)}
{result.outcome && outcomeLabels[result.outcome] && (
<p className={`aq-outcome aq-outcome-${result.outcome}`}>
{outcomeLabels[result.outcome]}
</p>
)}
<p
className="aq-answer"
id={result.resultId ? `aq-answer-${result.resultId}` : undefined}
>
{mobile ? mobileResultSummary(result) : result.summary}
</p>
{!result.conciseCount && !!visibleMetrics.length && (
<div className="aq-metrics aq-primary-metrics">
{visibleMetrics.map((metric) => (
<div key={metric.label}>
<span>{metric.label}</span>
<strong>
{metric.value.toLocaleString()}
<small>{metric.unit}</small>
</strong>
</div>
))}
</div>
)}
{!result.conciseCount && !!extraMetrics.length && (
<details className="aq-extra-metrics">
<summary>查看其余 {extraMetrics.length} 项指标</summary>
<div>
{extraMetrics.map((metric) => (
<span key={metric.label}>
{metric.label}:
<strong>
{metric.value.toLocaleString()}
{metric.unit}
</strong>
</span>
))}
</div>
</details>
)}
{!!result.unanswered?.length && (
<section className="aq-unanswered" aria-label="尚未回答的部分">
<strong>尚未回答的部分</strong>
{result.unanswered.map((item) => (
<p key={item.question}>
<span>{item.question}</span>
{item.reason}
</p>
))}
</section>
)}
{result.gap && (
<p className="aq-gap-note">
{result.gap.recorded
? "这个需求已记下。"
: "可以点击下方「未解决我的问题」记录需求。"}
</p>
)}
{plan.kind !== "clarify" && !result.conciseCount && (
<>
<div className="aq-result-card">
{result.inventoryBreakdown && display !== "metrics" && (
<section className="aq-briefing-inventory">
<h4>{result.inventoryBreakdown.title}</h4>
<p>{result.inventoryBreakdown.summary}</p>
{briefing && !!result.insights?.length && (
<details className="aq-secondary-details">
<summary>分析建议 · {result.insights.length}项</summary>
<ul className="aq-insights">
{result.insights.map((text) => (
<li key={text}>{text}</li>
))}
</ul>
</details>
)}
<div className="aq-briefing-groups">
{result.inventoryBreakdown.groups.slice(0, 5).map((group) => (
<div key={group.name}>
<span>{group.name}</span>
<strong>{group.count}台</strong>
<span>
{group.share === undefined ? "" : `${group.share}%`}
</span>
</div>
))}
</div>
<button
disabled={disabled}
onClick={() =>
run(
{ type: "category", category: "inventory" },
"查看库存详细分析",
)
}
>
查看库存详细分析 <ArrowUpRight size={13} />
</button>
</section>
)}
{!briefing && (
<>
<details className="aq-advanced-controls">
<summary>维度与分析</summary>
<div className="aq-analysis-controls">
{!flow && (
<>
<label>
主维度{" "}
<select
aria-label="主维度"
disabled={disabled}
value={plan.groupBy}
onChange={(e) =>
run(
{
type: "group",
groupBy: e.target
.value as QueryPlan["groupBy"],
},
"切换统计维度",
)
}
>
{[...DIMENSIONS, "none" as const].map((d) => (
<option value={d} key={d}>
{GROUP_LABELS[d]}
</option>
))}
</select>
</label>
<label>
交叉维度{" "}
<select
aria-label="交叉维度"
disabled={disabled || plan.groupBy === "none"}
value={plan.secondaryGroupBy || ""}
onChange={(e) =>
run(
{
type: "group",
groupBy: plan.groupBy,
secondaryGroupBy: (e.target.value ||
undefined) as Dimension | undefined,
},
"交叉统计",
)
}
>
<option value="">不交叉</option>
{DIMENSIONS.filter((d) => d !== plan.groupBy).map(
(d) => (
<option key={d} value={d}>
{GROUP_LABELS[d]}
</option>
),
)}
</select>
</label>
<label>
分析方式{" "}
<select
aria-label="分析方式"
disabled={disabled}
value={plan.analysis || "distribution"}
onChange={(e) =>
run(
{
type: "analysis",
analysis: e.target.value as NonNullable<
QueryPlan["analysis"]
>,
},
"切换分析方式",
)
}
>
<option value="distribution">分布统计</option>
<option value="compare">分组对比</option>
<option value="exceptions">异常定位</option>
</select>
</label>
</>
)}
{flow && (
<button
disabled={disabled}
onClick={() =>
run({ type: "compare-period" }, "对比上一等长周期")
}
>
对比上一等长周期
</button>
)}
</div>
</details>
{!!result.insights?.length && (
<details className="aq-secondary-details">
<summary>分析发现 · {result.insights.length}项</summary>
<ul className="aq-insights">
{result.insights.map((text) => (
<li key={text}>{text}</li>
))}
</ul>
</details>
)}
{!flow && !!result.exceptions?.some((item) => item.count) && (
<details
className="aq-secondary-details"
open={plan.analysis === "exceptions"}
>
<summary>异常与待交车</summary>
<div className="aq-exceptions">
{result.exceptions?.map((e) => (
<button
key={e.label}
disabled={disabled || !e.count}
onClick={() =>
run(
e.category
? { type: "category", category: e.category }
: { type: "exception", exception: "overdue" },
`查看${e.label}`,
)
}
>
{e.label}
<strong>{e.count}台</strong>
</button>
))}
</div>
</details>
)}
{result.groupComparison && display !== "metrics" && (
<div className="aq-table-scroll">
<p className="aq-chart-caption">
当前分组对比 · 数量最多的分组为基准:
{result.groupComparison.baseline}
</p>
<table>
<thead>
<tr>
{[
"分组",
"车辆数",
"较基准差值",
"占比",
"运营率差(百分点)",
].map((h) => (
<th key={h}>{h}</th>
))}
</tr>
</thead>
<tbody>
{result.groupComparison.rows.map((r) => (
<tr key={r.name}>
<td>{r.name}</td>
<td>{r.count}</td>
<td>{r.delta}</td>
<td>{r.share}%</td>
<td>{r.rateDelta}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{result.comparison && display !== "metrics" && (
<div className="aq-table-scroll">
<p>
上一等长周期:{result.comparisonPeriod?.start} —{" "}
{result.comparisonPeriod?.end}
</p>
<table>
<thead>
<tr>
{["指标", "本期", "上期", "差值", "变化率"].map(
(h) => (
<th key={h}>{h}</th>
),
)}
</tr>
</thead>
<tbody>
{result.comparison.map((m) => (
<tr key={m.label}>
<td>{m.label}</td>
<td>{m.current}</td>
<td>{m.previous}</td>
<td>
{m.delta > 0 ? "+" : ""}
{m.delta}
</td>
<td>
{m.rate === null
? "基期为0,不计算"
: `${m.rate}%`}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="aq-result-toolbar">
<div className="aq-view-tabs" aria-label="结果展示方式">
{(["chart", "table", "list"] as const).map((v, i) => (
<button
key={v}
aria-pressed={
display === "metrics"
? viewLabels[i] === "指标卡"
: plan.view === v
}
disabled={disabled}
onClick={() =>
run({ type: "view", view: v }, `显示${viewLabels[i]}`)
}
>
{viewLabels[i]}
</button>
))}
</div>
<span>
{result.rows.length}
{flow ? "笔业务" : "台车辆"}
</span>
</div>
{display === "metrics" ? null : plan.view === "list" ? (
<Records
key={result.resultId}
rows={result.rows}
flow={flow}
/>
) : flow ? (
<>
{plan.view === "chart" && (
<div className="aq-flow-chart">
<Chart option={option} label="交退换车按日趋势" />
</div>
)}
<details
className="aq-daily-details"
open={plan.view === "table"}
>
<summary>
每日车次与下钻明细 · {result.daily?.length || 0}天
</summary>
<div className="aq-table-scroll">
<table>
<thead>
<tr>
{[
"日期",
"交车(含替换)",
"还车",
"其中替换",
"查看",
].map((h) => (
<th key={h}>{h}</th>
))}
</tr>
</thead>
<tbody>
{result.daily?.map((d) => (
<tr key={d.date}>
<td>{d.date}</td>
<td>{d.delivered}</td>
<td>{d.returned}</td>
<td>{d.replaced}</td>
<td>
<button
disabled={disabled}
onClick={() =>
run(
{ type: "day", date: d.date },
`查看${d.date}明细`,
)
}
aria-label={`查看${d.date}明细`}
>
<ArrowUpRight size={15} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</>
) : plan.view === "chart" ? (
<div className="aq-bars">
{(showAll ? result.groups : result.groups.slice(0, 5)).map(
(g) => (
<button
key={JSON.stringify(g.keys)}
className="aq-bar-row"
disabled={
disabled || !Object.keys(g.keys || {}).length
}
onClick={() =>
run(
{ type: "drill", keys: g.keys || {} },
`下钻${g.name}`,
)
}
aria-label={`下钻${g.name},${g.count}台`}
>
<span className="aq-bar-name">{g.name}</span>
<span className="aq-bar-track">
<span
className="aq-bar-fill aq-bar-lead"
style={{ width: `${(g.count / largest) * 100}%` }}
/>
</span>
<strong>{g.count}</strong>
<span className="aq-bar-share">{g.share}%</span>
<ArrowUpRight size={13} />
</button>
),
)}
{result.groups.length > 5 && (
<button
className="aq-more"
onClick={() =>
setExpandedGroupResult(
showAll
? null
: result.resultId || result.fetchedAt,
)
}
>
{showAll ? "收起" : `展开全部${result.groups.length}组`}
</button>
)}
<p className="aq-chart-caption">
点击分组继续下钻 · 筛选条件自动用于下一次追问
</p>
</div>
) : mobile ? (
<div className="aq-mobile-data-list" aria-label="分组明细">{result.groups.map(g => <article className="aq-mobile-data-row" key={JSON.stringify(g.keys)}><div className="aq-mobile-data-values"><div><span>分组</span><strong>{g.name}</strong></div><div><span>车辆数</span><strong>{g.count}</strong></div><div><span>占比</span><strong>{g.share == null ? '—' : `${g.share}%`}</strong></div></div><div className="aq-mobile-data-footer">{plan.category !== 'operating' && <details><summary>更多指标</summary><p>运营率 {g.rate}% · 异动 {g.abnormal ?? '—'} · 待交车 {g.pending ?? '—'}</p></details>}<button disabled={disabled || !Object.keys(g.keys || {}).length} aria-label={`下钻${g.name}`} onClick={() => run({ type: 'drill', keys: g.keys || {} }, `下钻${g.name}`)}>继续分析<ArrowUpRight size={15} /></button></div></article>)}</div>
) : (
<div className="aq-table-scroll">
<table>
<thead>
<tr>
{[
"分组",
"车辆数",
"占比",
...(result.plan.category === "operating" ? [] : ["运营率", "异动", "待交车"]),
"下钻",
].map((h) => (
<th key={h}>{h}</th>
))}
</tr>
</thead>
<tbody>
{result.groups.map((g) => (
<tr key={JSON.stringify(g.keys)}>
<td>{g.name}</td>
<td>{g.count}</td>
<td>{g.share}%</td>
{result.plan.category !== "operating" && <>
<td>{g.rate}%</td>
<td>{g.abnormal}</td>
<td>{g.pending}</td>
</>}
<td>
<button
disabled={
disabled || !Object.keys(g.keys || {}).length
}
onClick={() =>
run(
{ type: "drill", keys: g.keys || {} },
`下钻${g.name}`,
)
}
aria-label={`下钻${g.name}`}
>
<ArrowUpRight size={15} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!result.rows.length && (
<p className="aq-no-data">
当前条件没有匹配数据,可返回上层调整。
</p>
)}
</>
)}
</div>
</>
)}
{result.conciseCount && result.metrics[0]?.value > 0 && (
<button className="aq-more" disabled={disabled} onClick={() => run({ type: "view", view: "list" }, "查看车辆明细")}>
查看车辆明细 <ArrowUpRight size={14} aria-hidden="true" />
</button>
)}
<div className="aq-result-footer">
<details className="aq-result-definitions">
<summary>
统计口径、权限与数据时间{result.warning ? " · 有补充说明" : ""}
</summary>
{mobile && <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>
{result.contextNote && <p>{result.contextNote}</p>}
{result.warning && <p role="status">{result.warning}</p>}
{scope.definitions.map((definition) => (
<p key={definition}>{definition}</p>
))}
<p>来源:{result.source}</p>
</details>
<ResultActions primary={plan.kind !== "clarify" && (
<button
disabled={disabled || active || !!correctionMessage}
onClick={activate}
>
{mobile ? (active ? "当前追问范围" : "继续追问") : (active ? "正在基于此结果追问" : "基于此结果追问")}
</button>
)}>
{plan.kind !== "clarify" && (!result.conciseCount || result.metrics[0]?.value > 0) && (
<button
disabled={disabled || saving}
onClick={() => void download()}
aria-label="导出当前结果"
>
<ArrowDownToLine size={14} />
导出明细
</button>
)}
<button disabled={saving || flagged} onClick={() => void flag()}>
{flagged ? "已记录" : "未解决我的问题"}
</button>
<button
onClick={() => {
void navigator.clipboard
.writeText(copyResultText(result, subject, correctionMessage))
.then(() => setNotice("已复制结论、范围、口径及数据时间。"))
.catch(() => setNotice("复制失败,请手动选择文字。"));
}}
>
<Copy size={14} />
复制完整结果
</button>
</ResultActions>
{notice && (
<p className="aq-notice" role="status">
{notice}
</p>
)}
</div>
{!result.conciseCount && !!suggestions.length && (
<div className="aq-followups">
<span>{plan.kind === "clarify" ? "你可以这样继续" : "继续分析"}</span>
{suggestions.slice(0, 4).map((question) => (
<button
key={question}
disabled={disabled}
onClick={() => ask(question)}
>
{question}
<ArrowUpRight size={13} />
</button>
))}
</div>
)}
</div>
);
}
export const ResultView = memo(
ResultViewContent,
// Event handlers close over this result; restore reads the latest turns from turnsRef.
(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 &&
previous.display === next.display,
);