951 lines
35 KiB
TypeScript
951 lines
35 KiB
TypeScript
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";
|
||
|
||
function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) {
|
||
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>
|
||
<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 [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}
|
||
>
|
||
{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>
|
||
) : (
|
||
<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>
|
||
<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>
|
||
<div className="aq-answer-actions">
|
||
{plan.kind !== "clarify" && (!result.conciseCount || result.metrics[0]?.value > 0) && (
|
||
<button
|
||
disabled={disabled || saving}
|
||
onClick={() => void download()}
|
||
aria-label="导出当前结果"
|
||
>
|
||
<ArrowDownToLine size={14} />
|
||
导出明细
|
||
</button>
|
||
)}
|
||
{plan.kind !== "clarify" && (
|
||
<button
|
||
disabled={disabled || active || !!correctionMessage}
|
||
onClick={activate}
|
||
>
|
||
{active ? "正在基于此结果追问" : "基于此结果追问"}
|
||
</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>
|
||
</div>
|
||
{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,
|
||
);
|