diff --git a/.dockerignore b/.dockerignore index 983485f..c9dac4a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,16 @@ node_modules dist .env +.env.* +!.env.example +!.env.*.example .git .codex outputs reports data/geo data/vehicle-heatmap/source.csv + +# Local machine metadata and runtime logs +.DS_Store +*.log diff --git a/.gitignore b/.gitignore index fd06fec..ce432a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ node_modules dist .env -.env.local +.env.* +!.env.example +!.env.*.example .worktrees .DS_Store scripts-tmp +*.log diff --git a/components.json b/components.json new file mode 100644 index 0000000..5c23ec4 --- /dev/null +++ b/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/docker-compose.yml b/docker-compose.yml index 22e5fdb..5cef8c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,9 +6,10 @@ version: '3.8' services: ln-bi: - image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.2.0 + image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.2.1 network_mode: host environment: + DEEPSEEK_API_KEY: "${DEEPSEEK_API_KEY:-}" NODE_ENV: "production" # 生产必须保持为 0:该开关会绕过全部认证。 DEV_BYPASS_AUTH: "0" diff --git a/docs/asset-ai-demo.md b/docs/asset-ai-demo.md new file mode 100644 index 0000000..07fac16 --- /dev/null +++ b/docs/asset-ai-demo.md @@ -0,0 +1,41 @@ +# 资产 AI 问数 Demo + +入口:本地运行 `npm run dev` 后打开 `http://localhost:3000/asset#assets`,点击「AI 问数」。 + +## 已实现 + +- 采用 Beautiful UI Thinking / Prompt Bar / Chat 的视觉语言;Thinking 步骤改为后端 SSE 实际查询进度,保留 MIT 许可于 `docs/licenses/beautiful-ui.txt`。 +- DeepSeek Flash 将自然语言和上一轮查询计划转成受校验的结构化查询;后端复用资产仓储、权限过滤和业务分类;第二次模型调用只解释汇总数据。 +- 查询当前资产数量、库存、可运营/待运营、运营率、异动和待交车;按区域、城市、车型、型号、部门和运营状态分组。 +- 查询最多 93 天的交车、还车、替换趋势。交车包含替换;流转按业务车次计数,不以车辆去重。 +- 图表/统计表/明细切换、分组下钻、明细搜索及分页、CSV 导出、连续追问、取消、错误重试、新对话、深浅主题及移动端。 +- 浏览器 SpeechRecognition 语音输入:用户点击麦克风启动,转写后可编辑再发送。不支持或拒绝授权时提示文字输入。 + +## 本机配置 + +`DEEPSEEK_API_KEY` 仅位于 Git 忽略的 `.env`,后端读取。客户端构建不包含密钥。无需配置 `VITE_` 密钥。更换临时密钥后重启后端。 + +现有开发环境本身已开启本地免登录;本次未改变该开关。问答路由挂在应用的认证中间件之后,未认证直接调用被拒绝。部署环境必须沿用正常认证配置。 + +模型接收问题、维度目录(区域/城市/车型/型号)、已校验的上一轮计划和汇总统计;不会主动发送车牌、VIN、客户或车辆明细。用户在提问中自行输入的文字会发送给模型。业务数据没有伪造或演示替换。 + +Portainer / Docker 部署见 [DeepSeek 环境变量注入](./portainer-deepseek.md)。 + +## 口径和边界 + +- 当前公司从资产页面继承,变更公司请关闭面板后选择公司;服务端执行账号权限与公司范围过滤。 +- 资产来源为当前台账与现有服务端缓存,结果标记查询时间,不声称是历史库存快照。 +- 分组与明细使用同一次查询返回的数据;区域按现有省市映射。 +- 运营率 = 当前筛选车辆中的运营车辆 / 当前筛选车辆数。 +- 历史库存、金额、成本、预测等暂不支持,助手应说明能力范围或追问,不执行模型生成的 SQL。 +- 会话只保存在打开的面板内,刷新/关闭不会保存。语音依赖浏览器和其在线识别服务,尚未接专用 ASR。 + +## 验证 + +- TypeScript 检查、Vite 生产构建;构建仍有现有 ECharts/AG Grid 大块提示。 +- 自动验证个人权限与所属公司范围、库存子集计数、无认证拒绝、非法查询/日期拒绝,以及模型请求不包含车辆明细。 +- 桌面及手机浏览器实际查询:库存 291 台 → 追问可运营库存 181 台;华东库存 198 条明细与分页对应。 +- 2026-09-01 至 2026-09-23 流转:交车 63 车次、还车 69 车次,其中替换 19 车次;2026-09-22 下钻及 CSV 均为 17 条记录。 +- 深浅模式、无横向页面溢出、空明细搜索和结果展示切换已验证。真实麦克风识别未自动录制测试。 + +开发大脑回执:DB-C-003,知识索引版本 1 校验通过,未发现冲突;本次是产品 Demo 实现,未形成新的跨任务复盘模式,无知识写入或 GitHub 同步。 diff --git a/docs/asset-ui-migration.md b/docs/asset-ui-migration.md new file mode 100644 index 0000000..ae367f2 --- /dev/null +++ b/docs/asset-ui-migration.md @@ -0,0 +1,43 @@ +# 资产管理组件库迁移 + +## 范围与约束 + +资产管理的总览、库存统计、车型/批次、交还车、部门、区域、客户、筛选与明细弹窗。 + +- shadcn/ui:官方 CLI 生成的 Radix Nova 组件,中文化关闭按钮;使用组件默认尺寸、语义主题与 Tailwind 布局工具类。 +- Apache ECharts:趋势、区域对比和省份分布,按需加载;ResizeObserver 适配容器,卸载时销毁实例。 +- AG Grid Community:排序、列筛选、列宽调整、固定识别列和分页。未使用 Enterprise、授权密钥、企业分组或 Excel 导出接口。 +- H5:同一数据/列定义驱动 shadcn 卡片与分页;关键数量、异动类型/时间、待交车合同/脱敏客户直接可见,辅助信息按需展开。手机不加载桌面 Grid 模块。 +- Excel 导出沿用现有 xlsx,按需加载。 +- 移除旧资产展示组件及 assets.css/apple-tokens.css;没有新增资产自定义选择器。视觉变量直接采用 apple-design 技能提供的 `src/shared/styles/apple-tokens.css`,组件空间、颜色与暗色适配使用 shadcn/ui 属性和 Tailwind 工具类。其他 BI 模块未迁移。 + +资产总览采用靛蓝标题区、浅色分组底、蓝色运营卡与靛蓝库存卡,并缩小卡片高度以提高信息密度。操作控件集中在标题下方,主视图与库存子视图选中态更清晰。资产页提供浅色/深色切换并记住当前浏览器选择;图表坐标、图例及明细表格颜色跟随主题。 + +## 数据口径 + +总运营 = 租赁 + 自营;异动、待交车属于总库存子集,不重复累计。 +交还车日期统计为车次;车型/批次的本周交还车沿用后端按车辆去重统计。 +省份分布使用既有公司范围接口,页面明确标示不受表格筛选影响。 +待交车客户来自后端脱敏字段,合同仅使用当前待交车任务,不回退历史合同。 + +修正库存型号误用为合同批次条件、周明细漏传公司条件、区域统计与明细范围不同,以及区域“其他车型”误排除挂车的问题。区域城市折叠列表保留实际城市,不再把第九个及后续城市合并成无法准确下钻的“其他”。新增离线接口回归覆盖。 + +## 验证 + +- TypeScript 检查通过。 +- 全仓 108 项测试通过(包含新的公司/区域下钻测试),无数据库依赖的新增测试使用 repository stub。 +- 生产构建通过。AG Grid 与 ECharts 独立懒加载包仍超过 Vite 的 500 kB 提示阈值;未屏蔽该提示。 +- Web:总览、库存下钻 55 → 55、部门、客户脱敏/省份图、公司切换、区域“其他”下钻 6 → 6、车型入口、日期筛选。 +- 390 px H5:页面无横向溢出;车辆弹窗宽 358 px;搜索结果 1 条及分页、辅助信息折叠、日期区间应用可用。 +- 浏览器验收未发现 console error/warn。 +- 验收时异动、待交车均为 0,只完成其空状态验证;有记录的业务数据展示仍需后续验收。 + +迁移初次验收基于 1.1.16;本次随 1.1.17 版本提交交付,完整回归测试共 130 项通过。 + +## 本轮可读性与交互调整 + +- 恢复深蓝标题、亮蓝操作、橙色异动提示,降低背景饱和度;库存数字使用深色,和运营数字区分。 +- 所有共享筛选面板默认折叠,已选条件在折叠标题显示,清空后回到未设条件状态。所属公司作为全局数据范围保留可见。 +- Web 与 H5 的记录数不超过 20 条时不分页,桌面表格高度适应数据;超过 20 条仍分页,避免大量车辆渲染。8 条以内不显示无必要的搜索框。 +- 手机标题与总数并排,主导航和统计页签触控高度至少 44 px。 +- 浏览器验证:部门 8 条完整展示且分页隐藏;766 条车辆仍分页;区域筛选后折叠摘要和库存计数一致。 diff --git a/docs/licenses/beautiful-ui.txt b/docs/licenses/beautiful-ui.txt new file mode 100644 index 0000000..6635631 --- /dev/null +++ b/docs/licenses/beautiful-ui.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Shane Levine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/oneos-mileage-reconciliation-investigation-2026-09-16.md b/docs/oneos-mileage-reconciliation-investigation-2026-09-16.md new file mode 100644 index 0000000..593d7bc --- /dev/null +++ b/docs/oneos-mileage-reconciliation-investigation-2026-09-16.md @@ -0,0 +1,198 @@ +# OneOS 日里程与累计里程差额排查报告 + +- 排查日期:2026-09-16(Asia/Shanghai) +- 涉及数据日期:2026-09-11 至 2026-09-15 +- 接收方:OneOS 开放平台及里程计算服务维护人员 +- 状态:已复现;待上游核查计算基准、跨日缺报及协议选源逻辑 + +## 1. 问题概述 + +BI 用户发现:昨日累计总里程减去前日累计总里程,不等于昨日的区间里程;继续向前检查,多日均存在差额。 + +本次在相同车辆范围、相同协议优先级下复现,并绕过 BI 直接请求 OneOS 单日接口核对异常车辆。上游返回本身就存在相邻日期累计差与日里程不一致的情况,不能仅用 BI 显示取整解释。 + +已确认的现象: + +1. 某查询日返回的累计里程对应更早日期的报文;后续更新后的累计差跨越多天,与当日日里程不一致。 +2. 同一车辆相邻日期切换 MQTT / GB32960,累计读数发生跳变,且切换后可能选中很早的读数。 + +尚未确认:日里程的内部计算公式、有效数据判定、跨日补算规则及异常过滤规则。本文不将差额直接认定为漏算里程,也不将累计差直接认定为真实日行驶里程。 + +## 2. 查询范围与 BI 计算口径 + +本次 BI 查询未附加客户、部门、车牌或里程筛选条件,每天返回 916 台车辆。 + +```text +GET /api/mileage/monitoring + ?startDate=2026-09-15 + &endDate=2026-09-15 + &sourcePriority=instrument + &limit=2000 +``` + +`instrument` 对应上游协议优先级: + +```json +["GB32960", "MQTT"] +``` + +BI 当前计算方式: + +| 指标 | 计算方式 | +| --- | --- | +| 单车累计里程 | OneOS `totalMileageKm`,不以日里程反推 | +| 单车单日里程 | OneOS `dailyMileageKm` | +| 单车区间里程 | 区间内每日 `dailyMileageKm` 求和 | +| 累计总里程 | 查询范围内车辆的累计里程求和;空值按 0 参与汇总 | +| 当日总里程 | 查询范围内车辆的日里程求和;汇总接口对结果取整 | + +以下对账表使用逐车日里程原始数值求和,保留三位小数,避免整数显示带来的误差。 + +## 3. 汇总对账结果 + +单位:km。定义 `差额 = 当天累计总里程 - 前一天累计总里程 - 当天日里程合计`。 + +| 日期 | 累计总里程 | 相邻日累计差 | 当日日里程合计 | 差额 | +| --- | ---: | ---: | ---: | ---: | +| 2026-09-11 | 48,448,486.800 | — | 98,182.208 | — | +| 2026-09-12 | 48,546,128.300 | 97,641.500 | 97,321.774 | +319.726 | +| 2026-09-13 | 48,619,784.800 | 73,656.500 | 82,322.847 | −8,666.347 | +| 2026-09-14 | 48,721,190.600 | 101,405.800 | 94,242.687 | +7,163.113 | +| 2026-09-15 | 48,824,917.800 | 103,727.200 | 103,554.604 | +172.596 | + +按车牌匹配相邻日期,差额来源分解如下: + +| 日期 | 两日累计非空且协议相同的车辆数 / 净差额 | 两日累计非空且协议变化的车辆数 / 净差额 | 至少一日累计为空的车辆数 / 净差额 | +| --- | --- | --- | --- | +| 09-12 | 759 / +319.726 | 0 / 0 | 157 / 0 | +| 09-13 | 758 / +674.653 | 1 / −9,341.000 | 157 / 0 | +| 09-14 | 759 / +7,163.113 | 0 / 0 | 157 / 0 | +| 09-15 | 759 / +172.596 | 0 / 0 | 157 / 0 | + +本次样本中,空累计值组对差额净贡献为 0;同协议数据也存在明显不一致,不能只修复协议切换问题。 + +## 4. 异常样本一:累计读数跨日,日里程未与累计差对齐 + +以下记录已通过 OneOS 单日接口直接复核,响应均为 HTTP 200、`code=SUCCESS`,记录状态均为 `NORMAL`。 + +### 4.1 粤AGQ8377:9 月 15 日差额 +70.2 km + +| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) | +| --- | --- | ---: | ---: | --- | +| 2026-09-13 | GB32960 | 14,366.0 | 6.1 | 2026-09-13 07:17:27 | +| 2026-09-14 | GB32960 | 14,366.0 | 0 | 2026-09-13 07:17:27 | +| 2026-09-15 | GB32960 | 14,467.4 | 31.2 | 2026-09-15 20:57:04 | + +```text +累计差 = 14,467.4 - 14,366.0 = 101.4 +差额 = 101.4 - 31.2 = 70.2 km +``` + +查询 9 月 14 日时,累计读数仍来自 9 月 13 日。请核查 9 月 15 日日里程的起点读数及其时间,并解释 70.2 km 在日统计中的归属或过滤原因。 + +### 4.2 粤AGR6839:9 月 15 日差额 +38.0 km + +| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) | +| --- | --- | ---: | ---: | --- | +| 2026-09-14 | GB32960 | 45,122.2 | 0 | 2026-09-11 11:10:53 | +| 2026-09-15 | GB32960 | 45,162.8 | 2.6 | 2026-09-15 10:34:02 | + +累计差为 40.6 km,日里程为 2.6 km,差额为 38.0 km。请核查跨多日无新读数后的统计基准及补算策略。 + +### 4.3 粤AGP3609:9 月 14 日差额 +808.7 km + +| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) | +| --- | --- | ---: | ---: | --- | +| 2026-09-12 | GB32960 | 48,102.9 | 1,293.6 | 2026-09-12 15:08:47 | +| 2026-09-13 | GB32960 | 48,102.9 | 0 | 2026-09-12 15:08:47 | +| 2026-09-14 | GB32960 | 48,981.7 | 70.1 | 2026-09-14 23:59:51 | +| 2026-09-15 | GB32960 | 50,349.1 | 1,367.4 | 2026-09-15 23:59:50 | + +9 月 14 日累计差为 878.8 km,日里程为 70.1 km,差额为 808.7 km;9 月 15 日累计差与日里程则同为 1,367.4 km。该车适合对比正常日期与跨日缺报日期的计算路径。 + +## 5. 异常样本二:协议切换导致累计值倒退 + +车辆:沪A60591F。以下记录也已直接请求上游复核,均返回 `NORMAL`。 + +| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) | +| --- | --- | ---: | ---: | --- | +| 2026-09-12 | MQTT | 51,416 | 0 | 2026-09-12 04:53:49 | +| 2026-09-13 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 | +| 2026-09-14 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 | +| 2026-09-15 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 | + +```text +9 月 13 日累计差 = 42,075 - 51,416 = -9,341 km +9 月 13 日日里程 = 0 km +``` + +该车单独贡献 −9,341 km,叠加其他同协议车辆 +674.653 km 的差额后,形成当日全量净差额 −8,666.347 km。 + +请重点核查: + +1. 同一请求优先级下,9 月 12 日采用 MQTT,9 月 13 日为何改用 7 月 16 日的 GB32960 读数? +2. 协议“有效数据”的定义是否包含数据时间、新鲜度和查询日期边界? +3. `NORMAL` 是否仅表示读数数值有效,而不表示查询当日存在有效报文? +4. 两协议累计读数是否具有同一基准?切换时是否应提供不连续标识? +5. 请核查该车协议记录对应的 VIN、设备及绑定历史,排除历史绑定变化造成的读数混用。 + +## 6. 上游直接复现方法 + +接口:`POST https://open.d.lnoneos.com/api/v1/vehicles/mileage/query`。 + +使用已授权的 API Key,通过环境变量注入,不将真实凭证写入报告或日志。 + +```bash +curl --fail-with-body --silent --show-error \ + 'https://open.d.lnoneos.com/api/v1/vehicles/mileage/query' \ + -H "Authorization: Bearer ${ONEOS_MILEAGE_API_KEY}" \ + -H 'Content-Type: application/json' \ + --data '{ + "date": "2026-09-15", + "plateNumbers": ["粤AGQ8377", "粤AGR6839", "粤AGP3609", "沪A60591F"], + "protocolPriority": ["GB32960", "MQTT"] + }' +``` + +依次将 `date` 替换为 `2026-09-12`、`2026-09-13`、`2026-09-14`、`2026-09-15`。 + +逐车对比字段:`date`、`plateNumber`、`vin`、`status`、`dailyMileageKm`、`totalMileageKm`、`sourceProtocol`、`dataTime`、`calculatedAt`、`updatedAt`。 + +本次直接复核保留了报告中的关键字段,未记录响应 `traceId`;上游重放时请保存完整响应及 `traceId`。历史数据如已重算或补报,当前返回值可能与本报告不同,请同时提供重算时间和修改前后结果。 + +## 7. 请上游提供的定位结果 + +### 7.1 明确字段契约 + +- `dailyMileageKm`:是当天首尾读数差、前日末值到当日末值的差,还是逐报文有效增量之和?是否丢弃跨日增量、异常跳变或负增量? +- `totalMileageKm`:是查询日最后读数、截至查询日最近读数,还是其他快照?无当日报文时是否延用历史值? +- `dataTime`:具体对应累计读数、日里程终点还是其他时间? +- 两个里程字段是否保证来自同一协议、同一设备、同一计算快照? +- 自然日边界是否统一为 Asia/Shanghai 的 00:00:00 至次日 00:00:00? +- 是否承诺相邻日累计差等于日里程;若不承诺,请明确哪些场景不成立及差额如何解释。 + +### 7.2 提供样本计算明细 + +对上述四辆车,请返回: + +1. 各协议参与选源的候选记录、时间及未选中原因。 +2. 日里程计算起止报文的时间、累计读数、设备/VIN 标识。 +3. 有效增量、被过滤增量及过滤原因。 +4. 缺报、补报、跨日增量归属,以及历史重算记录。 +5. 对每个差额给出可核对的数值分解,不能只解释为“统计口径不同”。 + +## 8. 修复或口径确认后的验收建议 + +1. 使用同一车辆集合和协议配置,重放 9 月 11 至 15 日数据,逐车核对后再核对汇总。 +2. 对连续上报且同协议的正常车辆,若契约约定可对账,应满足累计差与日里程一致;容差按上游实际数值精度明确。 +3. 对跨日缺报车辆,明确跨日增量是否补算、归属哪天,并能解释全部差额。 +4. 对协议切换车辆,避免将不同基准的累计值视为连续可减的序列;提供明确的协议切换和不连续信息。 +5. 对陈旧读数,区分“历史累计值仍有效”和“当日有有效报文”,避免用单一 `NORMAL` 混淆两者。 +6. 验证单日接口与区间接口对同一车、同一天的日里程、累计值和选源结果一致。 +7. 如修复涉及历史重算,提供重算范围及完成时间;BI 历史接口缓存最长 6 小时,应在刷新后复核。 + +## 9. 本次排查边界 + +- 已检查 BI 里程映射、日/区间聚合和汇总逻辑,并对异常样本直接调用上游单日接口复核。 +- 尚未访问 OneOS 原始报文、计算服务源码或内部存储,不能确定其内部算法根因。 +- 本次未修改业务代码、未变更里程口径、未触发历史数据重算。 diff --git a/docs/portainer-deepseek.md b/docs/portainer-deepseek.md new file mode 100644 index 0000000..85c76ca --- /dev/null +++ b/docs/portainer-deepseek.md @@ -0,0 +1,38 @@ +# Portainer 部署资产 AI 问数 + +DeepSeek Key 由 Node 服务端在运行时读取,变量名为 `DEEPSEEK_API_KEY`。模型固定为 `deepseek-flash`,接口为 `https://api.deepseek.com/chat/completions`,部署节点需要能够访问该 HTTPS 地址。 + +## Stack 配置 + +仓库的 `docker-compose.yml` 已包含: + +```yaml +services: + ln-bi: + environment: + DEEPSEEK_API_KEY: "${DEEPSEEK_API_KEY:-}" +``` + +1. 等待 main 分支的 Woodpecker 构建与镜像推送成功。本次版本 1.2.1 对应镜像 `harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.2.1`。 +2. Portainer → Stacks → 现有 `ln-bi` Stack。确认 Compose 的 `image` 使用上述镜像,且保留现有数据库、认证、网络和端口配置。 +3. 在 **Environment variables** 中新增名称 `DEEPSEEK_API_KEY`,值粘贴实际 API Key。Compose 必须包含上面的变量引用,才能将 Stack 变量传入容器。 +4. 点击 **Update the stack** 并重新部署,确保拉取新镜像。Git 管理的 Stack 需要从仓库拉取新版 Compose;Web Editor 管理的 Stack 则直接更新编辑器内容。 +5. 登录应用,在资产管理中打开「AI 问数」,查询“当前库存按区域统计”验证。 + +后续更换 Key 只需更新 Stack 环境变量并重新部署/创建容器,无需重新构建镜像。仅重启旧容器不会刷新其环境变量。未配置 Key 时 AI 问数返回配置提示,其他资产功能可继续使用。 + +## 检查是否注入 + +在 Portainer 中打开正在运行的应用容器 Console,执行: + +```sh +node -e 'console.log(process.env.DEEPSEEK_API_KEY ? "已配置" : "未配置")' +``` + +该命令只检查是否存在,不打印 Key;实际有效性由问数请求验证。Swarm 部署请进入对应任务容器检查。 + +Key 不使用 `VITE_` 前缀,不写入前端代码、Dockerfile、构建参数或 Git。`.env` 与本机日志已排除出 Git 和 Docker 构建上下文。现有应用端口保持 8111,无需为 DeepSeek 新增监听端口。 + +仓库 Compose 采用显式 `environment` 引用,可用于 Docker Standalone 与 Swarm;不依赖仅 Standalone 支持的 `env_file: stack.env`。 + +参考:[Portainer 创建 Stack 与环境变量](https://docs.portainer.io/sts/user/docker/stacks/add)、[更新现有 Stack](https://docs.portainer.io/user/docker/stacks/edit)。 diff --git a/docs/ui-optimization-checklist.md b/docs/ui-optimization-checklist.md new file mode 100644 index 0000000..ac5a915 --- /dev/null +++ b/docs/ui-optimization-checklist.md @@ -0,0 +1,141 @@ +# LN-BI UI 优化待办清单 + +> 生成日期:2026-09-20 +> 来源:全局外壳/导航、数据看板与图表、业务操作页面三路 UI/UX 审查汇总 +> 用法:`- [ ]` 为未完成,`- [x]` 为已完成;每条附 `文件:行号` 证据,完成后建议连同代码一起提交。 +> 关联文档:`../LN-BI-项目分析报告.md` §11.4、`../LN-BI-重构方案.md` 阶段 5/6 + +--- + +## P0 可用性与数据信任(最优先) + +### 可访问性:下拉组件 +- [ ] SearchSelect 键盘/读屏不可用:补 `role="combobox"` + `aria-expanded` + listbox/option 语义、↑↓/Enter/Escape,或直接替换 Headless UI / Radix Combobox — `src/components/SearchSelect.tsx:47-68` +- [ ] MultiSearchSelect 同上,且焦点环颜色与单选版不一致(emerald vs blue)— `src/components/MultiSearchSelect.tsx:59-85` +- [ ] mileage SearchableSelect 选项为裸 div,键盘不可达 — `src/modules/mileage/monitoring/components/SearchableSelect.tsx:60-72` +- [ ] 排序表头补 `aria-sort`;三张 div 模拟表格中期换成真 `` — `src/modules/mileage/daily-report/VehicleTable.tsx:220-271`、`GroupRow.tsx:86-132`、`ElectricDaily.tsx:124-186`、`HydrogenDaily.tsx:192-272` + +### 可访问性:弹窗/全屏 +- [ ] 抽共用 Modal/BottomSheet 壳:统一 `role="dialog" aria-modal`、Escape 关闭、遮罩点击关闭、焦点圈禁与还原、滚动锁定、移动端安全区 — 参考 `src/components/feedback/FeedbackModal.tsx:59-66`、`FeedbackHistoryDrawer.tsx:67-83` +- [ ] 全屏监控/全屏表格补 aria-label + Escape 关闭 + 打开时焦点移到关闭按钮 — `src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx:111-119`、`src/modules/mileage/statistics/FullscreenTargetTable.tsx:49-60` +- [ ] 抽 `useScrollLock` hook,统一锁定 body 滚动(VehicleDetailModal/FlowDetailModal/NotificationHistory 目前未锁)— `src/modules/assets/components/VehicleDetailModal.tsx:47`、`FlowDetailModal.tsx:33`、`src/modules/scheduling/NotificationHistory.tsx:122` +- [ ] 移动端底栏分组弹层:修溢出屏幕(定位约束)、加遮罩点击关闭、活动项补 `aria-current` — `src/components/Shell.tsx:194-206` +- [ ] 全部图标按钮补 `aria-label`/`title`(监控工具栏等)— `src/modules/mileage/monitoring/components/MonitoringToolbar.tsx:88-93` +- [ ] 移动端卡片用 span 冒充可点击元素:改 ` - ); + {open &&
+ {choices.map((option,index)=>)} + {filtered.length===0 &&

无匹配项

} +
} + ; } diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 6a76785..27a095f 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -82,14 +82,18 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) { const { user } = useAuth(); const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板'; - // 氢能经营看板已在模块内提供原型一致的标题与导航,避免桌面端重复显示全局面包屑。 - const showDesktopBreadcrumb = activeModule !== 'hydrogen'; const isHydrogenPrototype = activeModule === 'hydrogen'; + // 水印时间戳随会话推进刷新,避免长时间停留后时间失真。 + const [watermarkNow, setWatermarkNow] = useState(() => Date.now()); + useEffect(() => { + const timer = window.setInterval(() => setWatermarkNow(Date.now()), 30000); + return () => window.clearInterval(timer); + }, []); const watermarkText = useMemo(() => { const name = user?.userName || '未登录'; - const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-'); + const time = new Date(watermarkNow).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-'); return `${name}-${time}`; - }, [user]); + }, [user, watermarkNow]); return (
@@ -157,19 +161,13 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
-
{user?.userName || '未登录'}
+
{user?.userName || '未登录'}
{/* 内容区 */}
- {showDesktopBreadcrumb ? ( - - ) : null} + }> {ActiveComponent && } diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..bfa8eba --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,78 @@ +import * as React from "react" +import { cn } from "cn" +import { Accordion as AccordionPrimitive } from "radix-ui" +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react" + +function Accordion({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
+ {children} +
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..17ba728 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,75 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className + )} + {...props} + /> + ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..837196c --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" +import { Slot } from "radix-ui" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..18407c9 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" +import { Slot } from "radix-ui" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 0000000..a501f87 --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,222 @@ +"use client" + +import * as React from "react" +import { cn } from "cn" +import { + DayPicker, + getDefaultClassNames, + type DayButton, + type Locale, +} from "react-day-picker" + +import { Button, buttonVariants } from "@/components/ui/button" +import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + locale, + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + locale={locale} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString(locale?.code, { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative rounded-(--cell-radius)", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute inset-0 bg-popover opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "font-medium select-none", + captionLayout === "label" + ? "text-sm" + : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", + defaultClassNames.caption_label + ), + month_grid: cn("w-full border-collapse", defaultClassNames.month_grid), + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-(--cell-size) select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] text-muted-foreground select-none", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)" + : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)", + defaultClassNames.day + ), + range_start: cn( + "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn( + "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted", + defaultClassNames.range_end + ), + today: cn( + "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: ({ ...props }) => ( + + ), + WeekNumber: ({ children, ...props }) => { + return ( +
+ ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx new file mode 100644 index 0000000..e8dfaad --- /dev/null +++ b/src/components/ui/field.tsx @@ -0,0 +1,236 @@ +import { useMemo } from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" + +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const fieldVariants = cva( + "group/field flex w-full gap-2 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: "flex-col *:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +
+
+ {children} +
+
- - - - {deptViewMode === 'manager' && } - - {deptViewMode === 'department' && ( - <> - - - - - )} - {deptViewMode === 'manager' && ( - <> - - - - - - - - )} - - - - - {deptViewMode === 'department' ? ( - deptData.map((dept) => { - const isExpanded = expandedDepts.has(dept.department); - return ( - - toggleDept(dept.department)} - > - - - - - - - - {isExpanded && ( - - - - )} - - ); - }) - ) : ( - managerStats.map((m) => { - const isManagerExpanded = expandedManagerDetails.has(m.manager); - return ( - - toggleManagerDetails(m.manager)} - > - - - - - - - - - - - - {isManagerExpanded && ( - - - - )} - - ); - }) - )} - -
{deptViewMode === 'department' ? '部门名称' : '业务负责人'}所属部门{deptViewMode === 'department' ? '出勤率' : '合计资产'}总运营车辆出勤车辆闲置车辆4.5T冷链18T49T挂车其他详情
- {dept.department} - - - {dept.attendanceRate}% - - - - - - - - - {isExpanded ? : } -
-
- {dept.managers.map(m => { - const isManagerExpanded = expandedManagerDetails.has(m.manager); - return ( -
-
toggleManagerDetails(m.manager)} - > -
- {isManagerExpanded ? : } - {m.manager} -
- -
- - {isManagerExpanded && ( -
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 4.5T` })} - > -
4.5T
-
{m.t4_5}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 4.5T冷链` })} - > -
冷链
-
{m.t4_5c}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 18T` })} - > -
18T
-
{m.t18}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 49T` })} - > -
49T
-
{m.t49}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 挂车` })} - > -
挂车
-
{m.trailer}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 其他` })} - > -
其他
-
{m.other}
-
-
-
- )} -
- ); - })} -
-
- {isManagerExpanded ? : } - {m.manager} - {m.department} { - e.stopPropagation(); - setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 正在运营` }); - }} - > - {m.total} - ------ - -
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 4.5T` })}> - 4.5T - {m.t4_5} -
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 4.5T冷链` })}> - 冷链 - {m.t4_5c} -
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 18T` })}> - 18T - {m.t18} -
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 49T` })}> - 49T - {m.t49} -
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 挂车` })}> - 挂车 - {m.trailer} -
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 其他` })}> - 其他 - {m.other} -
-
-
- - - {/* Mobile Card View */} -
- {deptViewMode === 'department' ? ( - deptData.map((dept) => { - const isExpanded = expandedDepts.has(dept.department); - return ( -
-
toggleDept(dept.department)} - > -
-

{dept.department}

- - 出勤率: {dept.attendanceRate}% - -
-
-
{ e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', department: dept.department, category: 'Operating', source: 'department', title: `部门运营统计 - ${dept.department}` }); }}> -
总运营
-
{dept.totalAssets}
-
-
{ e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', department: dept.department, attendance: 'active', source: 'department', title: `部门运营统计 - ${dept.department} - 出勤车辆` }); }}> -
出勤
-
{dept.operatingCount}
-
-
{ e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', department: dept.department, attendance: 'idle', source: 'department', title: `部门运营统计 - ${dept.department} - 闲置车辆` }); }}> -
闲置
-
{dept.idleCount}
-
-
-
- {isExpanded ? : } -
-
- {isExpanded && ( -
- {dept.managers.map(m => { - const isManagerExpanded = expandedManagerDetails.has(m.manager); - return ( -
-
toggleManagerDetails(m.manager)} - > -
- {isManagerExpanded ? : } - {m.manager} -
- -
- - {isManagerExpanded && ( -
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 4.5T` })} - > -
4.5T
-
{m.t4_5}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 4.5T冷链` })} - > -
冷链
-
{m.t4_5c}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 18T` })} - > -
18T
-
{m.t18}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 49T` })} - > -
49T
-
{m.t49}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 挂车` })} - > -
挂车
-
{m.trailer}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 其他` })} - > -
其他
-
{m.other}
-
-
- )} -
- ); - })} -
- )} -
- ); - }) - ) : ( - managerStats.map((m) => { - const isManagerExpanded = expandedManagerDetails.has(m.manager); - return ( -
-
toggleManagerDetails(m.manager)} - > -
- {isManagerExpanded ? : } -
-

{m.manager}

- {m.department} -
{ - e.stopPropagation(); - setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 正在运营` }); - }} - > - 资产: {m.total} -
-
-
- - -
- - {isManagerExpanded && ( -
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 4.5T` })} - > -
4.5T
-
{m.t4_5}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 4.5T冷链` })} - > -
冷链
-
{m.t4_5c}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 18T` })} - > -
18T
-
{m.t18}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 49T` })} - > -
49T
-
{m.t49}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 挂车` })} - > -
挂车
-
{m.trailer}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department', title: `部门运营统计 - ${m.manager} - 其他` })} - > -
其他
-
{m.other}
-
-
- )} -
- ); - }) - )} -
- - - )} - - {tabReady && activeTab === 'region' && ( -
- {/* Region Distribution Chart */} -
-
-
-
-

区域资产分布概览

-
-
- - -
-
-
- - - - - - - - - - - -
-
- - {/* Region - Vehicle - Customer Section */} -
-
-
-
-
-

区域运营统计

-

*按区域—车型—客户维度统计

-
-
- -
- - - - {isRegionFilterOpen && ( - <> -
- -
-

区域筛选

- -
- -
-
- - setDraftRegionFilters(prev => ({ ...prev, customer: v }))} options={uniqueCustomerNames} placeholder="所有客户" className="text-xs py-2 px-2" /> -
- -
-
- - -
-
- - -
-
-
- -
- - )} - -
-
- - {Object.values(regionFilters).some(v => v !== '') && ( -
- {regionFilters.customer && ( - - 客户: {regionFilters.customer} - - - )} - {regionFilters.region && ( - - 区域: {regionFilters.region} - - - )} - {regionFilters.city && ( - - 城市: {regionFilters.city} - - - )} - -
- )} - -
-
- - - - - - - - - - - - {regionData.map((r) => { - const isExpanded = expandedRegions.has(r.region); - return ( - - toggleRegion(r.region)} - > - - - - - - - {isExpanded && r.cities.map((city) => { - const cityKey = `${r.region}-${city.city}`; - const isCityExpanded = expandedRegionCities.has(cityKey); - return ( - - toggleRegionCity(cityKey)} - > - - - - - - - {isCityExpanded && city.typeBreakdown.map(tb => ( - - - - - - - - ))} - - ); - })} - - ); - })} - -
区域 / 车型 / 客户资产总数运营中待交车主要客户
- {isExpanded ? : } - - {r.region}区域 - { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, source: 'region', title: `区域运营统计 - ${r.region}` }); }}>{r.totalAssets} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, category: 'Operating', source: 'region', title: `区域运营统计 - ${r.region} - 正在运营` }); }}>{r.operatingCount} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, category: 'Pending', source: 'region', title: `区域运营统计 - ${r.region} - 待交车` }); }}>{r.pendingCount}{r.customers.slice(0, 2).join(', ')}
- {isCityExpanded ? : } - - {city.city} - { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, source: 'region', title: `区域运营统计 - ${city.city}` }); }}>{city.totalAssets} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, category: 'Operating', source: 'region', title: `区域运营统计 - ${city.city} - 正在运营` }); }}>{city.operatingCount} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, category: 'Pending', source: 'region', title: `区域运营统计 - ${city.city} - 待交车` }); }}>{city.pendingCount}{city.customers.slice(0, 2).join(', ')}
-
- {tb.type} 车型 -
setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type}` })}>{tb.total} setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Operating', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 正在运营` })}>{tb.operating} { setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Pending', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 待交车` }); }}>{tb.pending}{tb.customers.slice(0, 2).join(', ')}
-
- - {/* Mobile View (Region) */} -
- {regionData.map((r) => { - const isExpanded = expandedRegions.has(r.region); - return ( -
-
toggleRegion(r.region)} - > -
- {isExpanded ? : } - - {r.region}区域 -
-
资产: {r.totalAssets}
-
- {isExpanded && ( - <> -
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, category: 'Operating', source: 'region', title: `区域运营统计 - ${r.region} - 正在运营` })} - > -
运营中
-
{r.operatingCount}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, category: 'Pending', source: 'region', title: `区域运营统计 - ${r.region} - 待交车` })} - > -
待交车
-
{r.pendingCount}
-
-
-
- {r.typeBreakdown.map(tb => ( -
- {tb.type} 车型 -
- setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, vehicleType: tb.type, category: 'Operating', source: 'region', title: `区域运营统计 - ${r.region} - ${tb.type} - 正在运营` })} - > - 运:{tb.operating} - - setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, vehicleType: tb.type, category: 'Pending', source: 'region', title: `区域运营统计 - ${r.region} - ${tb.type} - 待交车` })} - > - 待:{tb.pending} - -
-
- ))} -
- - )} -
- ); - })} -
-
-
- )} - - {tabReady && activeTab === 'customer' && ( -
- - {/* Customer Operations Statistics Section */} -
-
-
-
-
-

客户运营统计

-

*按客户维度统计资产分布

-
-
- -
- - - - {isCustomerFilterOpen && ( - <> - {/* Backdrop */} -
- - {/* Popover Content */} - -
-

数据筛选

-
- -
-
- -
-
- - setDraftCustomerFilters(prev => ({ ...prev, customer: v }))} options={uniqueCustomerNames} placeholder="所有客户" className="text-xs py-2 px-2" /> -
- -
- - -
- -
-
- - -
-
- - -
-
- - -
-
-
- -
- - )} - -
-
- - {Object.values(customerFilters).some(v => Array.isArray(v) ? v.length > 0 : v !== '') && ( -
- {customerFilters.customer.length > 0 && ( - - 客户: {customerFilters.customer.join(', ')} - - - )} - {customerFilters.manager && ( - - 负责人: {customerFilters.manager} - - - )} - {customerFilters.brand && ( - - 品牌: {customerFilters.brand} - - - )} - {customerFilters.department && ( - - 部门: {customerFilters.department} - - - )} - {customerFilters.region && ( - - 区域: {customerFilters.region} - - - )} - -
- )} - -
- {/* Desktop Table View (Customer) */} -
- - - - - - - - - - - - - - - - - {filteredCustomerStats.map((cust) => { - const isExpanded = expandedCustomers.has(cust.customer); - return ( - - toggleCustomer(cust.customer)} - > - - - - - - - - - - - - {isExpanded && ( - - - - )} - - ); - })} - -
客户名称所在区域关联业务负责人4.5T4.5T冷链18T49T挂车其他合计
- {isExpanded ? : } - {cust.customer} - - {cust.region} - {cust.manager} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: false, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T` }); }}>{cust.t4_5} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: true, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T冷链` }); }}>{cust.t4_5c} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '18T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 18T` }); }}>{cust.t18} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '49T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 49T` }); }}>{cust.t49} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '其他车型', source: 'customer', title: `客户运营统计 - ${cust.customer} - 挂车` }); }}>{cust.trailer} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '其他车型', source: 'customer', title: `客户运营统计 - ${cust.customer} - 其他` }); }}>{cust.other} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, source: 'customer', title: `客户运营统计 - ${cust.customer}` }); }}>{cust.total}
-
-
-
客户详情
-
{cust.customer}
-
-
-
主要车型
-
- {cust.t49 > cust.t18 ? '49T 重卡' : (cust.t18 > cust.t4_5c ? '18T 货车' : '4.5T 轻卡')} -
-
-
-
业务经理
-
{cust.manager}
-
-
-
资产占比
-
- {((cust.total / deptData.reduce((s, d) => s + d.totalAssets, 0)) * 100).toFixed(1)}% -
-
-
-
-
- - {/* Mobile Card View (Customer) */} -
- {filteredCustomerStats.map((cust) => { - const isExpanded = expandedCustomers.has(cust.customer); - return ( -
-
toggleCustomer(cust.customer)} - > -
- {isExpanded ? : } -
- {cust.customer} - {cust.region}区域 -
-
-
{ e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, source: 'customer', title: `客户运营统计 - ${cust.customer}` }); }} - > - 合计: {cust.total} -
-
- - {isExpanded && ( -
- {/* Details Cards for Mobile */} -
-
-
客户详情
-
{cust.customer}
-
-
-
主要车型
-
- {cust.t49 > cust.t18 ? '49T 重卡' : (cust.t18 > cust.t4_5c ? '18T 货车' : '4.5T 轻卡')} -
-
-
-
业务经理
-
{cust.manager}
-
-
-
资产占比
-
- {((cust.total / deptData.reduce((s, d) => s + d.totalAssets, 0)) * 100).toFixed(1)}% -
-
-
- -
-
车型分布
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: false, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T` })} - > -
4.5T
-
{cust.t4_5}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: true, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T冷链` })} - > -
冷链
-
{cust.t4_5c}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '18T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 18T` })} - > -
18T
-
{cust.t18}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '49T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 49T` })} - > -
49T
-
{cust.t49}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '其他车型', source: 'customer', title: `客户运营统计 - ${cust.customer} - 挂车` })} - > -
挂车
-
{cust.trailer}
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '其他车型', source: 'customer', title: `客户运营统计 - ${cust.customer} - 其他` })} - > -
其他
-
{cust.other}
-
-
-
-
- )} -
- ); - })} -
-
-
+
+ + + {updated + ? `更新于 ${updated.slice(11, 16)} · 台账截至 17:00` + : "正在加载数据"} + + +
- )} - - - - - -
- +

+ {updated + ? `更新于 ${updated.slice(11, 16)} · 台账截至 17:00` + : "正在加载数据"} +

+ {error && ( + + )} + {!data ? ( + loading ? ( + + ) : null + ) : ( + setHash({ tab: value as AssetTab })} + > + + + 总览 + + + 按部门 + + + 按区域 + + + 按客户 + + + + + setHash({ section: next }) + } + open={setSelection} + /> + + + + + + + + + + + + )} + setSelection(null)} + /> + {assistantOpen && 正在打开 AI 问数…}> { setAssistantOpen(false); requestAnimationFrame(() => assistantTrigger.current?.focus()); }} />} ); } diff --git a/src/modules/assets/api.ts b/src/modules/assets/api.ts index d04d63a..30ac0cc 100644 --- a/src/modules/assets/api.ts +++ b/src/modules/assets/api.ts @@ -37,6 +37,7 @@ export async function fetchByType(subject?: string | null): Promise { const query = new URLSearchParams(); + if (params.brand) query.set('brand', params.brand); if (params.batch) query.set('batch', params.batch); if (params.model) query.set('model', params.model); if (params.location) query.set('location', params.location); @@ -153,13 +155,14 @@ export async function fetchRegionChart( export async function fetchWeeklyDetail( type: string, - filters?: { model?: string; batch?: string; location?: string; source?: string }, + filters?: { model?: string; batch?: string; location?: string; source?: string; subject?: string | null }, ): Promise { const params = new URLSearchParams({ type }); if (filters?.model && filters.model !== 'All') params.set('model', filters.model); if (filters?.batch && filters.batch !== 'All') params.set('batch', filters.batch); if (filters?.location && filters.location !== 'All') params.set('location', filters.location); if (filters?.source) params.set('source', filters.source); + if (filters?.subject) params.set('subject', filters.subject); return fetchJson(`${BASE}/weekly-detail?${params.toString()}`); } diff --git a/src/modules/assets/assistant/AssetAssistant.tsx b/src/modules/assets/assistant/AssetAssistant.tsx new file mode 100644 index 0000000..072b80c --- /dev/null +++ b/src/modules/assets/assistant/AssetAssistant.tsx @@ -0,0 +1,640 @@ +import { useEffect, useRef, useState } from "react"; +import { + ArrowUp, + ArrowUpRight, + AudioLines, + BarChart3, + Building2, + CircleHelp, + Database, + History, + MessageSquare, + Mic, + Moon, + Plus, + ShieldCheck, + Sparkles, + Square, + Sun, + Truck, + X, +} from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "../../../components/ui/dialog"; +import type { + AssistantResult, + QueryPlan, +} from "../../../shared/assets/assistant"; +import { + CATEGORY_LABELS, + GROUP_LABELS, +} from "../../../shared/assets/assistant"; +import { askAssetAssistant } from "./client"; +import { ThinkingState } from "./ThinkingState"; +import { ResultView } from "./ResultView"; +import "./assistant.css"; + +type Turn = { + id: number; + question: string; + started: number; + steps: string[]; + result?: AssistantResult; + error?: string; +}; +type Recognition = { + lang: string; + continuous: boolean; + interimResults: boolean; + start(): void; + stop(): void; + abort(): void; + onresult: + | ((event: { + results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }>; + }) => void) + | null; + onerror: ((event: { error: string }) => void) | null; + onend: (() => void) | null; +}; +const PROMPTS = [ + { + icon: BarChart3, + title: "库存分布", + text: "各区域的库存车辆有多少?按数量排序。", + detail: "区域对比 · 一键下钻", + }, + { + icon: Truck, + title: "运营情况", + text: "按车型统计当前资产数量和运营率。", + detail: "车型构成 · 运营率", + }, + { + icon: History, + title: "交退换趋势", + text: "本月的交车、还车和替换交车趋势怎么样?", + detail: "按日趋势 · 业务明细", + }, + { + icon: ListIcon, + title: "待交车清单", + text: "列出当前待交车车辆明细。", + detail: "车辆列表 · 导出结果", + }, +]; +function ListIcon({ size }: { size?: number }) { + return ; +} + +export default function AssetAssistant({ + subject, + close, + appearance, +}: { + subject: string | null; + close: () => void; + appearance: "light" | "dark"; +}) { + const [theme, setTheme] = useState(appearance); + const [turns, setTurns] = useState([]); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [listening, setListening] = useState(false); + const [voiceNote, setVoiceNote] = useState(""); + const [showInfo, setShowInfo] = useState(false); + const abortRef = useRef(null); + const recognitionRef = useRef(null); + const nextId = useRef(0); + const composer = useRef(null); + const scroller = useRef(null); + const lastPlan = turns + .filter((t) => t.result && t.result.plan.kind !== "clarify") + .at(-1)?.result?.plan; + useEffect( + () => () => { + abortRef.current?.abort(); + recognitionRef.current?.abort(); + }, + [], + ); + const stop = () => { + abortRef.current?.abort(); + abortRef.current = null; + setBusy(false); + setTurns((previous) => + previous.map((t) => + !t.result && !t.error + ? { ...t, error: "已停止生成,可以修改问题后重试。" } + : t, + ), + ); + }; + const ask = async (message: string, previous = lastPlan) => { + if (!message.trim() || abortRef.current) return; + recognitionRef.current?.abort(); + setListening(false); + setVoiceNote(""); + const id = ++nextId.current; + const controller = new AbortController(); + abortRef.current = controller; + setDraft(""); + setBusy(true); + setTurns((old) => [ + ...old, + { + id, + question: message.trim(), + started: Date.now(), + steps: ["连接资产分析服务"], + }, + ]); + requestAnimationFrame(() => + scroller.current?.scrollTo({ + top: scroller.current.scrollHeight, + behavior: "instant", + }), + ); + try { + await askAssetAssistant( + { message, subject, previous }, + controller.signal, + (event) => { + if (controller.signal.aborted) return; + const container = scroller.current; + const follow = + container && + container.scrollHeight - + container.scrollTop - + container.clientHeight < + 150; + if (follow && event.type === "result") + requestAnimationFrame(() => + container.scrollTo({ + top: container.scrollHeight, + behavior: "instant", + }), + ); + setTurns((old) => + old.map((turn) => + turn.id !== id + ? turn + : event.type === "step" + ? { ...turn, steps: [...turn.steps, event.label] } + : event.type === "error" + ? { ...turn, error: event.message } + : { ...turn, result: event.result }, + ), + ); + }, + ); + } catch (error) { + if (!controller.signal.aborted) + setTurns((old) => + old.map((t) => + t.id === id + ? { + ...t, + error: + error instanceof Error + ? error.message + : "查询失败,请重试。", + } + : t, + ), + ); + } finally { + if (abortRef.current === controller) { + abortRef.current = null; + setBusy(false); + composer.current?.focus({ preventScroll: true }); + } + } + }; + const toggleVoice = () => { + if (listening) { + recognitionRef.current?.stop(); + return; + } + const win = window as typeof window & { + SpeechRecognition?: new () => Recognition; + webkitSpeechRecognition?: new () => Recognition; + }; + const Constructor = win.SpeechRecognition || win.webkitSpeechRecognition; + if (!Constructor) { + setVoiceNote( + "当前浏览器不支持语音识别,请使用 Chrome / Edge 或直接输入文字。", + ); + return; + } + const recognition = new Constructor(); + recognitionRef.current = recognition; + recognition.lang = "zh-CN"; + recognition.continuous = false; + recognition.interimResults = true; + const base = draft; + recognition.onresult = (event) => + setDraft( + (base ? base + " " : "") + + Array.from(event.results) + .map((r) => r[0].transcript) + .join(""), + ); + recognition.onerror = (event) => { + setListening(false); + setVoiceNote( + event.error === "not-allowed" + ? "麦克风权限未开启,你仍可以输入文字。" + : event.error === "no-speech" + ? "没有识别到语音,请再试一次。" + : "语音识别暂时不可用,请使用文字输入。", + ); + }; + recognition.onend = () => { + setListening(false); + composer.current?.focus(); + }; + try { + recognition.start(); + setListening(true); + setVoiceNote("正在聆听,点击麦克风结束。转写后可修改再发送。"); + } catch { + setVoiceNote("无法启动语音识别,请使用文字输入。"); + } + }; + const reset = () => { + abortRef.current?.abort(); + abortRef.current = null; + recognitionRef.current?.abort(); + setBusy(false); + setListening(false); + setVoiceNote(""); + setTurns([]); + setDraft(""); + composer.current?.focus(); + }; + return ( + { + if (!open) close(); + }} + > + { + e.preventDefault(); + composer.current?.focus(); + }} + > + 资产 AI 问数 + + 通过自然语言查询当前账号可见的资产数据,支持图表、表格和明细下钻。 + + +
+
+
+ + AI 问数 + + 你的资产分析助手 +
+
+ + + + +
+
+
+ + {subject || "全部所属公司"} + · + 当前账号可见范围 + + + 已连接业务数据 + +
+ {showInfo && ( +
+ 支持的分析 +

+ 当前资产数量、运营率、库存分布、交退换车趋势与车辆明细。可按区域、城市、车型、型号和部门筛选或分组。 +

+

+ 资产口径:当前台账;运营率 = 运营车辆 / + 筛选车辆。库存包含异动和待交车。暂不支持历史库存、成本或预测。模型接收问题和汇总统计,车辆明细留在业务系统。语音由浏览器提供,可能使用其在线识别服务。 +

+
+ )} +
+ {!turns.length ? ( +
+
+ +
+
+ LESS SEARCHING. MORE UNDERSTANDING. +
+

关于资产,你想了解什么?

+

+ 用一句话,发现数据里的答案。 +
{" "} + 从全局统计,一路看到每一台车。 +

+
+ {PROMPTS.map((p) => ( + + ))} +
+
+ + 只读查询 · 真实数据 · 可追溯明细 +
+
+ ) : ( +
+ {turns.map((turn, index) => ( +
+
+ 你 +

{turn.question}

+
+
+ + + + 资产助手 + DeepSeek Flash +
+ + {turn.error ? ( +
+

{turn.error}

+ +
+ ) : turn.result ? ( + + void ask( + text, + turn.result?.plan.kind === "clarify" + ? lastPlan + : turn.result?.plan, + ) + } + disabled={busy} + /> + ) : ( +
+ + + +
+ )} +
+ ))} +
+ )} +
+
+ {lastPlan && ( +
+ 追问上下文 + + {lastPlan.kind === "flow" + ? "交退换车" + : CATEGORY_LABELS[lastPlan.category]} + + {GROUP_LABELS[lastPlan.groupBy]} + {Object.values(lastPlan.filters).map((v) => ( + {v} + ))} +
+ )} +
{ + e.preventDefault(); + void ask(draft); + }} + > + +