feat(assets): add DeepSeek assistant with streaming results and voice input

This commit is contained in:
shishengliang
2026-09-23 14:16:36 +08:00
parent 822aabf2ca
commit 0c4a9bcd05
14 changed files with 3355 additions and 3 deletions
+508
View File
@@ -0,0 +1,508 @@
import { useMemo, useState } from "react";
import {
ArrowDownToLine,
ArrowLeft,
ArrowUpRight,
BarChart3,
Check,
ChevronLeft,
ChevronRight,
Copy,
List,
Table2,
} from "lucide-react";
import type {
AssistantResult,
AssistantRow,
} from "../../../shared/assets/assistant";
import { GROUP_LABELS } from "../../../shared/assets/assistant";
import { Chart } from "../library/Chart";
function downloadCsv(rows: string[][], title: string) {
const content = rows
.map((row) =>
row
.map(
(value) =>
`"${String(value)
.replace(/^[=+@\-\t\r]/, "'$&")
.replaceAll('"', '""')}"`,
)
.join(","),
)
.join("\r\n");
const url = URL.createObjectURL(
new Blob(["\uFEFF" + content], { type: "text/csv;charset=utf-8;" }),
);
const a = document.createElement("a");
a.href = url;
a.download = `${title}.csv`;
a.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) {
const [page, setPage] = useState(0);
const [search, setSearch] = useState("");
const filtered = rows.filter((row) =>
Object.values(row).some((value) => String(value).includes(search)),
);
const max = Math.max(1, Math.ceil(filtered.length / 8));
const current = Math.min(page, max - 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.toLocaleString()} 条记录</span>
</div>
<div className="aq-table-scroll">
<table>
<thead>
<tr>
<th>车牌</th>
<th>车型 / 型号</th>
<th>城市</th>
<th>{flow ? "业务类型" : "运营状态"}</th>
{flow && <th>日期</th>}
</tr>
</thead>
<tbody>
{filtered.slice(current * 8, current * 8 + 8).map((row) => (
<tr key={row.id}>
<td className="aq-plate">{row.plate || "未登记车牌"}</td>
<td>
{row.type}
<small>{row.model}</small>
</td>
<td>{row.city}</td>
<td>
<span className="aq-tag">
{flow ? row.event : row.status}
</span>
</td>
{flow && <td>{row.date}</td>}
</tr>
))}
</tbody>
</table>
</div>
{!filtered.length && <div className="aq-no-data">没有符合条件的明细</div>}
<div className="aq-pagination">
<span>
第 {current + 1} / {max} 页
</span>
<button
aria-label="上一页"
disabled={!current}
onClick={() => setPage(current - 1)}
>
<ChevronLeft size={16} />
</button>
<button
aria-label="下一页"
disabled={current >= max - 1}
onClick={() => setPage(current + 1)}
>
<ChevronRight size={16} />
</button>
</div>
</>
);
}
export function ResultView({
result,
ask,
disabled,
}: {
result: AssistantResult;
ask: (text: string) => void;
disabled: boolean;
}) {
const [view, setView] = useState(result.plan.view);
const [drill, setDrill] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [copyError, setCopyError] = useState(false);
const [showAll, setShowAll] = useState(false);
const flow = result.plan.kind === "flow";
const inventory = !["all", "operating"].includes(result.plan.category);
const rows =
drill === null
? result.rows
: result.rows.filter((row) =>
flow
? row.date === drill
: result.plan.groupBy === "none" ||
row[result.plan.groupBy] === drill,
);
const largest = Math.max(1, ...result.groups.map((g) => g.count));
const total = result.groups.reduce((n, g) => n + g.count, 0);
const option = useMemo(
() => ({
color: ["#668874", "#b5bdc4", "#a58b65"],
tooltip: { trigger: "axis" },
legend: { bottom: 0, textStyle: { color: "#7b7b80" } },
grid: { left: 42, right: 16, top: 20, bottom: 60 },
xAxis: {
type: "category",
data: result.daily?.map((d) => d.date.slice(5)),
axisLine: { lineStyle: { color: "#b5bdc4" } },
axisLabel: { color: "#7b7b80" },
},
yAxis: {
type: "value",
minInterval: 1,
splitLine: { lineStyle: { color: "#b5bdc430" } },
axisLabel: { color: "#7b7b80" },
},
series: ["delivered", "returned", "replaced"].map((key, i) => ({
name: ["交车(含替换)", "还车", "其中替换交车"][i],
type: "line",
smooth: false,
symbolSize: 5,
data: result.daily?.map((d) => d[key as "delivered"]),
})),
animation: !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
}),
[result.daily],
);
const exportResult = () => {
if (drill !== null || view === "list")
downloadCsv(
[
[
"车牌",
"车型",
"型号",
"区域",
"城市",
"部门",
"运营状态",
"日期",
"业务类型",
],
...rows.map((r) => [
r.plate,
r.type,
r.model,
r.region,
r.city,
r.department,
r.status,
r.date || "",
r.event || "",
]),
],
result.title + "-明细",
);
else if (flow)
downloadCsv(
[
["日期", "交车(含替换)", "还车", "其中替换交车", "业务总车次"],
...(result.daily || []).map((d) => [
d.date,
String(d.delivered),
String(d.returned),
String(d.replaced),
String(d.total),
]),
],
result.title,
);
else if (inventory)
downloadCsv(
[
[GROUP_LABELS[result.plan.groupBy], "车辆数", "占比"],
...result.groups.map((g) => [
g.name,
String(g.count),
`${total ? ((g.count / total) * 100).toFixed(1) : 0}%`,
]),
],
result.title,
);
else
downloadCsv(
[
[GROUP_LABELS[result.plan.groupBy], "车辆数", "运营车辆数", "运营率"],
...result.groups.map((g) => [
g.name,
String(g.count),
String(g.operating),
`${g.rate}%`,
]),
],
result.title,
);
};
return (
<div className="aq-result">
<p className="aq-answer">{result.summary}</p>
{result.warning && (
<p role="status" className="aq-notice">
{result.warning}
</p>
)}
{result.plan.kind !== "clarify" && (
<>
<div className="aq-source-chips">
<span>
<Check size={12} />
真实业务数据
</span>
<span>{result.source}</span>
</div>
<div className="aq-result-card">
<div className="aq-card-top">
<div>
<span className="aq-eyebrow">ASSET INSIGHT</span>
<h3>{result.title}</h3>
</div>
<button
className="aq-icon-button"
onClick={exportResult}
aria-label="导出当前结果"
title="导出 CSV"
>
<ArrowDownToLine size={17} />
</button>
</div>
<div className="aq-metrics">
{result.metrics.map((metric) => (
<div key={metric.label}>
<span>{metric.label}</span>
<strong>
{metric.value.toLocaleString()}
<small>{metric.unit}</small>
</strong>
</div>
))}
</div>
<div className="aq-result-toolbar">
{drill !== null ? (
<button className="aq-back" onClick={() => setDrill(null)}>
<ArrowLeft size={14} />
{drill} · {rows.length} 条<span>返回统计</span>
</button>
) : (
<div className="aq-view-tabs" aria-label="结果展示方式">
{(
[
{ id: "chart", name: "图表", icon: BarChart3 },
{ id: "table", name: "统计表", icon: Table2 },
{ id: "list", name: "明细", icon: List },
] as const
).map((tab) => (
<button
key={tab.id}
aria-pressed={view === tab.id}
onClick={() => setView(tab.id)}
>
<tab.icon size={14} />
{tab.name}
</button>
))}
</div>
)}
<span className="aq-result-unit">
{flow
? `${result.plan.start} — ${result.plan.end}`
: "单位:台"}
</span>
</div>
{drill !== null || view === "list" ? (
<Records key={drill || "all"} rows={rows} flow={flow} />
) : view === "chart" && flow ? (
<div className="aq-flow-chart">
<Chart option={option} label="交车、还车和替换交车按日趋势" />
<div className="aq-date-drills">
{result.groups
.filter((g) => g.count)
.map((g) => (
<button key={g.name} onClick={() => setDrill(g.name)}>
{g.name.slice(5)}
<span>{g.count} 车次</span>
<ArrowUpRight size={12} />
</button>
))}
</div>
</div>
) : view === "chart" ? (
<div className="aq-bars">
{(showAll ? result.groups : result.groups.slice(0, 8)).map(
(group, i) => (
<button
key={group.name}
className="aq-bar-row"
aria-label={`下钻${group.name},${group.count}台`}
onClick={() => setDrill(group.name)}
>
<span className="aq-bar-name">{group.name}</span>
<span className="aq-bar-track">
<span
className={
i === 0 ? "aq-bar-fill aq-bar-lead" : "aq-bar-fill"
}
style={{ width: `${(group.count / largest) * 100}%` }}
/>
</span>
<strong>{group.count.toLocaleString()}</strong>
<span className="aq-bar-share">
{total ? ((group.count / total) * 100).toFixed(1) : 0}%
</span>
<ArrowUpRight size={13} />
</button>
),
)}
{!result.groups.length && (
<div className="aq-no-data">
当前筛选范围没有车辆,可调整条件后重试。
</div>
)}
{result.groups.length > 8 && (
<button
className="aq-more"
onClick={() => setShowAll(!showAll)}
>
{showAll ? "收起" : `展开全部 ${result.groups.length} 组`}
</button>
)}
<div className="aq-chart-caption">
按数量降序排列 · 点击任意分组查看明细
</div>
</div>
) : (
<div className="aq-table-scroll">
<table>
<thead>
<tr>
{(flow
? ["日期", "交车(含替换)", "还车", "其中替换", "明细"]
: inventory
? [
GROUP_LABELS[result.plan.groupBy],
"车辆数",
"占比",
"明细",
]
: [
GROUP_LABELS[result.plan.groupBy],
"车辆数",
"运营数",
"运营率",
"明细",
]
).map((h) => (
<th key={h}>{h}</th>
))}
</tr>
</thead>
<tbody>
{flow
? 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
aria-label={`查看${d.date}明细`}
onClick={() => setDrill(d.date)}
>
<ArrowUpRight size={15} />
</button>
</td>
</tr>
))
: result.groups.map((g) => (
<tr key={g.name}>
<td>{g.name}</td>
<td>{g.count}</td>
{inventory ? (
<td>
{total
? ((g.count / total) * 100).toFixed(1)
: 0}
%
</td>
) : (
<>
<td>{g.operating}</td>
<td>{g.rate}%</td>
</>
)}
<td>
<button
aria-label={`查看${g.name}明细`}
onClick={() => setDrill(g.name)}
>
<ArrowUpRight size={15} />
</button>
</td>
</tr>
))}
</tbody>
</table>
{!result.groups.length && (
<div className="aq-no-data">暂无符合条件的数据</div>
)}
</div>
)}
<div className="aq-card-foot">
<span>
查询于{" "}
{new Date(result.fetchedAt).toLocaleString("zh-CN", {
hour12: false,
})}
</span>
<span>口径与权限已应用</span>
</div>
</div>
<div className="aq-answer-actions">
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(result.summary);
setCopied(true);
setCopyError(false);
} catch {
setCopyError(true);
}
}}
aria-label="复制结论"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? "已复制" : "复制结论"}
</button>
{copyError && (
<span role="status">复制失败,请手动选择文字复制。</span>
)}
<span>AI 解读供参考,请以统计表和明细为准。</span>
</div>
<div className="aq-followups">
<span>继续探索</span>
{(flow
? ["改成表格", "显示车辆明细"]
: ["再按车型拆分", "只看可运营库存", "改成表格"]
).map((q) => (
<button key={q} disabled={disabled} onClick={() => ask(q)}>
{q}
<ArrowUpRight size={13} />
</button>
))}
</div>
</>
)}
</div>
);
}