feat(assets): add DeepSeek assistant with streaming results and voice input
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user