Author SHA1 Message Date
kkfluous 1674f8b931 perf: reuse historical mileage range snapshots 2026-08-08 11:07:22 +08:00
kkfluous 559094cdc5 fix: decouple mileage KPIs from sorting 2026-08-07 18:44:49 +08:00
kkfluous c0d504cb7b feat: persist mileage monitoring context 2026-08-07 18:39:13 +08:00
kkfluous d958f3b28f fix: bind mileage list to request context 2026-08-07 18:30:49 +08:00
kkfluous f8e926876c fix: make mileage fullscreen failures visible 2026-08-07 18:23:50 +08:00
kkfluous 6bf88a25e3 fix: preserve vehicle drill date context 2026-08-07 18:16:14 +08:00
kkfluous 017001e889 fix: preserve dated vehicle panel snapshots 2026-08-07 18:09:52 +08:00
kkfluous b11187aba7 fix: surface mileage child request failures 2026-08-07 18:01:38 +08:00
kkfluous a344497505 fix: distinguish mileage statistics failures 2026-08-07 17:58:54 +08:00
kkfluous d1ed3903fe feat: flag mileage target coverage gaps 2026-08-07 17:52:49 +08:00
kkfluous bca0f7fe82 feat: publish mileage assessment freshness 2026-08-07 17:49:52 +08:00
kkfluous 8a7ce7eccf perf: reuse mileage target snapshots 2026-08-07 17:46:51 +08:00
kkfluous 2de417e109 fix: reconcile mileage attribution totals 2026-08-07 17:42:21 +08:00
kkfluous 67a269651e feat: preserve mileage assessment years 2026-08-07 17:37:05 +08:00
kkfluous 52d79ba260 feat: preserve BI sub tab history 2026-08-07 17:31:09 +08:00
kkfluous 39e869ad39 feat: focus electric order drilldowns 2026-08-07 17:25:40 +08:00
kkfluous 70498fc4fa feat: focus hydrogen order drilldowns 2026-08-07 17:22:46 +08:00
kkfluous 18287a2c70 feat: persist hydrogen order pages 2026-08-07 17:19:34 +08:00
kkfluous d786b984bb feat: reconcile ETC overview totals 2026-08-07 17:15:52 +08:00
kkfluous 5ea788e565 feat: reconcile hydrogen daily totals 2026-08-07 17:12:07 +08:00
kkfluous 003a0b580c feat: focus ETC metric drilldowns 2026-08-07 17:07:29 +08:00
kkfluous 64571d58c3 feat: reconcile electric daily totals 2026-08-07 17:03:16 +08:00
kkfluous 11b1afd68c fix: preserve electric drilldown history 2026-08-07 16:59:02 +08:00
kkfluous 0adc2c80f7 feat: make energy chart drilldowns keyboard accessible 2026-08-07 16:56:01 +08:00
kkfluous 86a23ad343 refactor: unify BI vehicle scope controls 2026-08-07 16:53:07 +08:00
kkfluous ea9c9212e2 fix: restore BI quick date state 2026-08-07 16:49:42 +08:00
kkfluous 3d57787e61 refactor: unify BI date range filters 2026-08-07 16:44:31 +08:00
kkfluous 55297a6240 refactor: unify embedded BI states 2026-08-07 16:37:53 +08:00
kkfluous 671a7a8224 refactor: persist ETC analysis state 2026-08-07 16:34:32 +08:00
kkfluous a8f7548314 feat: drill hydrogen trends into orders 2026-08-07 16:29:26 +08:00
kkfluous 263d8c6245 feat: add ETC records and bill drilldowns 2026-08-07 16:19:19 +08:00
kkfluous 47a4474608 feat: surface BI data source health 2026-08-07 16:09:01 +08:00
kkfluous 52c785f6ec feat: observe BI data source health 2026-08-07 16:05:20 +08:00
kkfluous 02d714175f feat: expose BI configuration readiness 2026-08-07 16:00:04 +08:00
kkfluous 686fc4e2e2 refactor: stabilize cumulative mileage metrics 2026-08-07 15:56:39 +08:00
kkfluous 8c6a3ad831 refactor: show mileage data freshness 2026-08-07 15:50:24 +08:00
kkfluous de2a0896c4 perf: reuse mileage monitoring snapshots 2026-08-07 15:48:07 +08:00
kkfluous 60e81cb4ba security: externalize BI data source credentials 2026-08-07 15:42:34 +08:00
kkfluous 616341adc8 perf: make mileage pagination explicit 2026-08-07 15:37:07 +08:00
kkfluous c2d42efa6d docs: define incremental BI refactor roadmap 2026-08-07 15:32:47 +08:00
kkfluous 326201abab refactor: surface hydrogen source failures 2026-08-07 15:29:21 +08:00
kkfluous 0d849fcbb1 refactor: expose electric data freshness 2026-08-07 15:22:04 +08:00
kkfluous 21f5e5388a refactor: align electric overview drilldown 2026-08-07 15:18:58 +08:00
kkfluous 08097f7c29 refactor: surface BI metric definitions 2026-08-07 15:12:34 +08:00
kkfluous 46dbe725de perf: deduplicate concurrent API reads 2026-08-07 15:04:47 +08:00
kkfluous 0fb62135f3 refactor: expose ETC integration readiness 2026-08-07 15:00:58 +08:00
kkfluous cdf1fd27e7 refactor: add electric order drilldown 2026-08-07 14:55:55 +08:00
kkfluous cf9782562b refactor: define electric BI metric semantics 2026-08-07 14:48:50 +08:00
kkfluous 757ed1ebd6 refactor: add hydrogen customer drilldown 2026-08-07 14:45:36 +08:00
kkfluous 71d0091457 refactor: add hydrogen station drilldown 2026-08-07 14:41:29 +08:00
kkfluous d53257c65c refactor: define hydrogen BI metric semantics 2026-08-07 14:33:38 +08:00
kkfluous b2044109e3 refactor: build drillable mileage daily report 2026-08-07 14:27:19 +08:00
kkfluous 91031bd034 refactor: add mileage attribution drilldown 2026-08-07 14:15:31 +08:00
kkfluous f24a18e459 refactor: persist mileage drill context in URL 2026-08-07 14:00:43 +08:00
kkfluous ce50265771 refactor: publish mileage metric catalog 2026-08-07 13:54:51 +08:00
kkfluous 13e720c2c3 refactor: add weighted mileage completion metric 2026-08-07 13:51:58 +08:00
452 changed files with 16919 additions and 46129 deletions
Vendored
BIN
View File
Binary file not shown.
-6
View File
@@ -1,15 +1,9 @@
node_modules node_modules
dist dist
.env .env
.env.*
!.env.example
.git .git
.codex .codex
outputs outputs
reports reports
data/geo data/geo
data/vehicle-heatmap/source.csv data/vehicle-heatmap/source.csv
# Local machine metadata and runtime logs
.DS_Store
*.log
+51
View File
@@ -0,0 +1,51 @@
# Core asset database
DB_HOST=
DB_PORT=3306
DB_USER=
DB_PASSWORD=
DB_NAME=
# Hydrogen ledger database
HYDROGEN_DB_HOST=
HYDROGEN_DB_PORT=3306
HYDROGEN_DB_USER=
HYDROGEN_DB_PASSWORD=
HYDROGEN_DB_NAME=
# Mileage reference database
MILEAGE_DB_HOST=
MILEAGE_DB_PORT=3306
MILEAGE_DB_USER=
MILEAGE_DB_PASSWORD=
MILEAGE_DB_NAME=
# OneOS mileage API
ONEOS_MILEAGE_API_BASE_URL=http://172.17.111.55:20310
ONEOS_MILEAGE_API_KEY=
ONEOS_MILEAGE_API_TIMEOUT_MS=20000
# Authentication and external API
EXTERNAL_API_BASE=https://lnh2e.com
JWT_SECRET=
DEV_BYPASS_AUTH=0
# Vehicle heatmap database and map SDK
HEATMAP_DB_HOST=
HEATMAP_DB_PORT=36453
HEATMAP_DB_USER=
HEATMAP_DB_PASSWORD=
HEATMAP_DB_NAME=lingniu_vehicle_data
HEATMAP_DB_SSL=false
AMAP_WEB_KEY=
AMAP_SECURITY_JS_CODE=
# Feedback attachment object storage
OSS_ENDPOINT=
OSS_REGION=oss-cn-shanghai
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_BUCKET=
OSS_BASE_DIR=/dos
OSS_HOST=
SERVER_PORT=3001
-9
View File
@@ -1,13 +1,4 @@
node_modules node_modules
dist dist
.env .env
.env.*
!.env.example
.worktrees .worktrees
# Local machine metadata and runtime logs
.DS_Store
*.log
# Permission-preview audit trail (operator/target identifiers)
logs/permission-preview.jsonl
-79
View File
@@ -1,79 +0,0 @@
# 故障管理 BI 设计验收
final result: passed
## 最新调整:总览视觉与多维分析对齐
管理总览复用多维分析的页面色板与标题布局,统一浅蓝背景、白色7px圆角面板、蓝色标题标记、细边框按钮、日期控件和表格。趋势图已闭环/未闭环使用与分布分析一致的蓝/橙色;周报新增/解决仍用蓝/绿语义色。统计和排列顺序不变。
- 桌面:`../outputs/fault-overview-theme-20261013/desktop.jpg`(1448×1086);手机:`../outputs/fault-overview-theme-20261013/mobile.jpg`(390×844),均已目视检查。
- 两页背景均为 rgb(248,251,255),共用主蓝、文字及边框变量;手机四卡132px等高,scrollWidth=390,无横向溢出。
- 页签往返、手机日期选择打开/关闭正常,趋势和周报导出入口保留。类型检查、生产构建和diff检查通过;本次验证控制台无新增error/warn。
- 未改动数据接口或统计口径;未重复执行数据库测试与下载导出文件。
## 最新调整:管理总览与处理效率合并
按用户最新要求,管理总览恢复原来的页面及 `/overview` 统计,依次展示实时汇总、故障处理趋势、故障运维周报;移除独立处理效率页签,保留多维分析、故障明细。以下原参考设计验收为历史记录,管理总览以本段及最新截图为准。
- 桌面证据:`../outputs/fault-overview-merged-20261013/desktop.jpg`,1448×1086。
- 手机证据:`../outputs/fault-overview-merged-20261013/mobile.jpg`,390×844;四卡均为132px,无页面横向溢出。
- 验证原今日新增、本日解决、当前在办及累计台账,当前在办下钻27单;页签往返正常,三个统计区同页出现,周报恢复默认业务周。
- TypeScript、生产构建、diff检查通过;本次未修改后端统计口径。页面非空、无框架覆盖层,最终验证期间控制台无新增error/warn。
- 保留趋势与周报的独立日期、下钻和导出按钮;本次未重复下载导出文件。已清除临时viewport覆盖。
## 对照依据
- 来源:`/var/folders/3b/z3fk0dm9419dk7qffdvrwmfh0000gn/T/codex-clipboard-1efea2c0-2930-4600-b725-874c3c2a1f4f.png`
- 实现:`http://127.0.0.1:4310/asset#faults`
- 桌面截图:`../outputs/fault-dashboard-20261013/desktop.jpg`
- 同图左右对照:`../outputs/fault-dashboard-20261013/comparison.jpg`(左参考,右实现)
- 局部对照:`../outputs/fault-dashboard-20261013/comparison-filters-kpis.jpg`(筛选、指标、提示条)
- 参考和实现各 1448×1086 像素;CSS viewport 1448×1086,DPR 1,无密度缩放。比较图片由两份截图并排组成,展示工具可能缩小,局部图另行检查。
- 状态:浅色、管理总览、2026-09-14 至 2026-10-13、全部授权数据。参考使用示例数据,实现使用实际数据库;数值、分类数量和客户名称不要求与示例相同。
- 手机:390×844 `mobile.jpg`;320×844 `mobile-320.jpg`。另有 `efficiency.jpg`、`drilldown.jpg`。
## Findings / 比较与修复历史
1. [P2,已修复] 现有应用顶部浮动面包屑与新标题重叠。通过仅作用于故障页面的 shell 选择器隐藏重复面包屑;最终桌面、手机截图无重叠。
2. [P2,已修复] 第一版分析面板和列表过长,重点跟进过度后移。车型默认前三、客户默认前四、跟进默认前三,提供全部入口;减小图表行距、等级间距和桌面表格单元格内边距。最终 `comparison.jpg` 显示完整指标、分析面板及跟进入口。
3. [P2,已修复] 分布图自动跳过部分区域标签。YAxis interval=0;补充柱尾数量,包括未闭环为零的组,保留精确数量表与下钻。
4. [P2,已修复] 320px 下标题和导出挤占,原生筛选框太窄。标题操作区换行,筛选单列,指标标题字号适配。`mobile-320.jpg` 显示全部入口及所选值,没有横向页面溢出。
5. 最终对照无未解决的 P0/P1/P2。以下为有意保留的业务差异,而非像素复刻:现有导航品牌与功能、页面水印、今日新增/本日解决/累计台账、未标注等级及部位、真实长车型名称和运维处置人第二列。实际内容略长,重点跟进明细需继续向下滚动;避免通过隐藏未知数据或缩小明细字体模拟示例高度。
## 必查表面
- 字体:沿用应用中文无衬线回退,桌面标题 24px、面板标题 14px、指标 29px,层级与参考一致;重点检查筛选、数字与窄屏换行。没有以图片模拟文字。
- 间距:四指标等高、两行双列分析面板、统一边框与圆角;手机两列指标、分析面板单列,宽表内部横滚。
- 颜色:浅蓝页面、白底面板、蓝色主操作;橙色未闭环、红色逾期,未知等级灰色。等级与实际停运含义分开说明。
- 图像与图标:参考主要为数据图表。使用现有 Lucide 图标及真实数据图表;沿用产品已有品牌导航,未自行绘制替代参考 ONE OS 标识。无新增位图资产或虚构装饰图。
- 文案与数据:使用真实口径和实际分类;“当前在办=待处理+处理中”,逾期为在办子集;“处置记录”避免把 solve_process 当作当前工作流阶段。发生频率缺分母,停运与修复时长缺数据,明确显示待补。
## 功能与数据验收
- 新增 3 项后端测试;故障模块与报表导出测试合计 32 项全部通过。
- TypeScript 检查、生产构建、git diff --check 通过。
- 实库只读核对 63 个分组、48 个车型×部位单元格,汇总与下钻一致;在办 26=17+9,逾期5,本期106,车辆去重89。
- 浏览器验证:华东筛选后总览39单,趋势39单,周报新增39/解决125;按月切换及报表下载;等级20单下钻、交叉单元格3单下钻、单条故障明细、十二维分析默认运维处置人/故障工单状态。
- 手机更多筛选 L1-特急得到本期1单;其他区域空数据正确显示0和空态。320/390px 页面 scrollWidth 等于 viewport width。
- 总览 Excel 下载成功,重新读取工作簿确认6张表、日期/筛选说明、冻结表头和数字106/26与页面一致。
- 处理效率保留既有柱图、周报折线图、今日行提示、报表导出与下钻。
- 最终验证时间段(08:40 UTC 后)无浏览器 error/warn;开发编辑过程曾有一次已修复的 HMR 导入错误,未计为运行期问题。
- 浏览器测试后恢复管理总览、全部筛选,并清除临时 viewport override。
## Open Questions / 残余限制
- 故障发生频率需要同期服务车辆规模;实际停运、修复时长需要业务定义和数据来源,未捏造这些指标。
- 未进行不同浏览器引擎实机测试;已验证当前 in-app 浏览器桌面和移动断点。
- 只读接入,未修改业务数据库;本次改动未提交或推送。
## Implementation Checklist
- [x] 参考布局落地并绑定真实数据。
- [x] 汇总、业务筛选、分布、矩阵、客户和重点跟进下钻。
- [x] 趋势/周报继承筛选并保留导出。
- [x] 数据权限、空数据、移动端与导出验证。
- [x] 同图对照、局部检查、问题修复和最终证据保存。
## Follow-up Polish
- P3:后续如提供统一品牌资产,可单独统一应用级导航标识;当前保持现有产品导航。
+32 -31
View File
@@ -2,42 +2,43 @@ version: '3.8'
services: services:
ln-bi: ln-bi:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:feature-asset-statistics-1.1.18 image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
network_mode: host network_mode: host
environment: environment:
# Runtime only: set this variable in Portainer Stack environment variables. DB_HOST: "${DB_HOST:?Set DB_HOST in the deployment environment}"
DEEPSEEK_API_KEY: "${DEEPSEEK_API_KEY:-}" DB_PORT: "${DB_PORT:-3306}"
# Enabled when a Key is configured; set 0 to use the local planner only. DB_USER: "${DB_USER:?Set DB_USER in the deployment environment}"
ASSET_AI_EXTERNAL_ENABLED: "${ASSET_AI_EXTERNAL_ENABLED:-1}" DB_PASSWORD: "${DB_PASSWORD:?Set DB_PASSWORD in the deployment environment}"
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com" DB_NAME: "${DB_NAME:?Set DB_NAME in the deployment environment}"
DB_PORT: "3306" HYDROGEN_DB_HOST: "${HYDROGEN_DB_HOST:?Set HYDROGEN_DB_HOST in the deployment environment}"
DB_USER: "oneos_db_prod" HYDROGEN_DB_PORT: "${HYDROGEN_DB_PORT:-3306}"
DB_PASSWORD: "adASHJcviqwjkbn23ngt1" HYDROGEN_DB_USER: "${HYDROGEN_DB_USER:?Set HYDROGEN_DB_USER in the deployment environment}"
DB_NAME: "ln_asset_management" HYDROGEN_DB_PASSWORD: "${HYDROGEN_DB_PASSWORD:?Set HYDROGEN_DB_PASSWORD in the deployment environment}"
HYDROGEN_DB_HOST: "47.99.185.173" HYDROGEN_DB_NAME: "${HYDROGEN_DB_NAME:?Set HYDROGEN_DB_NAME in the deployment environment}"
HYDROGEN_DB_PORT: "3306" MILEAGE_DB_HOST: "${MILEAGE_DB_HOST:?Set MILEAGE_DB_HOST in the deployment environment}"
HYDROGEN_DB_USER: "root" MILEAGE_DB_PORT: "${MILEAGE_DB_PORT:-3306}"
HYDROGEN_DB_PASSWORD: "lnMysql." MILEAGE_DB_USER: "${MILEAGE_DB_USER:?Set MILEAGE_DB_USER in the deployment environment}"
HYDROGEN_DB_NAME: "ln_asset_management" MILEAGE_DB_PASSWORD: "${MILEAGE_DB_PASSWORD:?Set MILEAGE_DB_PASSWORD in the deployment environment}"
MILEAGE_DB_HOST: "101.133.130.65" MILEAGE_DB_NAME: "${MILEAGE_DB_NAME:?Set MILEAGE_DB_NAME in the deployment environment}"
MILEAGE_DB_PORT: "3306"
MILEAGE_DB_USER: "bi_reader_02"
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
MILEAGE_DB_NAME: "hydrogen_energy"
# ECS production accesses OneOS over the private network. Configure the key
# as a Portainer stack environment variable; never commit it to this file.
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}" ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY}" ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY:?Set ONEOS_MILEAGE_API_KEY in the deployment environment}"
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}" ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
SERVER_PORT: "8111" SERVER_PORT: "${SERVER_PORT:-8111}"
EXTERNAL_API_BASE: "https://lnh2e.com" EXTERNAL_API_BASE: "${EXTERNAL_API_BASE:-https://lnh2e.com}"
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7" JWT_SECRET: "${JWT_SECRET:?Set JWT_SECRET in the deployment environment}"
AMAP_WEB_KEY: "${AMAP_WEB_KEY}" AMAP_WEB_KEY: "${AMAP_WEB_KEY:?Set AMAP_WEB_KEY in the deployment environment}"
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}" AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE:?Set AMAP_SECURITY_JS_CODE in the deployment environment}"
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}" OSS_ENDPOINT: "${OSS_ENDPOINT:?Set OSS_ENDPOINT in the deployment environment}"
OSS_REGION: "${OSS_REGION:-oss-cn-shanghai}"
OSS_ACCESS_KEY_ID: "${OSS_ACCESS_KEY_ID:?Set OSS_ACCESS_KEY_ID in the deployment environment}"
OSS_ACCESS_KEY_SECRET: "${OSS_ACCESS_KEY_SECRET:?Set OSS_ACCESS_KEY_SECRET in the deployment environment}"
OSS_BUCKET: "${OSS_BUCKET:?Set OSS_BUCKET in the deployment environment}"
OSS_BASE_DIR: "${OSS_BASE_DIR:-/dos}"
OSS_HOST: "${OSS_HOST:-}"
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST:?Set HEATMAP_DB_HOST in the deployment environment}"
HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}" HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}"
HEATMAP_DB_USER: "${HEATMAP_DB_USER}" HEATMAP_DB_USER: "${HEATMAP_DB_USER:?Set HEATMAP_DB_USER in the deployment environment}"
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}" HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD:?Set HEATMAP_DB_PASSWORD in the deployment environment}"
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}" HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}" HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
deploy: deploy:
BIN
View File
Binary file not shown.
-67
View File
@@ -1,67 +0,0 @@
# 老板视角问数验收
## 来源与判定原则
根据用户提供的《AI资产助手-老板视角实测-20260923.md》完整复测设计。原测试于 2026-09-23 16:11—16:14 在 beta 环境执行。它确认了一次会话的前端表现,不代表数据库审计或权限验收。
本轮优先纠正三个产品问题:待交车不能被待运营替代;复合问题先返回已支持部分;每张结果带足以独立理解的范围与口径。自动化测试使用合成数据和模拟模型,没有访问真实数据库或真实 DeepSeek API。部署后的实机复测必须另行记录,不能用模拟通过替代。
## 原始十问与目标
| 题号 | 原始输入 | 修复完成判定 |
| --- | --- | --- |
| 01 | 我是公司负责人。请用当前数据给我一份资产经营简报:资产总数、运营和库存各多少、运营率多少;哪个车型库存最多,最值得我优先关注?请给数字依据、数据截至时间和统计口径。 | 返回四个总量指标及库存车型集中度;重点关注是基于库存数量的建议,不编造滞销原因或损失。源更新时间缺失时明确未提供。 |
| 02 | 现在总共有多少台车?运营多少、库存多少,整体运营率是多少? | 四项都在结果指标中;数值由内部计算。 |
| 03 | 按车型看库存,按库存数量从多到少排序,告诉我库存最多的车型占总库存多少。 | 库存降序、占完整库存比例;说明吨位粒度,4.5T 包含普货与冷链。 |
| 04 | 这些4.5T库存主要在哪些区域?只看可运营的,按数量降序。 | 保留 4.5T、可运营、区域分组、降序;单独复制结果仍可识别完整范围。 |
| 05 | 换个问题:最近7天交车、还车分别多少?其中替换交车多少?比前7天是改善还是变差?请写明起止日期和统计口径。 | 清除旧 4.5T 和库存限制,返回最近七天与前七天数字、日期;改善方向缺少业务目标时仅澄清这一部分。不得把交车减还车直接认定为运营车辆净增。 |
| 06 | 全部车辆,不沿用之前4.5T或库存筛选。查询2026年9月17日至9月23日交车、还车、替换交车数量,按天列出。 | 日期完整保留、旧筛选清除;交车含替换且不重复相加,单位为车次。 |
| 07 | 当前库存比上个月月底增加了多少?这些闲置车每个月损失多少钱?没有相应数据请直接说明,不要估算。 | 说明缺历史库存快照与成本数据,无估算;不能回退到当前库存并假装已经回答历史变化。 |
| 08 | 现在全部公司的待交车有多少?列出待交车清单。 | 当前账号可见范围内待交车真实数量和明细;0 展示为空结果,不展示失败或停止。 |
| 09 | 查询待交车数量 | 与第 08 题同口径;即使模型误提议 awaiting,也不能执行待运营替代。 |
| 10 | 我问的是“待交车”,不是“待运营”。请区分这两个业务状态,重新查询待交车数量;如果不支持,请明确说明,不能用待运营数量替代。 | 重新查询待交车;若历史结果确实答错,明确指出该结果不适用于待交车判断并关联已纠正的结果。 |
## 合成测试基线
10 台资产,4 台运营,6 台库存,运营率 40%。待运营 3 台,待交车 0 台,故意让两者不同,避免状态替代错误被相同数量掩盖。
库存:4.5T 普货 1 台、4.5T 冷链 3 台、18T 1 台、49T 1 台。4.5T 合并为 4 台,占库存 66.7%。其中 4.5T 可运营 2 台,均位于华东。
流转:每个请求期间返回合成交车 1 笔、替换 1 笔、还车 1 笔;交车含替换合计 2 车次,总事件 3 笔。用于验证日期、筛选、计数与跨期调用,并不代表业务趋势。
## 自动化与实机分工
`src/server/routes/asset-assistant/boss-experience.test.ts` 校验业务计划实际执行、错误模型状态纠正、复合部分回答、完整十问本地降级链路、结果口径、4.5T 进一步分类和错误答案撤回关联。
2026-09-23 开发验收:以上 9 项路由集成场景全部通过,`npm run lint` 通过。执行命令为 `node --import tsx --test src/server/routes/asset-assistant/boss-experience.test.ts`。其中另外加入“真实待交 1 台但模型残留待运营过滤”的反例,验证不会产生假零结果。此处只记录本地自动化结论,不代表 beta 环境已经部署或真实模型十问已经全部通过。
实机需重点补验:
1. 使用原始十问直接询问真实模型,保留每题结果和实际查询计划。
2. 验证待交车推荐入口、短问、长问、多轮纠正与首页同口径。
3. 分别触发空结果、能力缺口、服务异常、主动停止,检查标题和操作不同。
4. 复制第 04 题结果检查公司、权限、4.5T、可运营、分组及两种时间说明。
5. 库存车型结果不再推荐同样的车型分组;冷链/普货入口可正常执行。
6. 连续十轮后能找到每条提问,新答案顶部的结论和指标可见;桌面短高度和手机都要测试。
## 独立审阅关注点
- 待交车是业务流程状态;待运营是运营状态。确定性纠正既要改 category,也要清掉旧的状态过滤与错误 unsupported 原因,否则仍可能得到假零或错标题。
- 综合简报的库存集中度必须在库存子集上计算,不能直接用全部资产按车型排序替代“库存最多”。
- 部分回答的未完成事项应单独列出并进入需求记录,不能把“经营好坏需要目标”误报为“交退换查询不支持”。
- 纠正旧答案需能定位原结果。正常把待运营查询切换为待交车时,不应无依据地声称先前答案错误。
- “查询于”是生成时间;没有源刷新证据时 `sourceUpdatedAt` 应为空并以文字说明,不能填当前时间。
- 普货/冷链分组必须复用首页分类,分组汇总与 4.5T 合计可核对。
## 最终开发验证(2026-09-23)
- AI 专项:服务端 assistant 与前端 assistant 共 89 项测试通过,包含原十问、错误计划、真实非零待交车、部分回答、复制范围以及分组示例不误变筛选等反例。
- 类型检查、生产构建、`git diff --check` 通过。构建仍有既有的大体积 chunk 提示。
- 真实 DeepSeek 验证仅提供合成车辆目录与合成汇总:原十问初跑 8 次模型成功、Q1/Q5 本地降级。定位到模型省略展示字段的问题后,Q1 与 Q5 单独复测均由 DeepSeek 正常规划;这不是“一次连续十题全部模型成功”的证据,也不是生产数据验收。
- 浏览器使用真实前端与真实问答路由、合成车辆和内存需求记录:完成原十问连续交互;10 条历史可回看,新回答定位到结论和指标;第 4 问复制包含区域数字、4.5T、可运营、公司、权限、口径及两种时间说明;浏览器无控制台错误。
- 390 × 844 手机视口与桌面视口检查:经营简报四项指标与输入框可见;部分回答、库存明细入口、独立待交车统计可正常显示。最后单独确认 Q7 分别列出缺历史快照、缺成本数据,并记录未完成需求。
- 尚未提交、部署。生产数据及实际部署后的交互须在发布后复测。
全量测试另有一项车辆路由声明顺序断言失败:并行工作将 `/list` 从 `app.get` 改为 `app.on(['GET', 'POST'], ...)`,原测试仍仅扫描 `app.get`。它不属于本轮 AI 修改,未覆盖或撤销该并行工作;AI 专项 89 项均通过。
本次改动与验证属于该项目的业务语义和展示优化,未形成跨项目知识候选,未修改全局开发规则。
-30
View File
@@ -1,30 +0,0 @@
# 资产 AI 自主组合分析
## 行为
问数分两阶段:先规划并查询,再把已计算的结果汇总交给 DeepSeek 设计展示和下一步交互。简单问题通常一次查询规划和一次展示规划;多项结果共用一次展示规划调用。复合问题由 DeepSeek 在一次 JSON 规划中拆成最多四项不同的查询,并选择指标卡、图表、统计表或明细;数据由既有后台查询和计算产生。模型不会生成可执行的 SQL、HTML 或 JavaScript。
例如“华东的4.5T库存有多少?待交车占库存多少?按城市展示库存分布,并列出哪些异动车辆即将到期”,可组合库存指标、城市库存图和即将到期异动明细。待交车、异动已包含在库存内,占比的分母是完整筛选范围的库存,不受图表 Top N 截断影响。零分母时明确不计算占比。
AI 根据问题与实际结果,自主决定信息块顺序、重点指标、柱图/环图/折线、表格列和下钻目标。多项分析可由 AI 选择分页切换或连续排列;前端保持 Web/H5 的紧凑尺寸与统一操作区。切换项目同时切换追问范围;点击分组原位更新所属项目,其他项目保留。每项均有自己的服务端会话结果 ID,可独立下钻、返回、导出和反馈。复制整份回答包含所有项目及其统计范围与口径。
无法生成有效计划的部分列入“尚未回答”;数据源执行失败的项目显示错误和单独重试入口。健康项目照常返回。缺口沿用原有需求记录存储,保存失败会如实提示。该记录用于产品需求改进,不会自动训练或修改模型。
## 协议与维护
规划层沿用单项 `plan`,可选 `queries: [{id,title,question,plan,display}]`;每项独立规范化和验证,避免整句的一个业务状态覆盖全部项目。普通单项响应维持兼容格式。
结果层增加 `sections: [{id,title,question,display,result?,error?}]`。每个成功的 `result` 仍使用既有结果协议。父分析和子分析均保留各项汇总上下文 `analyses`,供“第二项”“这些待交车”等追问选择对应分析。AI 下钻入口按服务端会话保存的目标查询执行,并绑定实际点击的分组或日期。每次下钻完成后重新设计展示和下一步入口;不再走固定的维度遍历顺序。
所有项目使用同一轮授权后的车辆快照。交还车事件仍按既有数据源查询;不同数据源不承诺事务级同时快照。导出会按当前权限重新查询,数据可能随业务更新变化。模型只选择现有数据与计算能力;财务损失、历史库存等未接入能力仍不能生成真实结论。
展示协议为 `presentation: {title, blocks, interactions}`。`blocks` 是有序的语义信息块,可配置 summary、narrative、metrics、chart、table;指标、列和图形序列绑定已有结果字段,事实数字由后台数据提供。叙述支持 `{{metric:指标名称}}` 引用。`interactions` 保存 AI 生成的目标查询和操作名称,客户端只提交入口 ID 与点击的数据点,后台再按当前权限查询。
AI 展示规划不可用时,保留查询结果并回退基础展示,明确提示回退。停止生成会取消后续展示规划。展示定义本身不会直接执行浏览器脚本,也不会生成数据库查询语句。
## 验证
- `src/server/routes/asset-assistant/composition.test.ts`:复合查询的授权范围、首项/次项故障隔离、单次车辆快照、跨项上下文、子项下钻、无效计划部分降级、占比分母和切换区域。
- `src/server/routes/asset-assistant/planner.test.ts`:自主规划协议、独立业务语义、展示回退及过滤条件继承。
- `src/modules/assets/assistant/conversation-presentation.test.ts`:子项原位替换、保留其他项目、历史范围恢复。
- 已对真实 DeepSeek 接口使用合成车辆做单次复合规划验证,未使用生产车辆数据。
-41
View File
@@ -1,41 +0,0 @@
# 资产 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 同步。
-53
View File
@@ -1,53 +0,0 @@
# 资产 AI 问答体验验收
本轮目标是恢复自然语言理解和连续分析。模型负责理解与查询规划,权限与业务数量继续由现有后端执行。验收需要分开检查“有没有理解”与“算得对不对”,不能仅以接口返回 200 为通过。
## 自动回归的数据与边界
`src/server/routes/asset-assistant/experience.test.ts` 使用合成车辆、模拟 DeepSeek 响应和内存需求记录,不访问真实数据库或模型。集成测试验证模型决策能穿过会话、查询执行和结果组装,不能证明真实模型对任意表达的理解率。
本轮已通过 17 项路由集成回归,包含五轮会话、澄清前后范围、历史结果分支、排名引用的汇总证据、异常到期筛选、历史流转日期及切回当前库存、模型故障、中文历史库存口径,以及需求记录的上下文可读性。执行命令:`node --import tsx --test src/server/routes/asset-assistant/experience.test.ts`。
## 中文真实问法语料
| 场景 | 连续输入 / 操作 | 应有行为 |
| --- | --- | --- |
| 口语表达 | 帮我瞅瞅华东现在有多少闲着的车,按城市排一下 | 理解库存意图;若“闲着”口径不明确,用简短说明或一次澄清,不要求用户使用固定关键词 |
| 指代和继承 | 查华东库存 → 只看嘉兴 → 这里面哪些快到期了? | 继承华东、嘉兴范围;“到期”可能指异动预计完成时间或合同到期,缺少明确指向时澄清 |
| 澄清恢复 | 查嘉兴库存 → 看下到期情况 → 我指异动预计完成时间 | 澄清不丢掉嘉兴范围;执行异动时间相关分析 |
| 排序与取前几名 | 哪些城市库存最多?只看前三名 → 换成最少的三个 | 正确应用降序、升序与 Top 3;展示截取口径,不能把前三名当作全量总数 |
| 排除条件 | 看华东库存,杭州先不要 → 还是把杭州加回来 | 支持排除和取消排除;展示当前条件 |
| 修改而非叠加 | 看嘉兴库存 → 不对,我想看杭州 | 城市替换为杭州,不把嘉兴和杭州错误叠加 |
| 恢复全局 | 看嘉兴异动车辆 → 重新看全部库存 | 清除旧地区、异常等限制,不残留不可见条件 |
| 任意结果分支 | 看华东库存 → 只看嘉兴 → 回到第一张图,按车型拆 | 从指定历史结果继续,使用华东范围;不能使用最近嘉兴结果 |
| 图表点击联动 | 按区域看库存 → 点击华东 → 按车型拆开 → 点击 4.5T → 列出异动车 | 保留区域和车型;统计与明细可核对;显示异动类型、预计完成时间 |
| 交叉对比 | 华东和华南的库存,按车型对比一下 | 地区与车型双维度;对比当前分布,不冒称历史涨跌 |
| 库存子集 | 这些库存里面有多少异动和待交车? | 总库存包含两个子集;不能将总库存与子集相加 |
| 超期定位 | 异动维修还没结束而且已经超期的有哪些? | 仅包括未完成且超过预计完成时间的车辆,显示明细依据 |
| 时间口语 | 最近一周交退换怎么样?和前一周比一下 → 换成表格 | 固定相同长度比较期间;切换展示不改变时间和比较条件 |
| 车辆指定 | 看一下浙A12345当前是什么情况 | 现有权限内查询对应车辆,不因自然文本被固定词表拦截 |
| 正常零结果 | 嘉兴有待交车吗?(样本中为 0) | 清楚回答 0,保留范围;不标记为未识别或生成失败,不自动进入需求池 |
| 能力说明 | 你能帮我分析些什么? → 库存和可运营库存有什么区别? | 直接说明口径或给可用示例;不把正常帮助对话登记为未满足需求 |
| 缺少数据 | 能算每台车的租赁毛利吗? | 说明当前缺哪些数据;不拿运营率替代毛利;记录实际需求且给出相关可用方向 |
| 原因解释 | 为什么这个城市库存高? | 先给已知分布证据,区分相关性和原因;没有业务原因数据时明确说明,不编造客户流失等原因 |
| 模型异常 | 模拟超时、非 JSON、截断或无效计划 | 简单可识别问题可以规则降级,并明确提示;复杂问题不能静默退成全部资产 |
| 权限一致 | 无权查看的车辆 / 公司 / 历史结果 | 沿用账号权限;模型选择不能扩大可见范围;导出重新核验权限 |
| 需求反馈 | 未支持问题 → 我的需求 → 标记未解决 → 管理员处理 | 保存用户实际需求,状态可追踪;重复点击同一结果的主动反馈只记录一次 |
## 实机验收方法
1. 用真实模型跑口语、修正、澄清、历史分支四组会话,每组至少五轮;保留问题、生成计划和可核对结果。
2. 用图表操作和文字提问交替完成同一个分析,核对筛选条件、总数和明细。
3. 用桌面和手机分别完成输入、停止、重试、切换结果、导出;不得把旧结果误标成当前上下文。
4. 检查需求池:正常帮助、澄清和零匹配不能污染需求池;不支持的真实需求应可读、可归类。
5. 报告分别列出通过、失败及未验证项,明确真实模型验证与模拟集成测试的区别。
## 本轮验证结果(2026-09-23)
- 全量 176 项自动化测试通过,TypeScript 检查与生产构建通过;含 22 项规划器测试、17 项会话路由体验测试。
- 真实 DeepSeek Flash 使用合成车辆验证口语库存、车型/型号区分、正负筛选、库存范围重置、Top N 指代、宽泛异常分析、异动详情和历史/预测能力说明。发现的理解偏差修复后重新调用验证通过;这是有限样本验收,不代表所有自然语言均已覆盖。
- 浏览器合成数据验证桌面 1440×960 与手机 390×844:总库存 4 → 华东 3 → 嘉兴 2 → 取消城市恢复 3;异动 1 台并保留类型和预计完成时间;帮助后可继续提问,旧回答可折叠。手机无页面横向溢出,控制台无错误和警告。
- 未进行生产部署、生产数据库需求表建表验证或长期真实用户效果评估。本轮保留工作区修改,未提交、推送或发布。
开发复盘:问题根因是封闭词表先行拦截、模型只负责展示、会话没有传递实际问答及结果上下文。改为模型完整规划并用真实中文多轮样本验证。本轮证据属于项目改造,不新增全局开发知识候选。
-23
View File
@@ -1,23 +0,0 @@
# AI 问数全量记录
在部署包含本次代码的服务之前,执行增量建表(只创建审计表,不修改业务数据):
```sh
node --import tsx scripts/migrate-ai-questions.ts # 只查看 SQL,不连接数据库
node --import tsx scripts/migrate-ai-questions.ts --apply # 使用部署环境 DB 配置执行
```
表:`bi_asset_ai_questions`。所有已登录、通过参数校验的 `/ask` 请求都先 INSERT,再执行查询;不按问题去重。
未登录、空问题、非法请求体不作为用户提问保存。输入中的凭据会脱敏。没有自动清理策略;旧的需求缺口表仍按原有 90 天规则维护。
- `event_type`:question 首次提问、followup 连续追问、action 下钻操作、feedback 未解决反馈。
- `business_types`:JSON 数组,使用既有计划分类(inventory 库存、operating 运营、pending 待交车、abnormal 异动、flow 交还替换等);综合问题可以有多个分类,无法完成理解时为空数组。
- `outcome`:processing 处理中、success 成功、empty 无匹配、partial 部分回答、clarification 待澄清、help 使用说明、unsupported 暂不支持、failed 失败、cancelled 取消、rejected 拒绝执行、feedback 用户反馈。
- `reason`:失败/拒绝/缺口原因代码,例如 busy、session_expired、generation、unsatisfied。
- `actor_id` 是实际操作者;`user_id`、`login_name`、`user_name` 是当前数据权限用户;模拟视角另有 `is_preview` 与 `preview_session_id`。
- `question` 保存接受的完整问题(最多 2000 字符,敏感凭据脱敏),`action_json` 保存下钻参数。会话、上级结果、当前结果、公司和权限级别分别关联。
- `answer` 保存回答摘要、提示和子分析摘要,不复制车辆明细数据;另存引擎、开始/结束时间、毫秒耗时。
初始写入失败返回 503,不继续执行无记录的查询。结束更新失败重试一次;原始问题仍在数据库,回答提示“处理结果暂未保存”,服务只输出记录 ID,不输出问题或数据库错误。进程突然终止或结束更新失败的记录保持 processing,可通过 started_at 识别长期未完成,不能将其算作成功。
模拟用户提问同样记录,但不新增需求缺口反馈,也不改变原有只读业务权限。没有新增公开查询接口;数据库查看权限沿用现有管理制度。新表不能补回此前仅在内存中保存的历史问题。
-36
View File
@@ -1,36 +0,0 @@
# 资产 AI 会话分析与需求反哺
## 会话能力
- DeepSeek Flash 理解完整自然语言、当前分析状态、近期问答和可回到的历史结果,生成完整统计方案。支持口语、纠正、否定、切换主题和连续追问。
- 内部程序执行筛选和计算;图表点击、文字追问、历史结果分支共享同一分析条件。澄清后补充条件仍承接最近有效的分析范围。
- 区域、城市、车型、型号、部门、运营状态支持多层下钻和双维度交叉统计;支持排除条件、指定车辆、排名和排序。Top N 仅限制分组展示,总量、占比和明细仍基于完整筛选范围,界面明确显示口径。
- 库存包含异动和待交车,子集不重复累加。支持超预计完成时间、未来 7 天到期、未填写预计完成时间的异动车辆定位,明细保留异动类型与预计完成时间。
- 交退换车按日趋势支持上一等长周期比较,单次最多 93 天;替换交车包含在交车中,基期零不计算增长率。资产历史快照、成本、预测等数据能力尚未接入,说明原因并给下一步建议。
- 展示包含指标、结论、图表、统计表、异常入口、车辆明细、导出。历史回答默认折叠,可展开或作为追问起点;当前范围可修改、清除或另起话题。
## 模型接入
按本轮产品要求,停用此前新增的封闭词表、未知文本拦截与实体代号投影。有 `DEEPSEEK_API_KEY` 时默认启用模型;显式 `ASSET_AI_EXTERNAL_ENABLED=0` 才使用本地规则。Compose 默认值已改为 1,既有容器如果显式设置了 0,需要重新配置并部署。
模型请求包含当前问题原文、最近会话文本、结构化分析条件、历史结果候选、当前结果的分组与指标和当前可见车辆的维度目录;问题明确提及某辆车时也包含匹配的车辆标识。历史回答和当前结果上下文包含之前计算得到的汇总数值。**这些内容会发送到 DeepSeek API。** 没有将完整数据库、轨迹或业务台账行打包发送;模型不执行 SQL。正常统计结论仍由内部确定性程序产生。
保留应用既有登录与数据权限、服务端密钥及查询执行机制。界面显示本次使用 DeepSeek 还是本地规则;模型超时、坏 JSON 或服务异常时给出降级提示,不把规则结果伪装成 AI。不会为本轮额外增设安全审批或词表限制。
## 需求反哺
未识别、暂不支持、执行生成失败会自动记录;每个结果也可标记“未解决”。正常帮助、针对性澄清和零匹配数据不作为需求失败。成功的本地降级只显示提示,不冒充理解能力完整的模型回答。
内部 MySQL 表 `bi_asset_ai_gaps` 保存未完成的问题文本(最多 600 字、常见 Key 格式隐藏)、原因、用户、次数和处理状态,不保存完整会话。按用户、问题与原因去重;用户对同一个结果重复点击“未解决”只记一次。普通用户查看本人记录;沿用反馈管理角色查看全部、设置待评估/已纳入计划/已解决/暂不处理。已解决需求再次出现会重新打开。
最近出现超过 90 天的记录会清理。数据库不可用时明确显示未保存。需求记录用于产品改进,不自动训练模型,不建立长期个人记忆,不向模型同步需求池。
## 部署与会话生命周期
环境变量和 Portainer 操作见 [部署说明](portainer-deepseek.md)。需求表复用现有 MySQL 连接,首次访问自动创建;预建 SQL 在 `src/server/routes/asset-assistant/gaps.ts`。表不可用不影响正常统计。
会话仅在应用进程内保存,闲置 1 小时失效;每用户最多 10 个会话,每会话最多 100 步。重启后可用原问题开启新对话,但不能恢复旧的分析范围。多副本需要粘性路由或共享会话存储。取消本轮新增的每日/每分钟问数额度,仅阻止同用户同时重复执行请求。
## 验证
自动化场景覆盖自然语言规划真实影响查询、连续纠正和排除、澄清上下文、历史分支、Top N 统计口径、异常期限、清除筛选、帮助与零结果、能力缺口与反馈、模型故障降级和现有权限。见 [体验验收语料](asset-ai-experience-cases.md)。浏览器与真实模型探针均使用合成车辆,不向业务库写测试记录。
-60
View File
@@ -1,60 +0,0 @@
# 单车状态问答优化与验收计划
## 问题与目标
用户询问“沪A57850F 这辆车现在是什么状态”,现有结果展示单车总量、运营率,并出现把数量描述为状态的回答。本轮让回答先满足单车事实查询,再按需要展示证据。截图仅作为问题证据,不作为当前车辆数据源。
预期示例(以实际查询值为准):沪A57850F 当前运营状态为「租赁」,不属于库存。随后补充城市、部门、车型及必要异动信息。不得把台账归属城市说成实时定位,也不得把未标记异动推断为车辆无故障。
## 实施范围
1. 区分单车事实问题与总量、占比、分布、趋势问题。单车状态查询优先返回状态,不因结果仅一行便把所有统计请求转成状态回答。
2. 自然语言中的事实由授权查询结果提供,绑定可校验字段;缺失字段如实处理。保留权限校验和既有查询边界。
3. 单车状态不展示无关总量、运营率和车队下钻建议。模型展示失败时仍有准确、简洁的默认答案。
4. 连续追问保持目标车辆;明确切回全部资产时解除单车范围。保留原有统计能力。
## 分工
- 主代理:范围与计划、集成检查、验收结果汇总。
- 开发子代理:查询/结果/展示实现、必要回归测试。
- 验收子代理:独立场景审查、执行验证、问题复核。
## 验收矩阵
| 场景 | 通过条件 |
| --- | --- |
| 指定租赁车辆状态 | 首先给出真实租赁状态,不出现“1台状态”或默认运营率 |
| 库存、异动、待交车车辆 | 使用各自真实字段,不混淆待运营与待交车 |
| 字段缺失 | 不虚构状态、归属、异常原因 |
| 无匹配或无权限 | 如实说明当前可见范围未找到,不泄露不可见车辆 |
| 多车结果 | 不擅自选首车作为唯一答案 |
| 明确询问运营率/数量 | 保留统计结果,即使筛选后仅一辆车 |
| 单车连续追问 | 沿用正确目标,并再次基于真实查询作答 |
| 回到全部资产 | 清除单车限制,恢复统计 |
| 模型失败 | 默认答案仍基于真实字段,保留必要失败提示 |
| 既有分布与复合分析 | 无统计口径、权限和结果渲染回归 |
## 验证证据要求
记录相关测试、TypeScript 检查和构建的实际执行结果。模拟模型测试、真实模型调用、浏览器验证分别标注,未执行项不得写成通过。本轮不包含生产部署。
## 实现决定
单车事实查询采用 `responseMode=vehicle-status`。服务端基于当前授权查询的真实字段直接生成状态结论及台账资料,跳过展示模型。无需扩大明细外发范围,也不依赖展示模型正确理解“状态”与“数量”的区别。
单车显式统计仍进入统计流程,但明确指定的车辆身份需要约束最终查询,避免模型遗漏车辆筛选后误答全量。否定、重置与复合问题须按各自范围处理,不能简单将出现的所有车牌都当成正向筛选。
开发前基线:`npm test`,285 项全部通过。
## 最终验收结果(2026-09-23)
- 独立验收子代理执行 `npm test`:301/301 通过,较基线新增 16 项(独立路由验收 13 项、开发回归 3 项)。
- `npm run lint`、`npm run build`、`git diff --check` 均退出成功;构建保留已有的大于 500KB 分块提示。
- 独立路由验收确认租赁、异动、待交车、未知车辆、显式统计、继承、否定、重置及复合问题。测试使用合成车辆和模拟模型,通过实际 Hono 路由执行。
- 验收中发现并修复:模型漏掉单车身份后误答全量;未知车辆查询扩大范围;状态和统计子项在旧去重逻辑中被合并。
- 复合用例分别核对同车状态与 100% 运营率,以及单车状态与合成全公司 50% 运营率。数字仅为测试样本,不代表真实业务数据。
- 未执行真实 DeepSeek、真实业务数据库或浏览器视觉验收;单车状态路径直接绑定后台事实,不需要展示模型调用。
已知边界:排除指定车辆后需要选择超过 100 台车辆时返回澄清;当前实现不新增批量排除协议。只回答当前台账事实,不推断实时位置与故障原因。本轮修改保留在工作区,未提交、推送或部署。
开发大脑结束复盘:执行 DB-C-003;本轮证据为项目内修复与本地测试,尚不足以形成跨任务可复用知识记录,无可沉淀候选,未触发知识仓写入。
-101
View File
@@ -1,101 +0,0 @@
# 故障管理
2026-10-13:故障管理为左侧独立业务入口 `/asset#faults`,与资产管理并列。首页展示四项总统计及故障趋势统计,不展示旧版维度排行或常驻故障明细。点击任一卡片进入多维分析,再进入故障明细。
## 四项指标
| 指标 | 口径 |
| --- | --- |
| 今日新增 | `fault_report_time` 在北京时间今日自然日内 |
| 本日解决 | 状态已归档且 `archived_at` 在北京时间今日内,与提报日无关 |
| 当前在办 | 全部待处理、处理中故障,不限制提报日期;按最新确认,在办 = 待处理 + 处理中 |
| 累计台账 | 全部有效故障台账,包括归档、未闭环和其他状态 |
「当前在办」卡片以“待处理 + 处理中”展示构成,逾期以总量旁的“逾期”独立标记,均使用故障多维分析与故障明细下钻。按最新确认,在办仅为待处理与处理中之和,挂起保留在累计台账;旧兼容接口的全部未闭环口径保持不变。
- 待处理/处理中:按 OneOS 标准化任务状态分别统计,兼容待处置、未处理、处置中等历史值。
- 逾期:待处理或处理中,且保存的 `deadline_at` 严格早于本次数据时间。按北京时间解释截止时间,挂起和已归档不计入;无截止时间不自行以提报时间或固定 30 天推算。与 OneOS `selectSupervisorRows.overdueCount` 汇总口径一致,存储的截止时间包含业务端 SLA 配置及挂起顺延结果。
- 逾期是当前在办的重叠子集,不与待处理、处理中相加。下钻明细不展示处置截止时间,也不提供该字段的统计维度;该字段仅保留用于逾期计算。
- 四张卡片使用统一紧凑高度:桌面 164px、移动端 132px。
所有数量以故障主键去重、单位为“单”;同车多故障分别统计。有效范围为 `vehicle_fault_manage.del_flag='0' AND is_submit IN (0,1)`。保留退出运营、缺失车辆关联的历史故障;缺失日期不以创建时间补齐。
## 下钻
在资产管理九个维度(车型、型号、区域、城市、部门、业务负责人、客户、资产用途、运营状态)基础上,增加独立的「运维处置人」「车牌」「故障工单状态」,共十二个维度,「运维处置人」固定为第二个维度选项,「车牌」为第三个。车牌取故障台账车牌,同车多故障按故障单计数,缺失车牌归入「未关联车辆」。支持组合分组、多选筛选、合并单元格、组内小计、排序、筛选此组、故障明细搜索和分批加载。运维处置人与业务负责人独立,按人员 ID 分组,同名不同 ID 不合并;多人共同处置分别计入各人,总数、上级小计和明细按故障去重,人员行不可直接相加。
车辆相关维度使用车辆当前资料,运维处置人取故障或关联工单的当前处置分配。城市使用 OneOS `vehicle_info.operation_city` 对应的城市,不使用车机 GPS;区域采用 Asset BI 的宏观区域映射。客户/部门/业务负责人取每车最新 `vehicle_lease_order_record`,按 `last_delivery_time DESC,id DESC`,型号与车型复用资产管理的别名和分类函数。无匹配资料保留未知组。客户键采用散列,展示名称脱敏,不因相同脱敏名称合并不同客户。
明细第一列主显示车牌、辅显示故障单号,第二列为运维处置人;其后依次展示故障描述、状态、提报时间、归档时间、运营城市、客户 / 部门。处置人来源优先级:有效来源任务工单多人→任务工单单人→故障多人→故障单人或历史姓名;用户 BIGINT ID 保持字符串精度,停用/删除人员保留历史姓名。
## 权限与接口
- `GET /api/asset-faults/overview`:北京时间日期、数据时间、四项总数。
- `GET /api/asset-faults/analysis?metric=reported|closed|open|waiting|processing|suspended|overdue|ledger`:所选指标的授权记录及分析单元,供前端在同份数据上分组和下钻。
- 旧 `GET /api/asset-faults` 查询接口保留兼容,首页不再使用。
接口以只读一致性事务读取数据,不修改 OneOS 业务表,不运行定时任务或迁移。汇总及下钻先应用既有全量/部门/本人权限;历史故障不继承公共库存的权限放宽规则;缺车辆资料仅全量权限可见。无共享响应缓存,HTTP `no-store`。401 未认证、400 无效参数、503 查询失败。首页和下钻分别读取最新数据,实时变化可能造成短暂数量差异。
源码依据(`oneos/ln-asset-management`):`FaultLedgerStatus`、`FaultLedgerQuerySupport`、`FaultManualCreateSupport`、`FaultHandlerAssignmentService.resolveForSupervisorBatch`、`VehicleFaultDispositionServiceImpl.archiveNow`、`VehicleFaultDispositionMapper.xml`、`VehicleLeaseOrderRecordMapper.xml`。
## 验证
故障模型与路由测试覆盖北京时间午夜边界、跨日归档、全部日期在办、累计范围、权限隔离、客户脱敏、同车多故障、资产维度映射及总数与明细一致。同步运行导航、资产分组/筛选/合并单元格回归、TypeScript检查、生产构建及桌面/手机 Playwright 交互验证。仅本地开发,未部署。
## 故障趋势统计
复用交还车的日期选择器,默认北京时间近30天,支持自选日期(最多370天)、按日/按月切换。以 `fault_report_time` 划分新增批次,统计同一批故障截至查询时刻的已闭环(已归档)和未闭环(其他状态,含挂起)。不将该日归档的其他批次故障混入,归档时间可在提报区间之后。
堆叠柱总高度为新增数,新增 = 已闭环 + 未闭环。零新增周期补零;无提报时间不进入趋势;月聚合严格保持起止边界,不扩展整月。每日/每月统计表按新日期在前展示新增、已闭环、未闭环和闭环率(零分母显示横线)。汇总与周期数字均可下钻到故障多维分析及故障明细。点击柱图内已闭环 / 未闭环色块,按对应日或月和状态下钻;趋势下钻默认按「运维处置人 → 故障工单状态」分组,重置也恢复这两个维度,按月仍保留所选日期边界。
接口 `GET /api/asset-faults/trend?start=YYYY-MM-DD&end=YYYY-MM-DD&granularity=day|month` 返回区间汇总与周期序列。`GET /api/asset-faults/trend-analysis` 使用同样区间及粒度,可附 `period=YYYY-MM-DD|YYYY-MM` 和 `status=all|closed|unclosed`。两个接口均先应用原有数据权限,范围/粒度/周期校验在查询数据库前执行。总览和下钻按请求分别读取最新数据。
## 故障运维周报
周报复用故障处理趋势的日期选择器,默认业务周(周六至周五),支持自选最多370天。按最新要求仅展示「新增故障」「解决故障」两项区间合计、日期倒序的每日数量及表尾合计,均可下钻。汇总下方增加每日折线图,蓝色实线表示新增故障,绿色实线表示解决故障,与日期选择器、每日统计表使用同一份数据;补齐零值日期、纵轴从零开始,保留表格提供精确数量与下钻。
- 新增故障:按 `fault_report_time` 当日计单。
- 解决故障:已归档故障按 `archived_at` 当日计单,不受提报日期限制。
已移除接收处理汇总、每日列、说明及故障明细中的接收时间列。现有接口暂保留接收指标兼容能力,页面不使用;不更改业务数据。
接口:`GET /api/asset-faults/weekly-report?start=YYYY-MM-DD&end=YYYY-MM-DD`;下钻 `GET /api/asset-faults/weekly-report-analysis` 传 `metric=reported|closed`,可附 `day=YYYY-MM-DD`。所有计数和下钻均先应用既有业务数据权限,沿用只读事务及 HTTP no-store。
页面时间标签统一为「解决时间」,底层仍取 `archived_at`,统计口径不变。周报折线图仍按时间正序,采用平滑实线、顶部图例、浅网格与统一悬浮提示。
故障工单状态独立于车辆运营状态:已归档为「已闭环」,其他状态(含待处理、处理中、挂起)均为「未闭环」,与故障处理趋势口径一致。所有故障下钻入口均可按该维度分组、多选筛选、查看明细。
所有下钻明细按提报时间倒序展示,无提报时间排在末尾;同一提报时间按故障 ID 倒序稳定排序。先筛选、排序,再分页,搜索和加载更多保持该顺序。
客户展示:已关联车辆且当前运营状态为库存、客户为空时,客户分组、筛选和明细显示「库存车」(独立键 `inventory-unlinked`);非库存或缺失车辆资料的空客户仍为「未关联客户」。有客户名称时仍按原客户展示和脱敏。此调整不改业务数据及权限。
下钻维度和筛选按入口保存在当前页面内存中;关闭、误点遮罩或 Esc 后重新打开恢复已应用设置。各入口互不覆盖,桌面与手机共用。点击重置恢复该入口默认维度并清空筛选;刷新整个页面后全部清除,不写入 localStorage 或跨刷新存储。每次重开仍重新请求实时数据。
## 报表导出
故障处理趋势和故障运维周报标题右侧均提供「导出 Excel」。导出当前已加载且经过权限过滤的统计快照;日期范围、趋势按日/按月粒度与页面一致,不重新查询或扩大数据范围。加载中及请求失败时禁用导出,生成文件期间显示进度状态并防止重复点击,失败可重试。
- 趋势:提报日期/月份、新增故障、已闭环、未闭环、闭环率,包含全部统计日期,按日期倒序。
- 周报:日期、星期、新增故障、解决故障,按日期倒序;不导出接收处理指标。
- XLSX 包含带标题、范围、数据时间、汇总指标的统计表;当前有图表时附加「图表」工作表,以图片保存页面图表快照并保持比例。图表为静态快照,修改统计表不会联动图表。
- 统一深色表头、指标配色、交替底色、今日浅色标记;真实 Excel 日期和数字格式、合计及闭环率公式、冻结表头、筛选与打印设置。ExcelJS 按导出操作动态加载。
## 风险与服务分析总览(2026-10-13)
故障管理按最新确认保留「管理总览 / 多维分析 / 故障明细」三个页签。管理总览与处理效率合并,恢复原有页面及统计:今日新增、本日解决、当前在办(待处理 + 处理中,逾期为子集)、累计台账,沿用 `/overview` 接口;下方直接展示原有故障处理趋势与故障运维周报。趋势默认近30天,周报默认周六至周五,分别选择日期、导出和下钻。管理总览不受其他页签的业务筛选影响。多维分析、故障明细保留新增分析能力及统一日期、区域、客户、品牌车型查询;更多筛选包含城市、业务部门、运维处置人、故障程度、故障部位、库存状态,点击查询生效。
- 期间新增故障:所选提报日期内的有效故障。卡片及导出仅展示期间新增总量,不再拆分提报来源。对比上期采用紧邻本期、天数相同的区间;上期为零时显示无可比基数。
- 受影响车辆:同批故障按关联车辆 ID 去重,缺 ID 时使用故障车牌;两者都缺失的不计车辆数,单列数据完整性提示。车辆跨组可能重复,不直接累加。
- 当前在办:已应用业务筛选下,所有日期的待处理 + 处理中。逾期为其子集;最长逾期按截止时间到数据时间的天数向上取整。
- 区域、等级、客户、车型分布和车型×部位矩阵:按本期提报队列统计,闭环状态截至更新时间。一单多部位分别计入单元格,车型合计按单去重。
- 客户影响表:本期影响车辆去重;同一客户的当前在办不限提报日期。库存空客户单列库存车。仅展示本期存在故障的客户组。
- 重点跟进:只展示 L1-特急且未闭环(状态非已归档,含挂起等)的故障,沿用权限及业务筛选,不限制提报日期。逾期优先,同优先级按提报时间倒序,最多展示5单;统计标签使用同一 L1 未闭环范围,没有符合条件的故障时显示空态。“查看全部”始终可用,进入当前权限内的全部故障台账,清除业务筛选和搜索条件,不限等级、状态及提报日期。完整明细可切换本期、在办、逾期及累计台账,搜索车牌、单号、描述或运维处置人。
新增只读字段:`fault_level`、`fault_categories`、`solve_process`。等级映射经 OneOS 字典核对:1=L1-特急、2=L2-紧急、3=L3-一般、4=L4-提示;缺失等级/部位独立显示未标注。处置记录使用 `solve_process`,不将 AI 流转字段 `process_status` 当作业务处理进度。没有同期服务车辆规模、实际停运和修复时长依据,发生频率禁用并提示缺少分母,停运和修复时长提示待补数据。
新增接口:
- `GET /api/asset-faults/dashboard?start=YYYY-MM-DD&end=YYYY-MM-DD`:授权范围内汇总、筛选项、分布、交叉矩阵、重点跟进和完整性信息。
- `GET /api/asset-faults/dashboard-analysis`:同样日期和业务筛选;`scope=focus|cohort|open|waiting|processing|overdue|ledger|reported|closed`。focus 为全部提报日期的 L1 未闭环故障;cohort 为提报日期队列,reported/closed 为北京时间今日,其他为全部日期当前状态;可附 `id` 查看单条记录。
- 趋势、周报及其下钻接口同时支持上述业务筛选。所有接口先执行权限筛选,筛选项也仅来自授权记录,保持 no-store,不写业务数据库。
总览导出新增 6 张 Excel 工作表:管理总览、区域分布、故障程度、客户影响、车型分布、车型与部位。导出已加载统计快照,包含日期、业务筛选、数据时间、冻结表头、交替底色和数字格式。趋势、周报的既有导出保留,并增加业务筛选说明。
-1
View File
@@ -1 +0,0 @@
period_month,scheduled_for,captured_at,definition_version,source_reference,vehicle_id,plate_number,vin,operation_status,vehicle_status,subject_org,department_name,manager_id,manager_name,vehicle_type,model,brand,color,ownership,rent_company,province,city,contract_no,customer_name,contract_org,project_name
1 period_month scheduled_for captured_at definition_version source_reference vehicle_id plate_number vin operation_status vehicle_status subject_org department_name manager_id manager_name vehicle_type model brand color ownership rent_company province city contract_no customer_name contract_org project_name
-179
View File
@@ -1,179 +0,0 @@
## 当前展示口径(2026-10-24 更新)
按用户最新要求,停止展示交还记录推算的历史资产数据。正式路由只提供当前实时数据和真实归档快照,已取消 `ASSET_HISTORY_RECONSTRUCTION_FILE` 的接入;即使保留旧环境变量,也不能通过趋势或明细接口读取回溯文件。旧核对表和私有回溯文件保留用于离线核查,不删除、不写入正式快照。
2026年10月前的缺失期间返回空值,不补零、不提供估算明细。页面仅显示一句提示:“历史库存状态快照缺失,核对中,暂不展示。” 当前10月继续读取实时数据;今后只有采集成功的真实快照才能归档展示。服务端仍返回存储和采集状态供运维检查,页面不再重复展示部署及回溯说明。
本地验收服务同步停用回溯文件。生产迁移、部署和自动采集仍未执行;该展示调整不等于采集已经启用。下文的回溯回填章节保留为历史实施记录,其环境变量接入和页面展示说明已被本节取代。
# 资产历史趋势
资产概览顶部指标之后新增历史趋势。月度选择年份,年度每年一个年末点;运营和库存为堆叠柱,总数为折线。下方期间按钮支持键盘和触摸选点,月度明细可分类、搜索车牌/VIN及分页。
## 口径及时间
- 复用资产仓储 `MAIN_SQL`、`transformRow`、`isOperatingVehicle`、`isInventoryVehicle`。
- 总数为未删除、未退出运营的车辆;运营为租赁/自营,库存为可运营/待运营;异动、待交车不另加。
- 每天 `Asia/Shanghai` 23:55 为计划时点,归属当天;只允许在 23:55:00(含)到 23:56:00(不含)内开始读取。每天独立保存完整逐车状态;月度展示月末当天、年度展示12月31日快照,不累加每日存量。
- `captured_at` 是程序记录的实际开始读取时间,`scheduled_for` 是计划时点;两者不宣称完全相同。
- 一致性读取可在窗口内开始后继续执行;持久化失败最多重试三次,始终使用同一份已取得数据,不重新读晚到的台账。
- 当月在截止前显示实时点;年末前当年年度点显示实时点。截止后只能显示归档、归档中、失败或缺失。12 月快照直接用作年度年末点。
- 实时点每五秒走 `/live`,不重读全部历史;切换年份/公司/模式、手动刷新、跨月或归档边界时更新历史。
- 实时数据沿用现有资产仓储的五秒缓存,实时 `asOf` 为本次服务端查询时间,不代表源台账所有记录的最后修改时间;固定快照绕过该缓存。
- 缺失及未来数量为 `null`,合法零数量为 `0`,不插值、不跨缺失点连线。比较只取相邻期间;一月使用上年十二月作为基期。
- 默认年度显示最近五年;更早有归档或导入时自动扩展年份。月度允许查询 2000 年至当前年份。
## 权限及历史字段
本功能是资产存量统计。实时和归档都复用 `filterAssetVehiclesByPermission`:库存对账号公共可见,其余车辆沿用全量、部门或个人权限。公共库存按所查询时点的运营状态判断;历史公司、部门和负责人取快照,角色及个人身份别名取当前认证系统。公司筛选在权限过滤范围内取 `subjectOrg`。
不连接今日车辆列表补字段,不因车辆退出、删除或转部门丢失历史。当前来源的部门权限使用部门名称,快照如实保存该名称,不虚构来源没有的部门 ID。未来源系统若增加稳定部门 ID,需版本化升级口径。
归档汇总从授权后的同一批逐车数据计算,不返回批次全量计数。明细客户名称复用既有脱敏。历史公司仅出现在有权限的相关历史数据中;顶部公司列表中的历史独有公司标注“历史主体”,不改变现有实时公司接口含义。权限视角切换会由现有认证容器重建页面并取消旧请求。
## 存储
`bi_asset_history_settings.enabled_at` 保留旧采集首次启用时间;`daily_enabled_at` 保存每日采集首次实际启用时间。重复启用保留首次时间,升级前的日期不会被标记为漏采。
`bi_asset_history_batches` 保存期间键、口径版本、计划及实际时间、来源、状态、失败码、全量校验计数及导入追溯信息。`period_key` 与口径版本唯一;新快照使用 YYYY-MM-DD,旧月度 YYYY-MM 键和明细原样保留。月度查询仅选月末日及旧月度记录;同月优先新日快照,但失败或缺失不会遮蔽旧 COMPLETE 归档。正式归档为 `COMPLETE`,失败为 `FAILED`,错过为 `MISSED`。
`bi_asset_history_vehicles` 保存车辆字符串 ID、原始运营/车辆状态、归档分类、公司、部门、负责人 ID 和冻结车辆 JSON。批次内车辆唯一;不以当前车辆表为外键。JSON 保留车牌、VIN、型号、品牌、业务归属等明细信息,排除无关的异动工作详情和待交车附件信息。
采集用 MySQL 同一连接的命名锁防止多实例并发,以只读一致性事务执行原始 SQL,再以独立事务原子发布头表、全部明细和 COMPLETE 状态。窗口外不读源数据。读接口、普通模块导入和只读预览不建表、不采集。读到不存在的表时显示部署提示并保留当前实时点;真实数据库故障返回错误,不伪装成空数据。
归档读取会校验车辆唯一性、分类、明细计数和批次计数,不把损坏的明细显示为合法零快照。已提交但提交确认丢失时,重试先检查 COMPLETE,不覆盖已发布数据。
## API
全部接口经现有认证和权限预览中间件,响应 `Cache-Control: private, no-store`。
| GET | 参数 | 用途 |
| --- | --- | --- |
| `/api/asset-history/trend` | `mode=monthly\|annual`、`year`、`subject` | 趋势点、相邻差值、可选年份及相关历史公司 |
| `/api/asset-history/live` | `mode`、`subject` | 仅更新当前实时点,不查历史明细 |
| `/api/asset-history/details` | `month=YYYY-MM`、`batchId`、`subject`、`category=all\|operating\|inventory`、`search`、`page`、`pageSize` | 月度明细,默认 50 台,最大 100 台;页面每页 30 台 |
归档明细入口附带批次 ID,服务端同时核对月份。没有可用快照返回 409,不回退到实时台账。400 为参数错误,401 为未认证,503 为读取或数据质量异常。没有 HTTP 采集、写入和导入接口。
## 迁移与启用
默认关闭:`ASSET_HISTORY_CAPTURE_ENABLED` 只有精确设为 `1` 才启动采集。迁移不等于启用,迁移本身不会写入首次启用时间。
在项目根目录审阅迁移(不连接数据库):
```sh
node --import tsx scripts/migrate-asset-history.ts
```
取得目标数据库迁移授权后,使用受控环境变量配置正确数据库,再显式执行:
```sh
node --import tsx scripts/migrate-asset-history.ts --apply
```
如部分建表失败,修正原因后可重复执行;脚本使用 `CREATE TABLE IF NOT EXISTS`。新建空表不会伪造以前历史。
每日采集升级(2026-10-30):先停止旧版采集服务,执行基础建表迁移(已有表不变),再审阅并执行一次增量迁移:
```sh
node --import tsx scripts/migrate-asset-history.ts --daily
node --import tsx scripts/migrate-asset-history.ts --daily --apply
```
增量迁移把 `period_month CHAR(7)` 改为 `period_key CHAR(10)`,原唯一索引继续约束期间键和版本;新增可空 `daily_enabled_at`。这是一次性 ALTER,不能重复执行。应在目标库完成迁移后启动新版服务并设置采集开关;旧版程序不能直接使用新字段名。迁移不启动采集,也不修改旧归档的计划/实际时间。当前页面仍提供月度和年度浏览,普通日期快照保存在数据库中。
授权启用后,将服务进程环境变量设为 `ASSET_HISTORY_CAPTURE_ENABLED=1` 并按现有流程重启服务。调度器首次成功连接时记录 `daily_enabled_at`,启用前的时点不会追补。如果在当天 23:55 之后才首次启用,即使仍处于该分钟,也从次日开始采集。
按每日采集需求确认日期 2026-10-30,如果在当天 23:55 前成功启用,首个计划时点为北京时间 **2026-10-30 23:55**;实际计划以每日采集首次启用时间和服务时间计算,不硬编码。
保持服务在计划时点在线。停止期间错过的 eligible 日期会在重启时记为 MISSED;已 FAILED 的记录保留诊断标记。记录到启用前没有归档,不能用重启时的实时数回填。日志前缀为 `[asset-history]`,包含日期及 captured/already-complete/locked/failed/missed 结果,不记录客户明细或连接凭据。
建议仅让受控服务账号写快照表;源表仍只需读权限。多实例可启用同一数据库下的同一采集配置,命名锁和数据库唯一约束共同保证幂等。启用开关为进程级配置,`daily_enabled_at` 是历史启用记录,不能仅根据该记录判断某个进程现在是否运行调度器。
管理员可用以下只读 SQL 检查,不通过普通用户接口暴露全量计数:
```sql
SELECT enabled_at FROM bi_asset_history_settings WHERE id=1;
SELECT period_key, scheduled_for, captured_at, completed_at,
definition_version, source, status, failure_code,
total_count, operating_count, inventory_count
FROM bi_asset_history_batches ORDER BY period_key DESC LIMIT 31;
SELECT b.period_key, b.total_count, COUNT(v.vehicle_id) AS detail_count
FROM bi_asset_history_batches b
LEFT JOIN bi_asset_history_vehicles v ON v.batch_id=b.id
WHERE b.status='COMPLETE'
GROUP BY b.id, b.period_key, b.total_count;
```
## 回退
1. 设置 `ASSET_HISTORY_CAPTURE_ENABLED=0` 并重启服务,停止后续采集。
2. 回退应用代码;已有归档表可原样保留,不影响旧代码。
3. 若确需撤销表,先备份并取得数据库删除授权;依次删除明细表、批次表、设置表。默认不删除任何归档数据。
读接口不自动建表,因此回退后也不会被请求重新创建。再次上线可继续读取既有归档;首次启用时间不自动重置。
## 后续逐车导入契约(本次不提供导入写接口)
提供仅表头模板 `docs/asset-history-import-template.csv`。使用 UTF-8 CSV,每行一辆车,同一文件只允许一个年月、一个口径版本和一个统计时点。
必填字段:`period_month`、`scheduled_for`、`captured_at`、`definition_version`、`source_reference`、`vehicle_id`、`operation_status`。空缺车牌/VIN使用空值,不造车辆 ID。ID 均按文本读取。旧版月度导入契约的 `scheduled_for` 是指定年月最后一天北京时间 17:00(保留旧格式,不能作为每日自动采集规则),`captured_at` 必须来自可信历史来源,不能把导入执行时间当作历史读取时间。
其余字段见模板。部门、负责人、主体缺失须显式留空,不能用今日台账补齐;部门/个人权限因此可能看不到未归属的运营车辆,这是数据缺失而非自动扩大授权的理由。展示型号、品牌、原始车辆状态按历史来源保存;历史状态映射复用现有分类函数,不接受退出运营车辆或未知运营状态作为有效存量。归档分类由导入校验程序计算,不直接信任文件中的分类列。
状态映射:`1`/`租赁`、`2`/`自营`属于运营;`3`/`可运营`/`在库`、`4`/`待运营`属于库存;`5`/退出运营及未知值拒绝计入存量。`vehicle_status` 为历史原值,待交车或异动不改变上述归档分类。展示用 `status` 应按现有 `mapStatus` 生成。
后续导入器必须先提供 dry-run:校验日期和时区、来源、未来时间、车辆唯一性、状态、权限所需字段、计数对平及同年月冲突,再原子发布来源为 IMPORT 的批次和逐车数据。`import_provenance` 至少保存文件校验和、来源引用、导入人、导入时间、校验报告与修订原因;不要在日志保存原始敏感明细。
现有 COMPLETE 批次不自动覆盖;相同口径同月冲突应拒绝。修订需新增可追溯版本并明确有效版本切换及跨版本读取策略,不直接改已归档车辆。版本切换功能属于后续导入工作,本次只提供存储空间及契约。
仅三个汇总数字的文件不符合逐车导入规范,不能提供个人/部门历史明细。若将来支持汇总导入,需另设权限和“无明细”模型。
## 交还业务回溯的本地回填
2026-10-19 按用户确认的回溯核对表,将 **2022-06 至 2026-09 共52个月、22,319条车辆月度记录** 发布到本地私有 JSON 文件。原始核查资料截至北京时间 `2026-10-19 14:38:33`。2026-10 未结期不回填,继续走当前实时查询。未向真实数据库建表、插入或更新。
这批数据保留 `HANDOVER_REVIEW_V1` 方法、核对表文件名与 SHA-256、原始核查时间、逐车交还单号、业务时间、分类依据及未判状态数量。车辆 ID 使用字符串,候选车辆的车牌/VIN来自交还单原值;未用当前台账补公司、部门、负责人、客户或未知运营状态。
它是独立的 **RECONSTRUCTED(历史回溯·估算)** 来源,不发布为 `AUTO` / `IMPORT` 正式快照,也不填写月末实际采集时间。`counts` 为 `null`;运营/库存推算数、未分类数、当前已退出子集和来源信息在 `point.reconstruction` 中返回。总数折线保持空白,页面明确显示“总数待核实”。正式归档始终优先,不覆盖已有归档或归档失败诊断。
权限单独收紧:只有当前全量权限且未选择公司时可读取这批估算。它缺少可靠历史主体和个人/部门归属,不能按今天的车辆权限、公司或库存状态补足历史授权。部门、个人及公司筛选继续显示原有正式快照或空白;直接访问回溯明细、篡改批次或公司参数也不能放宽权限。正式归档和实时数据原有权限规则不变。
年度仅复用各年12月的回溯记录。相邻两个回溯点可比较推算运营/库存,不计算总数差值;回溯点与实时或正式快照不作差,防止把不同覆盖范围的差异显示为业务增长。回溯明细支持分类、搜索、分页,汇总与逐车记录在加载前对平;未知归属明确标为“历史未提供”。
显式本地发布命令(没有数据库依赖,默认只校验):
```sh
node --import tsx scripts/backfill-asset-reconstruction.ts --input /private/reviewed-reconstruction.json
node --import tsx scripts/backfill-asset-reconstruction.ts --input /private/reviewed-reconstruction.json --output /private/asset-history/reconstruction-v1.json --apply
```
数据文件格式为 `ReconstructionData`(`src/server/routes/asset-history/reconstruction.ts`)。必须提供来源及文件校验和,每月车辆唯一、逐车分类与计数对齐、参考月份早于原始核查时间。合法的0与未知总数区分。发布使用私有文件权限和原子创建;相同内容幂等,不同内容拒绝覆盖。文件不能放入公开静态目录或源码目录。
启动时显式设置 `ASSET_HISTORY_RECONSTRUCTION_FILE` 为已发布文件的绝对路径;没有该变量时不读取估算数据。不会启用自动采集、初始化快照表或运行其他后台任务。读取结果在进程内缓存;修订时保留原文件和核对表,发布到新文件后更新变量并重启。回退时移除该变量并重启,正式归档及实时数据不受影响。本地验收启动脚本单独注入该变量,回退需同步移除该脚本的配置。
本地验收地址仍为 `http://127.0.0.1:3000/asset#assets`。当前仅本地回填,生产未部署、采集未启用;正式存储迁移及生产回填仍需明确指定目标和生产授权。
## 验证入口
```sh
node --import tsx --test src/server/routes/asset-history/*.test.ts src/modules/assets/history/*.test.ts
npm run lint
npm test
npm run build
```
单元、接口和事务故障测试只用模拟仓储与事务数据库替身,不访问真实数据库。开发启动若加载现有 `src/server/index.ts`,会执行其他模块的后台初始化;验收应使用隔离服务、不导入 bootstrap,并阻断所有数据库连接,避免顺带运行现有后台任务。
## 本次交付验证(2026-10-19)
- 新增 32 项针对性测试,全量 452 项通过;`npm run lint`、`npm test`、`npm run build`、`git diff --check` 均通过。
- 构建仍报告部分现有重依赖分包超过 500 kB 的体积提示;不影响构建,本次不扩展为全站拆包重构。
- 使用已有 Playwright Chromium(当前会话没有 Browser 插件),在 `http://127.0.0.1:3146/asset#assets` 隔离服务验收;测试服务未导入 bootstrap,数据库入口被阻断。该地址为临时验收环境,非生产部署。
- 1440×1200、390×844 验证交互,补充 390×1400 截图。13 组浏览器检查通过:页面身份/非空/无框架错误、月度归档及明细、分页搜索、年度、缺失/未来/零值、公司筛选、请求竞态、实时刷新不重读历史、部门及个人权限、存储未部署、503 重试、原有资产分析、手机触摸及无横向溢出。
- 浏览器无未解释的控制台错误或警告;模拟 503 的错误属于已验证的故障注入。最终验收记录中数据库访问尝试为 0。
- 参考图的卡片、堆叠柱/折线和选中期间指标保留;历史曲线按真实归档缺失留空,不复制示例数值。顶部现有资产样式保留;手机明细使用卡片而非横向表格。
- 迁移审阅命令已运行,未执行 `--apply`。事务、锁、故障和提交确认丢失在隔离数据库替身中验证,未向真实 MySQL 实例执行 DDL 或采集。生产迁移和启用需按上述授权步骤执行。
- 当前交付状态:本地实现完成,未发布,自动采集默认关闭;正式逐车快照导入仅提供契约和模板。用户确认的交还业务回溯另用上述显式本地文件发布入口,不混入正式快照。
- 回溯回填补充验证:新增8项测试,全量460项通过;类型检查与构建通过。52个月运营/库存与核对表逐月对齐,API明细分类数及分页核对通过。1440×1200、390×844完成月度、年度、明细、搜索分页、触摸、焦点关闭、公司范围及缺失月份验收;无未解释控制台错误、无横向溢出。本地只读保护记录的写入尝试为0,采集及后台任务均关闭。
-45
View File
@@ -1,45 +0,0 @@
# 资产管理组件库迁移(历史记录)
> 2026-09-23:本次组件库与视觉改造已按用户要求撤销。资产页面恢复到 9b0980e 的布局和样式,保留 AI 问数入口及库存统计、权限和下钻逻辑修复。以下为撤销前的实施与验收记录。
## 范围与约束
资产管理的总览、库存统计、车型/批次、交还车、部门、区域、客户、筛选与明细弹窗。
- 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/vendor/apple-design/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 条车辆仍分页;区域筛选后折叠摘要和库存计数一致。
+216
View File
@@ -0,0 +1,216 @@
# BI 渐进式重构方案
更新日期:2026-08-07
## 1. 目标
本次重构不重做整套系统,采用可回滚的小批次交付,逐步解决三个核心问题:
1. UI/UX:统一信息层级、筛选方式、状态反馈和移动端行为。
2. 下钻:任何汇总指标都能沿同一统计口径进入可核对的明细。
3. 统计逻辑:指标公式、时间语义、数据来源和数据截至时间均可查询、可测试。
不在当前阶段引入新的 BI 引擎或替换现有 React、Hono、MySQL 技术栈。
## 2. 设计原则
- 先口径,后图表:没有明确公式和数据源的指标不得进入主看板。
- 父子一致:下钻前后的时间、车辆范围、组织范围和数据源必须一致。
- URL 即分析状态:日期、范围、实体和数据源写入 URL,支持刷新、返回和分享。
- 空值不等于零值:接口失败、尚未接入、没有权限和真实零业务使用不同状态。
- 汇总可对账:页面汇总应等于明细全量聚合,明细截断不能影响顶部合计。
- 数据新鲜度显式展示:业务时间与页面刷新时间分开表达。
- 渐进发布:每批只修改一个口径或一条下钻链路,通过测试和真实数据核对后提交。
## 3. 目标信息架构
每个 BI 模块统一为三级信息结构:
| 层级 | 用途 | 页面内容 |
| --- | --- | --- |
| 总览 | 判断经营状态 | 核心 KPI、趋势、结构、异常、数据截至时间 |
| 分析 | 定位变化来源 | 时间筛选、组织/车辆范围、分组排行、归因 |
| 明细 | 核对原始业务 | 车辆、订单、站点、账单或通行记录 |
页面头部统一提供模块名称、简短业务说明和“指标口径”入口。筛选条件位于内容上方,实体下钻状态紧邻筛选区展示,避免用户忘记当前分析范围。
## 4. 模块与下钻矩阵
### 4.1 里程
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
| --- | --- | --- | --- |
| 实时监控汇总 | 车辆详情 | 日期区间、协议优先级、车牌 | 已完成 |
| 考核目标 | 部门/客户归因 | 考核年份、目标、归因维度 | 已完成 |
| 归因分组 | 车辆清单 | 目标、部门或客户值 | 已完成 |
| 每日汇报 | 实时监控/考核目标 | 汇报日期、目标或车牌 | 已完成 |
考核完成率使用目标里程加权:
```text
SUM(completed_mileage_km) / NULLIF(SUM(target_mileage_km), 0) * 100
```
禁止直接平均车辆完成率或考核组完成率。
### 4.2 氢能
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
| --- | --- | --- | --- |
| 站点排行 | 站点每日明细 | 年份、站点、日期区间、车辆范围 | 已完成 |
| 客户排行 | 客户每日明细 | 年份、客户、日期区间、车辆范围 | 已完成 |
| 每日趋势 | 加氢订单 | 日期、车辆范围、站点或客户 | 下钻代码已完成,待数据源恢复后验收 |
成本、收入和毛利必须分开:
```text
成本 = SUM(cost_total)
客户收入 = SUM(fee_total)
客户单毛利 = 客户收入 - 客户单对应成本
```
数据库不可用时返回可识别的 503 状态,页面不得显示派生零值。
### 4.3 电能
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
| --- | --- | --- | --- |
| 总览日柱 | 当日订单 | 日期、全部车辆范围 | 已完成 |
| 月份 | 日期 | 车辆范围、日期区间 | 已完成 |
| 日期 | 充电订单 | 日期、车辆范围 | 已完成 |
电能范围包括“全部车辆、羚牛车辆、外部车辆”。总览使用全部车辆口径,因此总览下钻必须进入 `all`,不能默认切换为羚牛车辆。
综合费用强度使用加权公式:
```text
SUM(fee) / NULLIF(SUM(kwh), 0)
```
零费用订单参与电量和订单数统计。当前月无数据时可以展示最近数据月,但所有月度标签必须显示实际月份,并展示 `MAX(start_time)` 数据截至时间。
### 4.4 ETC
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
| --- | --- | --- | --- |
| 通行费用 | 通行记录 | 日期、车牌、客户、费用承担类型 | 下钻代码已完成,待首次真实同步验收 |
| 账单应收 | ETC 账单 | 账期、客户、支付状态 | 下钻代码已完成,待首次真实同步验收 |
供应商未配置或从未同步时展示接入状态,不使用模拟业务数据填充图表。
## 5. 指标治理
指标目录由 `/api/analytics/metrics` 发布,当前采用版本化合同。每个指标必须包含:
- 唯一且带领域前缀的指标 ID。
- 中文名称和业务解释。
- 单位、聚合方式和时间语义。
- 可审计公式与真实数据源。
- 支持的筛选维度和下钻实体。
指标变更规则:
1. 修改公式、来源或时间语义时提升目录版本。
2. 后端查询、指标目录、页面标签和测试在同一批次修改。
3. 比率类指标必须由分子、分母汇总后计算,禁止平均子级比率。
4. 金额同时标明成本、收入、应收、实收或总费用,不使用模糊的“费用”。
5. 快照、区间流量和数据新鲜度不得混在同一时间标签下。
## 6. URL 与交互约定
- 模块和子页使用 hash,例如 `#electric/overview`。
- 分析上下文使用领域前缀查询参数,例如 `electricDate`、`hydrogenStationId`、`mileagePlate`。
- 不删除其他领域的查询参数。
- 总览进入明细使用 `pushState`,同页筛选调整使用 `replaceState`。
- 浏览器返回、前进和刷新必须恢复筛选与展开状态。
- 图表下钻同时支持鼠标和键盘,交互图形提供可读名称。
## 7. 接口与性能边界
| 类型 | 目标 |
| --- | --- |
| 热缓存汇总接口 | P95 小于 500 ms |
| 普通数据库聚合 | P95 小于 1.5 s |
| 明细查询 | 首屏小于 2 s,超过上限时分页或截断 |
| 并发同参 GET | 前端和服务端均只执行一次 |
| 外部数据源失败 | 返回结构化 503,不返回连接信息 |
查询要求:
- 日期条件使用范围比较,避免对索引列套 `DATE()` 或 `DATE_FORMAT()` 后再过滤。
- 汇总与明细可以并行查询,但必须使用相同过滤条件。
- 明细展示上限与全量汇总分离,截断时明确提示。
- 缓存键必须包含所有影响统计结果的筛选维度。
- 失败请求不得驻留在并发请求缓存中。
## 8. 分阶段路线
### 阶段 A:口径与导航基线
- 已完成版本化指标目录和四模块入口。
- 已完成里程、电能、氢能主要 URL 状态。
- 已完成 ETC 记录/账单视图、日期、搜索和分页 URL 状态,以及超范围页码自动纠正。
- 已统一共享加载、空数据和故障组件的顶层卡片与明细内联边界,消除能源明细中的嵌套状态卡片。
- 已统一电能、氢能和 ETC 日期范围输入;电能与氢能共用快捷区间算法和单一输入事件路径。
- 电能与氢能可根据 URL 日期恢复快捷区间选中态;过期区间自动归为自定义,避免标签与统计日期不一致。
- 已统一电能和氢能车辆范围 segmented control,并区分前端车辆归属状态与后端 `customer` 查询参数语义。
- 已完成并发同参 GET 去重。
### 阶段 B:核心下钻闭环
- 已完成里程考核归因、车辆详情和每日汇报跳转。
- 里程考核年度已纳入 `mileageYear` URL 状态;目标、部门/客户归因和车辆清单下钻继承同一年度,无效年度会按目标真实年度归一。
- 车辆级里程详情继承 `mileageStart`、`mileageEnd` 和数据源优先级;显式下钻日期严格展示所选日,预设区间继续排除尚未完整的当天数据,请求故障显示为不可用并支持重试。
- 里程实时监控的日期、来源、批次、区域、车牌、归属、高级筛选和排序已纳入独立 URL 状态;分享或刷新链接可恢复完整查询,车辆详情前进后退保留列表上下文且不把下钻车牌误写为筛选车牌。
- 已完成氢能站点、客户下钻代码。
- 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。
- 已为电能总览与氢能每日趋势柱图统一 Enter/Space 键盘下钻;氢能有效日期柱提供可读名称、焦点和选中反馈。
- 已完成电能全部车辆、日期和订单下钻。
- 电能每日列表打开新日期订单使用浏览器历史 `pushState`,返回/前进可恢复进入前后的分析状态;筛选调整仍使用 `replaceState`。
- 已完成 ETC 通行记录与结算账单下钻代码、筛选、分页和未同步空状态;待首次真实同步后验收真实数据。
### 阶段 C:数据可信度
- 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。
- 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。
- 里程考核接口区分目标声明台数与当前有效车辆数;两者不一致时页面明确标注缺少或超配数量,并说明完成率、缺口和归因的实际计算范围。
- 里程考核统计主数据已区分加载、真实空数据、首次请求故障和保留旧数据的刷新故障,并提供统一重试入口,接口失败不再显示为零值或空页面。
- 里程考核归因车辆与 7 天趋势已提供独立加载、故障、空数据和重试状态;子请求失败时停止展示“0 台未达标”和“最新日变化 +0”等伪业务结果。
- 里程考核车辆日期侧栏仅展示带目标和日期标识的成功快照;切换日期失败时保留并标注上一成功日期,首次失败不再回退为当前日数据或伪造“0 辆”。
- 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。
- 里程实时监控主列表快照已绑定完整筛选与排序口径;新口径请求失败时隐藏旧 KPI、趋势和车辆,原口径刷新失败时保留并标注旧快照,快速筛选与分页的过期响应不会覆盖当前结果。
- 里程实时监控已将排序维度与 KPI 公式解耦:主值和平均单车始终使用当日/区间流量里程,累计仪表合计保持独立快照语义;平均单车里程已纳入版本 7 指标目录。
- 里程历史区间监控已复用权限过滤前的分钟级基础快照;筛选、排序、分页和并发同参请求不再重复拉取 OneOS 区间数据及车辆元数据,强制刷新仍可绕过已完成快照,失败请求不进入缓存。
- 已完成电能数据截至时间和实际趋势月展示。
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
- 已完成氢能结构化故障状态与重试体验。
- 已完成里程今日仪表快照复用:监控查询和考核车辆当前日明细命中同一分钟级快照,手动刷新生成一致的分页快照;历史日仍使用独立日期缓存。
- 已在各 BI 模块头部提供当前数据源的真实请求状态、失败率和最后成功/失败时间。
- 待恢复氢能数据库后进行总览、站点、客户三层对账。
- 待 ETC 同步后进行通行记录、账单和收款三层对账。
### 阶段 D:部署与安全
- 已将部署清单、氢能和里程连接中的数据库敏感值迁移为必填环境变量。
- 轮换已经进入版本历史的氢能数据库凭据。
- 已提供无敏感值的 `.env.example`;测试、生产值由各环境密钥管理。
- 已拆分进程存活检查与无敏感值的配置就绪检查。
- 已增加基于真实业务请求的数据源最后结果、最后成功时间和滚动失败率监控。
## 9. 每批验收门槛
每次提交至少满足:
1. TypeScript 检查通过。
2. 受影响的口径、URL 或查询测试通过。
3. 生产前端构建通过,服务端入口可加载。
4. `git diff --check` 通过。
5. 桌面和移动端检查无溢出、遮挡或不可操作控件。
6. 使用真实接口核对至少一个父级汇总与子级明细。
7. 不提交本地数据、临时脚本、凭据或无关工作区修改。
## 10. 当前外部依赖
- 氢能数据库连接尚未恢复,真实数据对账未完成。
- ETC 供应商未配置、未执行首次同步;记录与账单下钻代码已完成,但真实数据仍不可验收。
- 氢能连接信息已从当前版本迁出,但历史凭据仍必须在数据库侧轮换;文档不记录任何连接值。
-21
View File
@@ -1,21 +0,0 @@
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.
-56
View File
@@ -1,56 +0,0 @@
# 里程监控结果复用(2026-10-27)
本文为第二阶段历史记录。当前主列表已改用上游区间汇总;最新行为、权限趋势处理及实测见 [区间汇总接入与验证](mileage-summary-integration-2026-10-27.md)。下文的明细缓存和首次耗时描述不代表当前实现。
## 行为
同一服务进程内,按规范化起止日期和有序协议优先级缓存 OneOS 区间明细、结束日数据及对比日数据。有效期为成功获取后 5 分钟,最多保留 4 组,新增超限时淘汰最早写入项;相同在途查询合并。只在成功完成后写入缓存,失败可重试。
分页、排序、筛选、全屏和导出复用这组上游数据。车辆归属及考核关系每次从数据库读取,然后重新合并并按当前用户权限过滤。用户身份不需要进入原始里程缓存键;授权结果绝不跨用户缓存。车辆改派经理、变更部门后,下一次请求读取最新归属。
区间日趋势始终根据已授权且符合筛选条件的车辆重新计算,修正此前权限过滤后仍可能使用全量日趋势合计的问题。
点击「查询」可命中缓存;点击普通视图或全屏的刷新按钮发送 `refresh=1`,跳过已完成缓存,包括历史单日缓存。已有相同在途请求仍可共享。刷新失败时不回退旧的区间缓存。日期和搜索仍需确认后请求。
## 接口可观察性
`GET /api/mileage/monitoring?startDate=...&endDate=...`:
- 响应头 `X-Mileage-Cache`:`miss` / `hit` / `shared` / `refresh`。
- 响应字段 `queriedAt`:这组 OneOS 数据成功获取的时间。缓存命中时保持不变;前端使用它展示获取时间,避免将缓存读取误显示为重新采集。
- `refresh=1`:显式刷新。普通分页、排序和导出不附带该参数。
旧的 `date=` 单日兼容分支和不带日期的后台缓存分支保持原有机制;当前监控前端使用起止日期参数,走本次新增的复用路径。
## 限制与取舍
这是 5 分钟的进程内数据复用,不是持久化会话快照。过期、淘汰、进程重启或请求落到另一实例时会重新取数;跨过这些边界时数据可能变化,不能保证整场会话固定在同一快照。前端可随时主动刷新。没有新增 Redis、数据库表或后台清理任务。
缓存仅减少 OneOS 的重复拉取。数据库读取、权限过滤、汇总和排序仍按请求执行,保证归属及时变化;首次长区间仍受上游串行分页限制。导出接口耗时不包含浏览器生成 XLSX 文件的时间。
## 本地真实接口实测
条件:2026-10-01 至 2026-10-26,仪表来源,765 辆 BI 车辆;同一进程连续请求,包含 HTTP 传输和 JSON 读取。
| 操作 | 耗时 | 缓存状态 |
| --- | ---: | --- |
| 首次查询 50 条 | 10,723 ms | miss |
| 第 2 页 | 92 ms | hit |
| 改为升序 | 48 ms | hit |
| 导出接口返回全部 765 条 | 64 ms | hit |
| 主动刷新 | 9,869 ms | refresh |
| 刷新后再次导出 | 96 ms | hit |
前 4 次响应使用相同的 `queriedAt`;主动刷新更新该时间,再次导出复用新的结果。以上为单次样本,不是 P95。命中路径仍包含最新车辆信息 SQL 和重新聚合;首次/主动刷新仍受上游耗时影响。
## 验证
TypeScript 检查、30 项里程相关测试及生产构建通过;构建仍有已有的大资源包提示。
- 查询缓存单测覆盖命中、5 分钟过期、容量淘汰、来源顺序隔离、在途合并、刷新和失败重试。
- 路由集成测试以模拟 OneOS/SQL 验证:首页拉取 3 次上游数据,后续分页、排序、导出、筛选及权限变更均不新增上游请求;刷新再新增 3 次。归属变更后权限立即按新的数据库结果生效。
- 验证部门/个人范围的列表、筛选车牌、日趋势及空权限结果;缓存不会被某一用户的筛选污染。
- 浏览器模拟测试覆盖桌面 1440px 和手机 390px,各 15 项交互检查通过,包括刷新传参、刷新后恢复复用、未确认条件不请求。
- 实测脚本与结果保存在工作区上级 `outputs/mileage-query-cache-20261027/`。
上游首次查询建议见 [OneOS 性能优化反馈](oneos-mileage-performance-feedback-2026-10-27.md)。
@@ -1,56 +0,0 @@
# 里程监控查询交互与性能分析(2026-10-27)
当前已完成第三阶段 [区间汇总接入与验证](mileage-summary-integration-2026-10-27.md),首次查询不再拉取全量逐日明细。
本文保留第一阶段诊断与修改记录。后续已实现 OneOS 查询复用,见 [第二阶段实现与验证](mileage-query-cache-2026-10-27.md);可转交上游的独立建议见 [OneOS 性能优化反馈](oneos-mileage-performance-feedback-2026-10-27.md)。
## 本次修改
实时监控页将编辑中的筛选条件与已提交条件分开。日期输入、快捷日期、搜索选择、筛选标签清理、重置与排序修改只更新草稿,点击「查询」才提交。首次进入仍加载默认日期;主动刷新与确认后的滚动分页继续保留。移除页面每分钟自动刷新,避免编辑过程中产生后台查询。
高级筛选和全屏均提供查询按钮。草稿未提交时禁用刷新和导出,并暂停加载更多,避免使用未确认条件。日期不完整或起止倒置时不可提交。接口失败显示重试提示;数据统计使用返回结果的日期范围。保留原有权限过滤、数据来源规则及全屏参数差异。
范围:本次修改实时监控页;统计报表、每日汇报和单车详情的日期交互未改动。后端数据缓存、上游分页及实时性策略未修改。
## 测量方式
在本地开发环境中,使用已有配置调用 `queryRangeMileage`,仅执行数据读取。临时探针包裹 `fetch`(统计至 JSON 读取完毕)及数据库 `execute`,不记录凭据和车辆明细。来源为仪表数据,测量日期均为已结束的上海自然日。
这些是单次样本,不代表 P95 或高并发表现;总耗时为服务端查询函数耗时,包含网络、解析、数据库及合并计算,不包含浏览器渲染。同一进程重复查询可复用已有单日缓存。
| 区间 | 总耗时 | 区间上游记录数 | 区间分页请求数 |
| --- | ---: | ---: | ---: |
| 10 月 26 日 | 1,644 ms | 1,024 | 1 |
| 10 月 20–26 日,首次 | 3,100 ms | 7,168 | 2 |
| 10 月 20–26 日,重复 | 2,994 ms | 7,168 | 2 |
| 10 月 1–26 日,首次 | 11,123 ms | 26,624 | 6 |
| 10 月 1–26 日,重复 | 9,510 ms | 26,624 | 6 |
单日样本同时另发结束日和前一日的两个单日请求,各约 1.6 秒。26 天首次查询的 6 个游标分页分别约 2,283 / 2,114 / 1,775 / 1,762 / 1,682 / 1,466 ms,串行耗时合计约 11.08 秒,几乎占满总耗时。
车辆关联 SQL 返回 765 行,耗时 58–168 ms;考核目标关联 SQL 返回 512 行,耗时 25–125 ms。它们与上游查询并行执行。本次样本不支持把主要瓶颈归因于数据库或浏览器渲染。
## 原因与代码依据
1. **每次改条件就发请求。** `MonitoringView.tsx` 原本直接以全部输入状态构建 `monitoringQuery`,依赖它的 `loadFirstPage` 改变后由 Effect 自动调用;另有 60 秒轮询。本次已经改为显式提交。
2. **页面分页发生在全量查询之后。** `src/server/routes/mileage/monitoring.ts` 先调用 `queryRangeMileage`,然后执行权限过滤、业务筛选、排序和 `slice`。请求第 2 页或只选一辆车仍先执行全量区间查询。
3. **区间查询没有结果缓存。** `src/server/routes/mileage/oneos-api.ts` 中的 `fetchOneOsMileageDates` 只有 `rangeInflight`,完成后删除;单日查询的历史 6 小时缓存不覆盖此路径。重复查询同一个月仍发 6 次分页请求,与实测吻合。
4. **游标必须串行。** `requestRange` 每页 5,000 条,拿到 `nextCursor` 才能发下一页。不能直接并行发后续页。前端固定传 `startDate/endDate`,即使只选一天也会走区间接口,再额外取结束日单日数据。
5. **重试可能进一步拉长等待。** 本地配置每次上游调用超时 20 秒,最多尝试 3 次,中间等待 1 秒、2 秒。某一页连续超时即可约 63 秒;这是代码推导,实测样本没有出现超时。接口没有整个区间请求的总时限。
6. **开发环境首次请求有两次。** `src/main.tsx` 启用 React StrictMode,浏览器测试观察到首次挂载两次请求;用户点击查询只产生一次请求。上游相同在途请求可合并,但数据库工作仍重复。这与生产环境行为需区分,不能为消除开发重复而移除 StrictMode。
## 后续优化顺序
1. **优先让分页、排序、导出复用一次已完成查询的结果。** 采用明确的查询快照或按日期范围与数据来源缓存原始结果,再按当前用户权限过滤。需要定义刷新失效方式及允许的数据延迟;不能缓存某个用户过滤后的结果再跨用户共享。这样能去掉重复查询的主要等待,但不会缩短首次上游查询。
2. **对单日范围复用单日数据。** 避免区间与结束日两个接口重复查询同一天,验证缺失值、累计里程、来源优先级和前一日统计口径后再替换。
3. **减少首次查询传输量。** 上游若提供区间聚合结果,可直接获取每车汇总及每日总量;明确车牌查询可考虑传入车牌限制,但必须保留现有权限检查和筛选候选项语义。若继续只提供明细游标分页,长区间首次查询仍受串行页数限制。
未通过缩短超时、隐藏等待动画或并发重试来声称已解决单次查询耗时。
## 验证
- TypeScript 检查通过。
- 监控查询模型、日期范围及里程显示相关 12 项测试通过。
- Vite 生产构建通过,保留已有的大资源包提示。
- 浏览器真实接口检查:修改两个日期没有监控请求,点击查询后发送一条包含两个最终日期的请求。
- 浏览器自动化使用模拟数据验证 1440px 和 390px 两种宽度,各 11 项交互检查通过,均无页面运行时错误;运行脚本与结果位于工作区上级 `outputs/mileage-query-20261027/`。模拟测试用于验证请求触发条件,不用于性能结论。
@@ -1,59 +0,0 @@
# 区间里程汇总接入与验证(2026-10-27)
本地里程监控页已接入 `POST /api/v1/vehicles/mileage/summary/query`。依据用户提供的《BI 区间里程汇总接口对接说明》及 [OpenAPI 契约](https://open.d.lnoneos.com/open-api/openapi.yaml);沿用现有服务地址和 appKey,不新增凭据配置。
## 查询与展示
- 首次进入加载默认日期;修改日期、来源及业务筛选后点击「查询」才请求。保留手动刷新,取消定时轮询。
- 主列表每辆车使用一条区间汇总,不再先拉全部逐日明细。分页、排序、全屏及区间汇总导出复用服务器结果。
- 默认仪表来源仍为有序的 `GB32960, MQTT`;不因文档示例包含 `JT808` 而扩大来源。保留现有来源选择、366 天 BI 区间限制和上海自然日口径。
- 区间里程取 `periodMileageKm`,只合计有效日;真实零显示 0,无有效数据显示横杠。异常车辆保留其余有效日期的合计,并展示有效、缺失、异常天数及原因提示。
- 结束日累计里程取 `endDateTotalMileageKm`,独立于区间日里程质量。无读数时不回填历史累计。采集时间、源更新时间与查询时间保持区分。
- `CARRIED_FORWARD` 明细计为缺失,不冒充有效零。日趋势的 `null` 保留缺口。
- 单车详情按当前区间和来源按需取明细。导出菜单分为「导出区间汇总」和「导出逐日明细」;前者复用汇总,后者明确拉取所选车辆逐日数据,仍可能较慢。
## 权限与缓存
每次 BI 请求重新读取车辆归属和考核关系,再执行现有部门、个人等权限及业务筛选、客户名称脱敏。上游原始结果可复用,用户授权结果不跨用户缓存。单车详情也校验当前 BI 车辆权限。
上游全量授权车辆与 BI 当前可见车辆并不相同,因此不能直接把全量趋势展示给用户。当筛选范围缩小时,使用已授权且可见的车牌集合请求范围内趋势;空集合直接返回无数据趋势,不向上游传空数组(空数组在契约中代表全部)。分页、排序、全屏和导出使用 `includeDailyTrend=0`,无需重复获取趋势。
缓存键包含起止日期、有序来源及规范化车牌范围;同一在途请求合并,成功结果最多缓存 5 分钟且不超过上游快照到期时间,最多 4 项。显式刷新绕过已完成缓存;失败不回退成空数据或成功零值。401、403、429、504 等上游错误转为明确提示,保留错误码、traceId 和适用的 Retry-After。
缓存为进程内机制,过期、淘汰、重启或跨实例都会重新请求。BI 归属变化立即按最新数据库结果过滤;上游授权变化需要下次上游请求才能获知。列表与按权限重查的趋势可能来自两个生成时点,当前日或历史补报期间不保证原子一致;上游首次汇总本身也不是数据库原子快照。
## 真实数据对账
条件:2026-10-01 至 2026-10-26,仪表来源。仅执行读取,不修改上游或业务数据库。
- 旧明细接口:6 页、26,624 条记录;新汇总:1,024 辆、26 天趋势。
- 逐车对比有效里程合计、有效/缺失/异常天数:差异 0。
- 旧明细中 6,910 条 `NORMAL/CARRIED_FORWARD` 按契约计为缺失,真实正常记录 11,150 条,无数据 8,559 条,异常 5 条。
- BI 列表 765 辆,其中 761 辆匹配上游汇总,4 辆不在返回集合内,保持无可用里程。匹配车辆的区间里程、质量天数、结束日累计里程:差异 0。
- 按 BI 车辆集合重新计算的 26 天趋势与返回趋势逐日对比:差异 0。
## 性能实测
同一本地开发环境、同一日期和来源;单次样本,非 P95。HTTP 指标包含响应及 JSON 读取,不包含浏览器绘图或 XLSX 生成。
| 路径 | 耗时 |
| --- | ---: |
| 旧上游逐日明细,完整 6 页 | 10,250 ms |
| 新上游区间汇总,单次请求 | 1,869 ms |
| 新 BI 首次完整查询,缓存 miss | 3,862 ms |
| 第 2 页,缓存 hit | 68 ms |
| 升序查询,缓存 hit | 35 ms |
| 区间汇总导出数据,765 辆,缓存 hit | 39 ms |
| 主动刷新 | 3,572 ms |
| 刷新后再次导出数据,缓存 hit | 67 ms |
首次 BI 请求仍包含全量汇总、原有对比日读数、数据库查询,以及 BI 范围的趋势汇总,因此不能把上游单次 1.9 秒宣称为完整页面耗时。此前旧 BI 冷查询实测 10,723 ms,本次 3,862 ms,单样本约缩短 64%。
## 验证记录
- 38 项里程相关测试通过:覆盖契约解析、快照分页、真实零与缺失、部分异常有效合计、缓存与过期、部门/个人/空权限、归属变化、错误响应和按需明细。
- `npm run lint` 和 `npm run build` 通过;构建仍提示已有的大资源包。
- 真实浏览器 1440px / 390px:日期编辑不请求、确认后查询、765 辆和 26 天趋势、质量天数、手机查询按钮可见,均通过,无页面脚本异常。
- 实际下载并解析区间汇总 XLSX:1 张表、765 辆、质量列齐全;单车逐日 XLSX:2 张表、26 个日期列。单车详情请求日期与来源与列表一致。
本机验证脚本、脱离 Git 的原始结果及截图位于工作区父目录 `outputs/mileage-summary-20261027/`:`timings.json`、`reconciliation.json`、`browser-results.json`。原始响应含业务数据,不应直接发布到公共仓库或转发。
@@ -1,103 +0,0 @@
# OneOS 里程接口性能优化反馈
后续进展:上游已提供区间汇总接口,本地 BI 已接入并完成对账,见 [接入与验证记录](mileage-summary-integration-2026-10-27.md)。下文保留最初反馈背景。
日期:2026-10-27。本文供 BI 与 OneOS 团队对接,建议项尚未代表上游已实现或已承诺。
## 当前问题与业务影响
BI 查询一段日期的车辆里程时,先获取全部车辆逐日明细,完成后才能汇总、排序及展示首页。长区间查询的主要等待发生在 OneOS 游标分页:首次进入或主动刷新一个月的数据仍需约 10 秒。
相关现有接口:
- `POST /api/v1/vehicles/mileage/range/query`:日期范围、可选车牌、来源优先级、游标分页。
- `POST /api/v1/vehicles/mileage/query`:单日里程及累计里程。
## 可复现样本
请求条件如下,不包含任何访问凭据:
```json
{
"startDate": "2026-10-01",
"endDate": "2026-10-26",
"pageSize": 5000,
"protocolPriority": ["GB32960", "MQTT"]
}
```
后续请求携带上一页返回的 `nextCursor` 作为 `cursor`;BI 检查返回 `snapshotId` 的一致性。
在当前应用授权范围内,共返回 26,624 条逐日记录,需 6 次串行分页请求。2026-10-27 的一组测量结果如下(客户端计时至 JSON 读取完毕):
| 页 | 返回条数 | 耗时 |
| --- | ---: | ---: |
| 1 | 5,000 | 2,283 ms |
| 2 | 5,000 | 2,114 ms |
| 3 | 5,000 | 1,775 ms |
| 4 | 5,000 | 1,762 ms |
| 5 | 5,000 | 1,682 ms |
| 6 | 1,624 | 1,466 ms |
区间分页合计约 11.08 秒,BI 查询函数整体约 11.12 秒。同一进程重复查询约 9.51 秒,仍需请求 6 页。另一组 7 天查询约 3.10 秒,重复约 2.99 秒。
车辆归属 SQL 约 58–168 ms,与上游调用并行;合并后 BI 范围为 765 辆车,上游返回范围大于 BI 的有效车辆集合。
以上是单次本地实测,包含网络与 JSON 处理,不是服务端执行时间或 P95。尚无 OneOS 内部 trace,不能据此断言某条 SQL 缺索引或每个游标页都重复计算。
## BI 已做的优化
- 日期、筛选和搜索修改后,点击查询才发请求。
- 相同日期范围与来源优先级的 OneOS 结果在本地服务进程中复用 5 分钟,最多保留 4 组。
- 分页、排序、筛选和导出复用 OneOS 数据;主动刷新绕过该缓存,并重新获取相关单日数据。
- 车辆归属与考核关系仍每次读取,权限检查、筛选和汇总仍按当前用户执行,不缓存用户权限结果。
这解决重复查询,不能消除首次长区间查询的 6 次串行等待。上游优化的目标是首次查询、主动刷新及跨应用复用,而不是让 BI 延长缓存来掩盖延迟。
## 建议优先级
### P0:诊断每页耗时,并复用已生成快照
请提供一次上述查询的 `traceId` 与各页阶段耗时,至少区分:数据库读取、里程计算/协议选择、快照生成或读取、序列化、排队等待。
请确认:
1. 后续游标页是否只读取已有快照,还是重新扫描、计算整个区间?若存在重复计算,优先改为计算一次后分页读取。
2. 相同应用授权范围、日期范围、协议优先级和车牌集合,能否在短期内复用已完成快照?
3. 当前 `snapshotId` 的有效期及数据更新时间是什么?能否通过新增参数重用指定快照,而不是重新创建?
快照复用必须包含应用权限范围和有序协议优先级,车牌集合需规范化;不能仅按起止日期跨应用共享授权后的结果。需要返回有效期/数据时间,明确实时日更新、历史补数与主动刷新的失效规则。
### P1:提供按车辆的区间聚合,减少传输与调用次数
建议新增“区间汇总”能力(接口名称由 OneOS 确定,非现有协议):每辆车返回一条汇总,而非默认返回每辆车每天一条完整记录。BI 列表所需字段包括:
| 字段能力 | 用途及口径 |
| --- | --- |
| 车牌、VIN | 与 BI 车辆信息关联 |
| 区间有效日里程合计 | 不能用末日累计里程减首日累计里程替代 |
| 有效天数、缺失天数、异常状态 | 区分真实零里程和整段无数据 |
| 结束日累计里程 | 当前 BI 为它额外调用单日接口 |
| 区间开始日前一天的日里程 | 当前对比口径,若可一并提供可再减少一次调用 |
| 实际来源/混合来源、数据时间 | 保持来源优先级与数据质量可解释性 |
| 快照标识、计算/生成时间 | 后续查询与数据时间展示 |
这能将主列表传输量从约“车辆数 × 天数”降至“车辆数”。日报趋势/单车逐日明细按需读取,或通过字段投影仅返回必要的逐日值。
BI 的每日趋势必须遵循当前筛选与权限范围:不能直接采用全应用车辆的日合计。建议日趋势聚合支持同一快照和明确车牌集合,或在汇总结果中可选返回每车的精简逐日值,再由 BI 汇总。
### P2:减少单页耗时并提供稳定的调用预算
- 基于 trace 和实际执行计划,优化日期范围、车牌集合、来源优先级条件的读取路径;目前数据不足以指定具体索引。
- 核实响应压缩、连接复用及明细字段投影是否生效。调大 `pageSize` 可作为对照实验,但应同时评估响应大小、内存与单页超时;不能只靠加大页数掩盖重复计算。
- 目前 BI 每页超时 20 秒、最多尝试 3 次,退避 1 秒与 2 秒。单页连续超时可导致约 63 秒等待,这是配置推导,非本次实测异常。建议约定整个查询的期限、可重试错误码和可恢复快照,避免失效后从第一页重做。
## 建议验收方式
双方使用相同应用权限、同一车牌集合及协议优先级,对 1 / 7 / 31 天、冷查询与快照复用分别测量。记录首屏耗时、完整结果耗时、请求数、响应字节数和上游阶段耗时;有足够重复样本后再报告 P50/P95。
可讨论的目标:约 1,000 辆 × 31 天汇总首次完整返回在 3 秒内,快照复用在 300 ms 内。以上是待双方评估确认的目标,不是当前测量结果或已承诺 SLA。
结果正确性需与现有接口逐车核对:来源优先级、缺失值与零值、异常里程、累计里程、上海自然日起止边界、跨月区间及权限范围。缓存命中不能改变这些口径。
请上游反馈:阶段 trace、快照当前实现与复用能力、聚合接口可行性,以及可交付的第一阶段范围与排期。
@@ -1,198 +0,0 @@
# 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 原始报文、计算服务源码或内部存储,不能确定其内部算法根因。
- 本次未修改业务代码、未变更里程口径、未触发历史数据重算。
-29
View File
@@ -1,29 +0,0 @@
# 权限视角预览
授权账号登录后,直接访问 `/who` 搜索同一组织的有效用户,先核对角色与数据范围,再进入只读预览。常规页面不展示入口,未授权用户直接访问链接也无法使用。预览使用目标账号真实角色:BI-Leader/所有权限/数智中心为全量,BI-Leader-Dep为本部门,其余为本人负责;多角色时全量优先。资产库存(包括待交车、异动)对三类角色均展示全量,汇总、分组与车辆明细口径一致;所属公司筛选仍然生效。非库存资产及交还车历史维持部门/个人范围,公共库存权限不扩展历史业务访问。
## 授权与身份来源
- 默认授权已核实的旧SSO账号:`puhongxia`(`1014807041625047040`)、`zhanglan`(`1105261382487539712`)、`shishengliang`(`1116631120763437056`)。
- 后续通过服务端 `BI_PERMISSION_PREVIEW_USER_IDS` 配置逗号分隔的用户ID名单;该配置**替换**默认名单。空字符串关闭全部授权。不要用姓名、前端配置或请求参数授予权限。
- 身份与角色读取现有SSO库 `lingniu_prod`,可由 `BI_IDENTITY_SCHEMA` 指定;仅读取启用且未删除的用户、未删除角色和部门。不读取密码、电话等字段。
- 新车辆系统 `ry-cloud` 的账号ID不是现有BI登录ID,不能直接填入授权名单,也不能按同名人拼接身份。个人车辆范围通过唯一、大小写精确相同的登录名匹配两套有效账号,正常登录与预览共用同一过滤;缺失或歧义不扩权。新用户租户由 `BI_VEHICLE_IDENTITY_TENANT_ID` 指定(默认已核实的 `000000`),部门仍采用原BI登录目录的部门。
- 本地免登录默认无预览权限。用户明确要求后,可在本地 `.env` 同时开启 `DEV_BYPASS_AUTH=1`、`DEV_PERMISSION_PREVIEW=1`;仅非生产模式生效。可由 `DEV_PERMISSION_PREVIEW_USER_ID` 指定已授权的组织范围锚点(默认puhongxia ID),操作与审计身份仍为dev-local。局域网访问此开发服务也能使用该入口。真实用户授权验收仍需业务系统正常SSO登录。
## 会话与隔离
- 人员查询仅在输入关键词后返回模糊匹配结果;空白关键词不展示也不返回人员列表,范围筛选不能单独枚举人员。
- 原登录Bearer始终代表操作者。预览使用独立随机令牌 `X-BI-Preview`,有效期30分钟,绑定操作者和目标用户;原JWT不被替换。
- 服务端每次请求复核操作者授权、用户有效性、同组织关系、目标角色与部门。原身份与目标权限不做并集。
- 切换会取消旧请求并重建页面,AI会话还按操作者及预览会话ID隔离;退出恢复原账号。写入接口及缓存刷新、调试接口禁止在预览中调用。
- AI问数只查询,不自动记录需求。日报预览只读获取现有归档或内存生成,不建表、不保存归档。
- 预览会话目前保存在服务进程内存,重启后失效,多实例部署需会话粘滞或先迁移到共享会话存储。
## 审计
默认追加到 `logs/permission-preview.jsonl`;可用 `BI_PERMISSION_PREVIEW_AUDIT_FILE` 配置持久化日志路径。记录开始、读取、阻止写入、退出,以及操作者ID、目标ID、时间、路径和范围,不记录令牌或业务数据。审计写入失败时拒绝预览请求。生产部署应挂载持久卷并由日志平台采集/轮转。
## 验收
验证未授权者拒绝、三类范围、目标失效、角色变动、跨组织拒绝、令牌伪造/过期/跨操作者复用、只读拦截和AI会话隔离。真实账号端到端登录仍需业务系统SSO会话;本地免登录不能替代此项。
-41
View File
@@ -1,41 +0,0 @@
# Portainer 部署资产 AI 问数
配置 Key 后默认使用 DeepSeek Flash 理解自然语言与会话上下文;`ASSET_AI_EXTERNAL_ENABLED=0` 可显式关闭模型。此前版本显式配置了 0 的 Stack,请改为 1。模型会收到问题原文、近期会话、分析条件和相关维度目录,详情见 [会话分析说明](asset-ai-safe-analysis.md)。
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:-}"
ASSET_AI_EXTERNAL_ENABLED: "${ASSET_AI_EXTERNAL_ENABLED:-1}"
```
1. 等待当前分支的 Woodpecker 构建与镜像推送成功,取得本次构建对应的镜像标签。
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 或显式关闭模型时使用本地规则,并在界面提示,其他资产功能可继续使用。
## 检查是否注入
在 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)。
@@ -1,97 +0,0 @@
# LN-BI 渐进式重构方案
## 目标
本轮只调整代码结构,不改变业务功能。以下内容视为冻结契约:
- 页面文案、布局、交互和 URL 行为
- API 路径、请求参数、状态码和响应字段
- SQL、数据源优先级、权限与脱敏顺序
- 里程、资产、能源和调度的统计口径
- 缓存刷新频率与日报归档时间
## 当前结构
项目已经按业务模块组织,但部分文件仍承担了过多职责:
| 热点 | 规模 | 主要职责混合 |
| --- | ---: | --- |
| `modules/assets/AssetsModule.tsx` | 3240 行 | 请求、53 组状态、筛选、图表、弹窗、导出和四个视图 |
| `modules/mileage/MonitoringView.tsx` | 1611 行 | 查询参数、44 组状态、列表、全屏、导出和统计 |
| `server/routes/vehicles.ts` | 1426 行 | SQL、缓存、权限、领域映射、聚合和 15 个接口 |
| `modules/mileage/DailyReportView.tsx` | 1192 行 | 日期、领域计算、图表、表格和页面编排 |
目标结构遵循同一方向:
```text
route / page
-> controller / hook # 流程与状态
-> service / model # 业务规则与纯计算
-> repository / api # 数据访问
```
React 组件负责展示和交互编排;纯函数负责排序、筛选、分组和口径;服务端路由只解析请求并组织响应;数据库查询集中在 repository 层。
## 实施顺序
1. **建立回归门禁**:类型检查、领域测试和生产构建必须同时通过。
2. **提取纯模型**:先移动无副作用的日期、映射、筛选、排序和聚合逻辑,并补特征测试。
3. **拆分前端组件**:日期选择器、表格、展开详情等独立,页面保留状态和组合。
4. **拆分后端边界**:服务器应用创建与进程启动分离;大型路由按 model、service、repository、routes 拆分。
5. **清理重复与废弃代码**:只有在引用和行为测试都证明无影响后执行。
每一批只处理一个边界,不把现存 Bug 修复、新功能或视觉调整混入重构提交。
## 模块迭代路线
| 批次 | 模块 | 主要边界 | 风险控制 |
| --- | --- | --- | --- |
| 第一轮 | 应用入口、服务启动、日报、资产模型、监控查询、车辆模型 | 路由装配、启动副作用、纯计算 | 特征测试锁定路由、日期、口径和请求参数 |
| 第二轮 | 资产页、里程监控、车辆接口、统计报表 | 展示组件、数据访问、弹层和图表 | 保持 DOM、SQL、缓存覆盖和请求时机不变 |
| 第三轮 | 氢能展示、能源查询路由 | 图表展示、日期模型、数据库查询 | 固定金额/重量单位、客户范围和日期边界 |
| 第四轮 | 智能调度 | 筛选模型、建议详情、通知历史 | 写操作与只读派生分开,通知接口单独回归 |
| 第五轮 | OneOS 接入、里程缓存 | 外部 API、协议降级、缓存与合并 | 使用响应夹具验证降级顺序、TTL 和数据源优先级 |
高风险模块不会与普通页面组件放在同一批:能源路由依赖两套数据库,调度包含通知写入,OneOS 层还包含协议兼容、超时和缓存淘汰。拆分这些模块前必须先建立可重复的输入夹具。
## 当前进度
截至 2026-08-13,前五轮可独立验证的边界已经完成:
- 应用路由配置与服务器启动副作用已经从入口文件分离。
- 资产、里程监控、每日汇报、统计报表和智能调度已改为“页面协调层 + 展示组件 + 纯模型”。
- 车辆接口已改为“路由 + repository + model”,原 SQL、缓存范围和接口顺序由特征测试锁定。
- 能源接口已按氢能总览、氢能每日、电能总览和电能每日拆分,入口只保留权限守卫与装配。
- 氢能与电能每日页共用自然日范围模型和筛选控件;金额、重量、环比及 30% 波动边界已有测试。
- 反馈入口和电能导入页已收敛为“副作用协调层 + 无状态展示组件”;上传、筛选和请求时机仍由主组件负责。
- OneOS 响应归一化、连续日期分组和请求键已提取为纯模型;协议别名、去重和区间边界由固定样例锁定。
- 里程缓存中的车辆合并、筛选项、考核映射和自然日工具已与刷新流程分离;数据源分类和累计里程空值规则保持不变。
- 调度通知的数据库行映射、请求校验和更新字段生成已有契约测试;数据库写入顺序和单车有效干预约束仍留在路由层。
- 车辆与加氢热力图已把参数解析、距离计算、网格/排名和序列化提取为纯模型;原 SQL 及参数顺序由契约测试锁定。
- CI 已把类型检查、全量测试和生产构建设为同一发布门禁。
下一批继续只处理能够独立回归的边界:
1. 继续缩小 `AssetsModule.tsx` 的状态协调职责,但不改请求、筛选、导出和弹窗时序。
2. 拆分车辆详情、调度建议详情和通知历史中的纯展示区块,主组件继续持有写操作。
3. 为电能导入服务端路由补 SQL 与去重契约后,再拆解析、repository 和路由装配。
4. OneOS 网络重试、协议降级和缓存 TTL 暂不移动;只有在可注入请求层覆盖 400 降级和重试后再处理。
## 注释约定
注释解释“为什么”,不复述语句本身。优先说明:
- 统计边界和历史口径
- 时区、日期范围和特殊状态映射
- 外部 API 的兼容处理
- 看似可合并但必须保持差异的流程
命名和类型能够表达清楚的代码不额外添加注释。
## 已识别但不在本轮修复的问题
- 统计页实时车辆与历史日期车辆共用同一个缓存槽,存在状态覆盖风险。
- 监控页不同入口的请求参数目前不完全一致。
- 数据库连接配置与部署凭据需要独立安全治理。
这些问题需要单独建缺陷、明确期望行为后再修复,避免借重构改变线上结果。
BIN
View File
Binary file not shown.
-141
View File
@@ -1,141 +0,0 @@
# 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 模拟表格中期换成真 `<table>` — `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 冒充可点击元素:改 `<button type="button">` + 稳定业务 key — `src/modules/assets/components/overview/AssetSummaryMobileCards.tsx:60-63`、`InventoryMobileList.tsx:50,93`
### 数据可信
- [ ] 全屏监控筛选口径与普通视图静默不一致:全屏复用同一 `monitoringQuery`,或在全屏头部明示"以下筛选未生效:项目/主体/里程区间" — `src/modules/mileage/monitoring/query-model.ts:92-96`、`MonitoringView.tsx:108-110`
- [ ] 热力图刷新期间旧数据无标识:请求期间地图区加半透明遮罩 + spinner,或 KPI/热力层 `opacity-50` 过渡(两个热力图文件同步改)— `src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx:73-92,143-147`、`src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx`
- [ ] 洞察卡片"月度动能"对进行中当月做按天折算或加"本月未完整"标注;正收益改用 TrendingUp/CheckCircle 图标 — `src/modules/energy/hydrogen-overview/model.ts:64-66`、`components/InsightCards.tsx:77-79`
- [ ] 区分"无数据"与"数据未就绪"两种空态文案;未就绪时隐藏 KPI 行 — `src/modules/energy/ElectricDaily.tsx:62,101-119`、`HydrogenDaily.tsx:91-109`
### 写操作防错与反馈
- [ ] 「登记干预」加二次确认弹层(复用 BatchConfirmModal 底部弹模式,展示 原车牌→候选车牌+客户)— `src/modules/scheduling/SwapPreview.tsx:33-40,155-163`
- [ ] 批量干预成功后保持 `resp.message` 可见(弹窗延迟关闭或页面 Toast),失败才留在弹窗内 — `src/modules/scheduling/SchedulingModule.tsx:142-144`、`scheduling-module/BatchConfirmModal.tsx:59-61`
- [ ] 替换全部原生 alert/confirm 为统一 Toast/内联错误 — `src/modules/scheduling/SwapPreview.tsx:44-47`、`NotificationHistory.tsx:109-110`
### 上传/导入工作流
- [ ] UploadDropzone 前置校验扩展名/大小并内联报错;uploading 期间禁用点击/拖入防并发 — `src/modules/ele/import-page/UploadDropzone.tsx:42-58`、`EleImportPage.tsx:77-95`
- [ ] 大文件上传给进度反馈(XMLHttpRequest 进度),成功后状态改"✓ 已导入,点击重新上传" — `src/modules/ele/EleImportPage.tsx:77-95`
- [ ] 导入记录表分页或"加载更多",修 total 与可见 50 条脱节;搜索改防抖即时生效或显式搜索按钮;输入框加清除按钮 — `src/modules/ele/EleImportPage.tsx:58`、`import-page/RecordFilters.tsx:30`
- [ ] 空态区分"无记录"与"筛选无结果" — `src/modules/ele/import-page/RecordTable.tsx:46`
---
## P1 明显体验缺陷
### 加载/刷新范式统一(目标:氢能总览为模板)
- [ ] 日报已有数据时改顶部进度条 + 内容淡化(去掉全屏遮罩),取消或缩短 280ms 强制延迟;全屏遮罩仅留首次加载 — `src/modules/mileage/DailyReportView.tsx:24,134-136`、`MileageLoadingOverlay.tsx:13`
- [ ] 统计报表补骨架屏 + ErrorState + 重试,不再静默吞错 — `src/modules/mileage/StatisticsView.tsx:39-100`
- [ ] 电能/氢能"每日"页补首屏骨架屏 — `src/modules/energy/ElectricDaily.tsx:131-132`、`HydrogenDaily.tsx:208-209`
- [ ] 电能总览 LoadingState 单卡片升级为骨架屏 — `src/modules/energy/ElectricOverview.tsx:31`
### 布局错位与遮挡
- [ ] 监控 KPI 吸顶条按断点修正 sticky 偏移(导航 62px + 顶栏 48px),375px 与 1440px 实机滚动验证 — `src/modules/mileage/monitoring/components/MonitoringSummary.tsx:27`、`src/modules/mileage/MileageModule.tsx:33`
- [ ] 面包屑遮罩与页面内容重叠:`<main>` 加 `md:pt-12` 或 PageFrame 桌面顶距提至 ≥56px — `src/components/Shell.tsx:159-164`、`src/components/ui/surface.tsx:34`
- [ ] 面包屑纳入子 tab(如 `羚牛氢能 BI / 里程管理 / 统计报表`),结构化 breadcrumb 标记;移动端补子 tab 位置指示 — `src/components/Shell.tsx:160-163`、`src/modules/energy/useHashSubTab.ts:30-35`
### 移动端专项
- [ ] 热力图移动端图例不直接隐藏:折叠为渐变条按钮,点击展开 — `src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx:220`、`src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx:206`
- [ ] VehicleDetailModal 客户视图 13 列在移动端改卡片列表(借鉴 FlowDetailModal 模式)— `src/modules/assets/components/VehicleDetailModal.tsx:194-236`
- [ ] 库存统计筛选弹层加遮罩点击关闭或改底部弹层 — `src/modules/assets/components/overview/InventoryStatisticsSection.tsx:85`
- [ ] 地图点击查询打临时 Marker 或二次确认,避免误点覆盖概览 — `src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx:94-103`
- [ ] 热力图 ToolBar/Scale 避免与底部详情面板重叠(改左上或随面板状态调整)— `src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx:88-89`
### 视觉一致性
- [ ] 次级文字对比度整改:定义对比度达标的语义色 token(slate-500 起步),全站替换 9–12px 的 `text-slate-400/300` 关键文字 — `src/components/ui/surface.tsx:137,181,269`、`src/components/Shell.tsx:122,154,186`
- [ ] 全站字号下限 10–11px,清掉 7–8px — `src/modules/mileage/monitoring/components/VehicleList.tsx:77-126`、`FullscreenMonitoring.tsx:159-318`、`MonitoringSummary.tsx:30-44`
- [ ] SearchSelect/MultiSearchSelect 统一到 slate 系 + `rounded-xl` + 统一 focus ring,尺寸默认值移入组件内部 — `src/components/SearchSelect.tsx:34,48`、`MultiSearchSelect.tsx:46`
- [ ] 资产域表格数字列统一右对齐 + `tabular-nums`,表头随列对齐 — `src/modules/assets/components/overview/AssetSummaryDesktopTable.tsx:22-34,87-192`、`InventoryDesktopTable.tsx:36,76`(对齐标准:`src/modules/ele/import-page/BatchTable.tsx:27-47`)
- [ ] 分组大表表头 sticky + 容器右缘渐变阴影提示可横滑 — `src/modules/assets/components/overview/AssetSummaryDesktopTable.tsx:16-17`
### 筛选/标签交互
- [ ] ActiveFilterTags 默认日期区间不进"已选条件"标签(区间降级为统计周期信息条)— `src/modules/mileage/monitoring/components/ActiveFilterTags.tsx:90-97`、`MonitoringView.tsx:68-69`
- [ ] `searchTerm` 死筛选:工具栏补搜索框,或删除该状态 — `src/modules/mileage/MonitoringView.tsx:43`、`query-model.ts:44`
- [ ] 筛选统一即时生效(防抖),删除 filterMileageRange/appliedMileageRange 双状态 — `src/modules/mileage/monitoring/components/FilterPanel.tsx:259-267`、`MonitoringView.tsx:64-65`
- [ ] 筛选 tag 清除热区扩至 24px+,tag 整片可点清除 — `src/modules/scheduling/scheduling-module/ActiveFilterTags.tsx:15-20`
- [ ] 多选模式补"全选当前筛选结果";不可选项加 `title="已干预,不可重复登记"` — `src/modules/scheduling/SuggestionList.tsx:85-103`
- [ ] 监控工具栏快速区间补"近7天" — `src/modules/mileage/monitoring/components/MonitoringToolbar.tsx:149-155`
### 小 Bug 集群(改动极小,顺手修)
- [ ] TargetDetailPanel:徽标硬编码"在线"改随 `isOnline`;ChevronDown 条件旋转;非法类名 `bg-slate-50/50/50` — `src/modules/mileage/statistics/TargetDetailPanel.tsx:90-95,213,216-218`
- [ ] 图表车型长名截断加 `title` 悬浮完整名,复用 shortTargetName 缩写规则 — `src/modules/mileage/daily-report/ReportCharts.tsx:111`、`GroupRow.tsx:94`
- [ ] 热力图默认日期兜底改为"近 30 天"由当前日期推导 — `src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx:9-10`、`src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx:9-10`
- [ ] 堆叠柱 radius 接缝缺口 — `src/modules/mileage/daily-report/ReportCharts.tsx:114-115`
- [ ] ReportDatePicker 补点击外部/Esc 关闭;日期加业务下界 — `src/modules/mileage/daily-report/ReportDatePicker.tsx:56-110`、`ReportHeader.tsx:45-53`
- [ ] FullscreenTargetTable 行 key 改业务值、补空态、刷新按钮转动反馈 — `src/modules/mileage/statistics/FullscreenTargetTable.tsx:49-54,86`
- [ ] 电能每日表月份分组行环比列留空问题;氢能每日图 Y 轴 `k` 与 Kg 混淆 — `src/modules/energy/ElectricDaily.tsx:124-153`、`HydrogenDaily.tsx:155`
- [ ] forceLandscape 监听 resize/orientationchange — `src/modules/mileage/MonitoringView.tsx:298-308`
- [ ] ReportInsights key 撞车改 index 或 id — `src/modules/mileage/daily-report/ReportInsights.tsx:26`
- [ ] DailyRangeControls 去掉 onChange+onInput 重复触发、加 max 限制未来日期 — `src/modules/energy/daily-range/DailyRangeControls.tsx:56-72`
- [ ] ETC 占位页里程碑与 meta 文案矛盾统一 — `src/modules/energy/EtcModule.tsx:12`、`ETCView.tsx:50`
- [ ] FeedbackHistoryDrawer 死 prop `onBack` 移除;截图链接补 `aria-label`;状态文案"已忽略"改"暂不处理" — `src/components/FeedbackHistoryDrawer.tsx:44,135-136`、`FeedbackFab.tsx:169`
- [ ] MultiSearchSelect 标签移除按钮补 `aria-label` — `src/components/MultiSearchSelect.tsx:40`
- [ ] SearchSelect 容器 onClick 与 input onFocus 双触发误关问题;清理不可达的 placeholder 分支 — `src/components/SearchSelect.tsx:35,40,45`
- [ ] FeedbackAdminPage 反馈列表 limit=200 静默截断:加分页或溢出说明;tab 计数改后端聚合 — `src/modules/admin/FeedbackAdminPage.tsx:79,124-171`
- [ ] VehicleDetailModal 周明细表补空态行;footer 计数口径区分"本周流转 n 台/共 n 台" — `src/modules/assets/components/VehicleDetailModal.tsx:158-178,267`
- [ ] BatchTable 补空态占位;上传成功后最新批次高亮或加"新"徽标 — `src/modules/ele/import-page/BatchTable.tsx:12`
- [ ] FlowDateRangePicker 超出 370 天日期直接置灰,而非事后报错 — `src/modules/assets/components/overview/FlowDateRangePicker.tsx:18-19,110`
- [ ] NotificationHistory 增加相对时间(x 小时/天前);待执行超 48h 加"滞留"警示徽标;"近7天"过滤改后端参数化 — `src/modules/scheduling/NotificationHistory.tsx:67-72,222-228`
- [ ] SuggestionDetail 当前车摘要 sticky;保留滚动条或滚动阴影 — `src/modules/scheduling/SuggestionDetail.tsx:145-169`
- [ ] FeedbackFab 改走统一 api-client(401 触发全局登出)— `src/components/FeedbackFab.tsx:16-25`(同类:`EleImportPage.tsx:23-33`、`FeedbackAdminPage.tsx:52-53`)
- [ ] 全局补 `:focus-visible` 兜底样式 — 目前仅 4 处模块组件有
- [ ] SegmentedNav 的 `layoutId` 加 `useId()` 前缀防同页撞车 — `src/components/ui/surface.tsx:202-243`
---
## P2 体系化改进(对应重构方案阶段 5/6)
- [ ] **设计令牌化**:`index.css` 用 Tailwind 4 `@theme` 定义语义 token(`--color-ink-secondary`、`--shadow-card`、`--shadow-nav`、`--color-surface-bg`),替换散落 arbitrary value;删除无人引用的 `--panel-bg`/`--hairline` — `src/index.css:3-7`、`src/components/ui/surface.tsx:41`、`src/App.tsx:49`、`src/components/Shell.tsx:103`
- [ ] **统一格式化层**:`fmtKm` 三套实现合并到 shared;TrendBadge 环比小数位统一 — `src/modules/mileage/daily-report/utils.ts:54`、`src/modules/mileage/statistics/model.ts:48`、`src/modules/energy/TrendBadge.tsx:21`
- [ ] **统一"今天"定义与日期区间预设**:日报按上海自然日 / 监控按本地 05:00 / 电能按本地自然日,收敛为一套 — `src/modules/mileage/daily-report/utils.ts:13-24`、`monitoring/date-range.ts:11-15`、`energy/daily-range/model.ts:22-39`
- [ ] **配置化硬编码**:高里程告警规则(写死车型名+800km)挪后端或配置常量 — `src/modules/mileage/MonitoringView.tsx:36-40`;站点横幅硬编码 — `src/modules/energy/ElectricOverview.tsx:50-52`
- [ ] **z-index 分层约定**:定义 `z-modal`/`z-overlay` 等 token,替换 `z-[1000]`/`z-[80]`/`z-[60]` 随意值 — `src/modules/assets/components/FlowDetailModal.tsx:33`、`VehicleDetailModal.tsx:47`、`src/modules/scheduling/SwapPreview.tsx:58`、`NotificationHistory.tsx:122,262`
- [ ] **错误呈现统一**:表单内联错误用统一 `FormError` 组件 + `role="alert"`,对齐 ErrorState — `src/components/feedback/FeedbackFormStep.tsx:145`、`FeedbackHistoryDrawer.tsx:107`
- [ ] **深色模式显式决策**:短期写进设计约定;中期按 令牌化 → Shell/surface → 图表 palette → 地图底图 顺序评估
- [ ] **导航扩容策略**:一级模块超过 5 个时收口为"更多"抽屉;分组首点直达第一个子模块 — `src/components/Shell.tsx:117,173,183`
- [ ] **PageFrame 拆分**:`hideHeader`/`compactInfo` 布尔分叉拆成 PageHeader 与 CompactHeader 两个组合 — `src/components/ui/surface.tsx:46-110`
- [ ] **死代码清理**:移除 69 处 no-op `<Blur>` 包裹与 `DemoModeProvider enabled={false}` — `src/components/Blur.tsx:13-16`、`Shell.tsx:93`
- [ ] **杂项**:`cn.ts` 换 clsx + tailwind-merge — `src/lib/cn.ts`;RotatingFooterHint keyframes 移入 index.css — `src/components/RotatingFooterHint.tsx:43-48`;UnauthorizedPage 英文 eyebrow 中文化 — `src/auth/UnauthorizedPage.tsx:10`;简化 index.css 双层滚动容器 — `src/index.css:9-26`;相对时间 `relativeNow` 5s 整表重渲染优化 — `src/modules/mileage/MonitoringView.tsx:131-134`;Top5 榜 Y 轴刻度与 width 解耦 — `src/modules/energy/hydrogen-overview/components/DistributionCharts.tsx:31-36,62`;饼图 >9 区域颜色循环失真 — 同文件 :15-19,109;水印时间戳随会话刷新 — `src/components/Shell.tsx:86-90`
---
## 已确认的正面实践(保持,勿回退)
- 氢能总览:骨架屏 + 非打断 RefreshOverlay(全站推广模板)
- VehicleTable 可点行:role/tabIndex/aria-pressed/键盘支持、吸顶首列 + 右侧阴影
- GroupRow 的 aria-expanded;日报日期写回 URL 可刷新恢复
- BatchTable 数字右对齐 + tabular-nums(资产域对齐目标)
- FlowDetailModal 移动端卡片模式(VehicleDetailModal 降级参考)
- 动效克制(layoutId 弹簧/FadeIn/shimmer 均为 transform/opacity 级)
---
## 建议实施顺序
| 阶段 | 内容 |
|---|---|
| 第 1 周 | P0 写操作反馈 / 上传工作流 / 数据可信 / 小 Bug 集群 |
| 第 2 周 | P0 可访问性(Modal 壳 + useScrollLock + 下拉组件)+ 加载范式统一 + 布局错位 |
| 第 3 周 | P1 移动端专项 + 视觉一致性 |
| 持续 | P2 令牌化 → 格式化/日期层 → 配置化(重构方案阶段 5/6) |
@@ -1,31 +0,0 @@
-- Explicit additive migration. No business tables are changed and no historical records are purged.
CREATE TABLE IF NOT EXISTS bi_asset_ai_questions (
id CHAR(36) PRIMARY KEY,
actor_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
login_name VARCHAR(255) NOT NULL,
user_name VARCHAR(255) NOT NULL,
permission_level VARCHAR(24) NOT NULL,
is_preview TINYINT(1) NOT NULL DEFAULT 0,
preview_session_id VARCHAR(64) NULL,
session_id VARCHAR(64) NULL,
base_result_id VARCHAR(64) NULL,
result_id VARCHAR(64) NULL,
subject VARCHAR(150) NULL,
event_type VARCHAR(24) NOT NULL,
question TEXT NULL,
action_json JSON NULL,
outcome VARCHAR(24) NOT NULL,
reason VARCHAR(64) NULL,
business_types JSON NULL,
engine VARCHAR(24) NULL,
answer MEDIUMTEXT NULL,
started_at DATETIME(3) NOT NULL,
finished_at DATETIME(3) NULL,
duration_ms BIGINT UNSIGNED NULL,
KEY user_time (user_id, started_at),
KEY actor_time (actor_id, started_at),
KEY result_time (outcome, started_at),
KEY session_time (session_id, started_at),
KEY event_time (event_type, started_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-43
View File
@@ -1,43 +0,0 @@
-- Explicit migration only; API reads and module imports never create storage.
CREATE TABLE IF NOT EXISTS bi_asset_history_settings (
id TINYINT NOT NULL PRIMARY KEY,
enabled_at DATETIME(3) NOT NULL,
CONSTRAINT chk_asset_history_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS bi_asset_history_batches (
id VARCHAR(36) NOT NULL PRIMARY KEY,
period_month CHAR(7) NOT NULL,
definition_version VARCHAR(32) NOT NULL,
scheduled_for DATETIME(3) NOT NULL,
captured_at DATETIME(3) NULL,
completed_at DATETIME(3) NULL,
timezone VARCHAR(32) NOT NULL DEFAULT 'Asia/Shanghai',
source ENUM('AUTO','IMPORT') NOT NULL,
status ENUM('COMPLETE','FAILED','MISSED') NOT NULL,
failure_code VARCHAR(64) NULL,
total_count INT UNSIGNED NOT NULL DEFAULT 0,
operating_count INT UNSIGNED NOT NULL DEFAULT 0,
inventory_count INT UNSIGNED NOT NULL DEFAULT 0,
import_provenance JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_asset_history_period (period_month, definition_version),
KEY idx_asset_history_status (status, period_month),
CONSTRAINT chk_asset_history_counts CHECK (total_count = operating_count + inventory_count)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS bi_asset_history_vehicles (
batch_id VARCHAR(36) NOT NULL,
vehicle_id VARCHAR(64) NOT NULL,
operation_status VARCHAR(50) NOT NULL,
vehicle_status VARCHAR(50) NULL,
disposition ENUM('operating','inventory') NOT NULL,
subject_org VARCHAR(500) NULL,
department_name VARCHAR(500) NULL,
manager_id VARCHAR(64) NULL,
frozen_vehicle JSON NOT NULL,
PRIMARY KEY (batch_id, vehicle_id),
KEY idx_asset_history_subject (batch_id, subject_org(128)),
KEY idx_asset_history_manager (batch_id, manager_id),
CONSTRAINT fk_asset_history_batch FOREIGN KEY (batch_id) REFERENCES bi_asset_history_batches (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -1,4 +0,0 @@
-- Run once after 20261019_asset_history.sql; stop old capture workers before migration.
-- Existing YYYY-MM keys and archived vehicle rows remain unchanged.
ALTER TABLE bi_asset_history_batches CHANGE COLUMN period_month period_key CHAR(10) NOT NULL;
ALTER TABLE bi_asset_history_settings ADD COLUMN daily_enabled_at DATETIME(3) NULL;
+81 -3957
View File
File diff suppressed because it is too large Load Diff
+2 -9
View File
@@ -1,7 +1,7 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"private": true, "private": true,
"version": "1.1.19", "version": "1.1.13",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
@@ -9,27 +9,20 @@
"dev:client": "vite --port=3000 --host=0.0.0.0", "dev:client": "vite --port=3000 --host=0.0.0.0",
"build": "vite build", "build": "vite build",
"start": "node --import tsx src/server/index.ts", "start": "node --import tsx src/server/index.ts",
"lint": "tsc --noEmit", "lint": "tsc --noEmit"
"test": "node --import tsx --test $(find src -type f -name '*.test.ts' -print | sort)",
"test:mileage-report": "node --import tsx --test src/server/routes/mileage/daily-report-model.test.ts"
}, },
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1", "@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0", "@hono/node-server": "^1.13.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"ali-oss": "^6.23.0", "ali-oss": "^6.23.0",
"class-variance-authority": "^0.7.1",
"cn": "^0.4.0",
"dotenv": "^16.4.0", "dotenv": "^16.4.0",
"echarts": "^6.1.0",
"exceljs": "4.4.0",
"hono": "^4.7.0", "hono": "^4.7.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"mysql2": "^3.11.0", "mysql2": "^3.11.0",
"pg": "^8.22.0", "pg": "^8.22.0",
"radix-ui": "^1.6.7",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",
-20
View File
@@ -1,20 +0,0 @@
// Does not load dotenv, database pools, application bootstrap or background jobs.
import { readFile } from 'node:fs/promises';
import { resolve, relative, sep } from 'node:path';
import { publishReconstruction, validateReconstruction } from '../src/server/routes/asset-history/reconstruction.js';
const args = process.argv.slice(2);
const inputIndex = args.indexOf('--input'), outputIndex = args.indexOf('--output');
if (inputIndex < 0 || !args[inputIndex + 1]) throw new Error('需要 --input 指定已确认的回溯文件');
const data = validateReconstruction(JSON.parse(await readFile(resolve(args[inputIndex + 1]), 'utf8')));
console.log(JSON.stringify({ action: args.includes('--apply') ? 'local-file-publication' : 'dry-run', months: data.periods.length,
firstMonth: data.periods[0]?.month, lastMonth: data.periods.at(-1)?.month, sourceAsOf: data.sourceAsOf,
workbookSha256: data.workbookSha256, historicalTotal: null, databaseWrites: 0 }));
if (args.includes('--apply')) {
if (outputIndex < 0 || !args[outputIndex + 1]) throw new Error('需要 --output 指定本地私有存储位置');
const output = resolve(args[outputIndex + 1]);
for (const folder of ['public', 'dist', 'src']) {
const within = relative(resolve(folder), output);
if (!within || (!within.startsWith(`..${sep}`) && within !== '..' && !within.startsWith(sep))) throw new Error('回溯文件必须保存于源码及公开静态目录之外');
}
console.log(await publishReconstruction(output, data));
}
@@ -1,78 +0,0 @@
import type { RowDataPacket } from 'mysql2';
import pool from '../src/server/db.js';
import type {
DailyMileageReport,
MileageReportTrendPoint,
} from '../src/shared/mileage/daily-report.js';
import { fetchOneOsMileageDates } from '../src/server/routes/mileage/oneos-api.js';
import { DEFAULT_ONEOS_PROTOCOL_PRIORITY } from '../src/server/routes/mileage/source-policy.js';
import { roundMileage } from '../src/server/routes/mileage/daily-report-model.js';
import { updateDailyReportSnapshotPayload } from '../src/server/routes/mileage/daily-report-store.js';
interface SnapshotRow extends RowDataPacket {
report_date: string;
source: string;
report_payload: DailyMileageReport | string;
}
function reportDates(reportDate: string): string[] {
const end = new Date(`${reportDate}T00:00:00Z`);
return Array.from({ length: 7 }, (_, index) => {
const date = new Date(end);
date.setUTCDate(end.getUTCDate() - (6 - index));
return date.toISOString().slice(0, 10);
});
}
function parseReport(value: DailyMileageReport | string): DailyMileageReport {
return typeof value === 'string' ? JSON.parse(value) as DailyMileageReport : value;
}
async function trendByPlate(report: DailyMileageReport): Promise<Map<string, MileageReportTrendPoint[]>> {
const dates = reportDates(report.reportDate);
const plates = report.groups.flatMap(group => group.vehicles.map(vehicle => vehicle.plate));
const rowsByDate = await fetchOneOsMileageDates(dates, plates, DEFAULT_ONEOS_PROTOCOL_PRIORITY);
const result = new Map<string, MileageReportTrendPoint[]>();
for (const plate of plates) {
result.set(plate, dates.map(date => {
const mileage = (rowsByDate.get(date) || [])
.filter(row => row.status === 'NORMAL' && row.plateNumber === plate)
.reduce((maximum, row) => Math.max(maximum, Number(row.dailyMileageKm) || 0), 0);
return { date, totalMileage: roundMileage(Math.max(0, mileage)) };
}));
}
return result;
}
const apply = process.argv.includes('--apply');
const [rows] = await pool.query<SnapshotRow[]>(`
SELECT DATE_FORMAT(report_date, '%Y-%m-%d') AS report_date, source, report_payload
FROM lingniu_prod.tab_mileage_daily_report
WHERE source <> 'XLSX_IMPORT'
ORDER BY report_date
`);
try {
for (const row of rows) {
const report = parseReport(row.report_payload);
if (report.groups.every(group => group.vehicles.every(vehicle => vehicle.trend?.length === 7))) {
console.log(`${row.report_date}: already complete`);
continue;
}
const trends = await trendByPlate(report);
const updated: DailyMileageReport = {
...report,
groups: report.groups.map(group => ({
...group,
vehicles: group.vehicles.map(vehicle => ({
...vehicle,
trend: trends.get(vehicle.plate) || [],
})),
})),
};
if (apply) await updateDailyReportSnapshotPayload(updated);
console.log(`${row.report_date}: ${apply ? 'updated' : 'would update'} ${trends.size} vehicles`);
}
} finally {
await pool.end();
}
-223
View File
@@ -1,223 +0,0 @@
import XLSX from 'xlsx';
import type { WorkBook } from 'xlsx';
import type { RowDataPacket } from 'mysql2';
import pool from '../src/server/db.js';
import type {
MileageReportBreakdown,
MileageReportGroup,
MileageReportVehicle,
} from '../src/shared/mileage/daily-report.js';
import {
buildDailyMileageReport,
mileageBand,
roundMileage,
} from '../src/server/routes/mileage/daily-report-model.js';
import { saveDailyReportSnapshot } from '../src/server/routes/mileage/daily-report-store.js';
interface TargetRow extends RowDataPacket { id: number; target_name: string }
interface ImportSpec {
date: string;
file: string;
}
const SHEET_SPECS = [
{ sheet: '40台普货', targetMatch: '交投40', summaryRow: 2, displayName: '40台普货' },
{ sheet: '190台冷藏车', targetMatch: '交投190', summaryRow: 3, displayName: '190台冷链' },
{ sheet: '136台冷藏车', targetMatch: '羚牛136', summaryRow: 4, displayName: '136台冷链' },
{ sheet: '100台双飞翼', targetMatch: '羚牛100', summaryRow: 5, displayName: '100台18T双飞翼' },
{ sheet: '50台普货', targetMatch: '恒运', summaryRow: 6, displayName: '26台恒运普货' },
] as const;
const INVENTORY_REGIONS: Record<string, Record<string, Record<string, number>>> = {
'2026-03-30': {
'40台普货': { 广东: 2 },
'190台冷链': { 嘉兴: 2, 广东: 38 },
'136台冷链': { 新疆: 1, 嘉兴: 13, 广东: 31 },
'100台18T双飞翼': { 嘉兴: 9, 广东: 29 },
'26台恒运普货': { 广东: 11, 嘉兴: 13 },
},
'2026-03-31': {
'40台普货': { 广东: 2 },
'190台冷链': { 嘉兴: 2, 广东: 36 },
'136台冷链': { 新疆: 1, 嘉兴: 13, 广东: 32 },
'100台18T双飞翼': { 嘉兴: 8, 广东: 29 },
'26台恒运普货': { 广东: 11, 嘉兴: 12 },
},
};
function parseArgs(values: string[]): ImportSpec[] {
return values.map(value => {
const split = value.indexOf(':');
if (split < 10) throw new Error(`参数格式应为 YYYY-MM-DD:/path/file.xlsx,收到 ${value}`);
const date = value.slice(0, split);
const file = value.slice(split + 1);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !file) throw new Error(`参数不合法:${value}`);
return { date, file };
});
}
function valueNumber(value: unknown): number {
return Number(value) || 0;
}
function sheetRows(workbook: WorkBook, name: string): unknown[][] {
const sheet = workbook.Sheets[name];
if (!sheet) throw new Error(`工作表不存在:${name}`);
return XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, raw: true, defval: null });
}
function trendDate(reportDate: string, label: unknown): string {
const match = /^(\d{1,2})\.(\d{1,2})日/.exec(String(label || ''));
if (!match) throw new Error(`无法识别趋势日期:${String(label)}`);
let year = Number(reportDate.slice(0, 4));
const candidate = `${year}-${String(Number(match[1])).padStart(2, '0')}-${String(Number(match[2])).padStart(2, '0')}`;
if (candidate > reportDate) year -= 1;
return `${year}-${String(Number(match[1])).padStart(2, '0')}-${String(Number(match[2])).padStart(2, '0')}`;
}
function breakdown(rows: MileageReportVehicle[], key: 'department'): MileageReportBreakdown[] {
const grouped = new Map<string, MileageReportVehicle[]>();
for (const row of rows) {
const name = row[key] || '未标注';
const items = grouped.get(name) || [];
items.push(row);
grouped.set(name, items);
}
return Array.from(grouped.entries()).map(([name, items]) => ({
name,
vehicleCount: items.length,
totalMileage: roundMileage(items.reduce((total, item) => total + item.dailyMileage, 0)),
averageMileage: items.length > 0
? roundMileage(items.reduce((total, item) => total + item.dailyMileage, 0) / items.length)
: 0,
})).sort((a, b) => b.vehicleCount - a.vehicleCount);
}
function inventoryBreakdown(date: string, groupName: string): MileageReportBreakdown[] {
return Object.entries(INVENTORY_REGIONS[date]?.[groupName] || {}).map(([name, count]) => ({
name,
vehicleCount: count,
totalMileage: 0,
averageMileage: 0,
}));
}
function generatedAt(date: string): string {
const next = new Date(`${date}T00:00:00Z`);
next.setUTCDate(next.getUTCDate() + 1);
return `${next.toISOString().slice(0, 10)}T09:30:00+08:00`;
}
async function importWorkbook(spec: ImportSpec, targets: TargetRow[]): Promise<void> {
const workbook = XLSX.readFile(spec.file, { cellDates: true });
const summary = sheetRows(workbook, '汇总');
const groups: MileageReportGroup[] = SHEET_SPECS.map(sheetSpec => {
const rows = sheetRows(workbook, sheetSpec.sheet);
const headerIndex = rows.findIndex(row => row[0] === '序号');
if (headerIndex < 0) throw new Error(`${sheetSpec.sheet} 未找到明细表头`);
const header = rows[headerIndex];
const currentDayIndex = header.findIndex(value => String(value || '').includes(`${Number(spec.date.slice(5, 7))}.${Number(spec.date.slice(8, 10))}日行驶里程`));
if (currentDayIndex < 0) throw new Error(`${sheetSpec.sheet} 未找到 ${spec.date} 日里程列`);
const trendStartIndex = currentDayIndex - 6;
const trendDates = header.slice(trendStartIndex, currentDayIndex + 1).map(label => trendDate(spec.date, label));
const detailRows = rows.slice(headerIndex + 1).filter(row => typeof row[0] === 'number');
const vehicles: MileageReportVehicle[] = detailRows.map(row => {
const rawStatus = String(row[1] || '在库');
const dailyMileage = roundMileage(valueNumber(row[currentDayIndex]));
const status = rawStatus === '租赁' || rawStatus === '自营' ? rawStatus : '在库';
return {
plate: String(row[2] || ''),
status,
customer: row[3] ? String(row[3]) : null,
department: status === '在库' || !row[4] ? null : String(row[4]),
inventoryRegion: null,
dailyMileage,
cumulativeMileage: roundMileage(valueNumber(row[13])),
completionRate: Math.round(valueNumber(row[17]) * 10000) / 100,
mileageBand: mileageBand(status, dailyMileage),
trend: trendDates.map((date, index) => ({
date,
totalMileage: roundMileage(valueNumber(row[trendStartIndex + index])),
})),
};
});
const operating = vehicles.filter(vehicle => vehicle.mileageBand !== 'INVENTORY');
const inventory = vehicles.filter(vehicle => vehicle.mileageBand === 'INVENTORY');
const summaryRow = summary[sheetSpec.summaryRow];
const target = targets.find(item => item.target_name.includes(sheetSpec.targetMatch));
if (!target) throw new Error(`未找到考核目标:${sheetSpec.targetMatch}`);
const periods = String(summaryRow[5] || '').split(/\n+|\s{2,}/).map(value => value.trim()).filter(Boolean);
const trend = trendDates.map((date, index) => ({
date,
totalMileage: roundMileage(detailRows.reduce((total, row) => total + valueNumber(row[trendStartIndex + index]), 0)),
}));
const dailyMileage = roundMileage(vehicles.reduce((total, vehicle) => total + vehicle.dailyMileage, 0));
const yesterdayMileage = trend[trend.length - 2]?.totalMileage || 0;
const currentYearTarget = roundMileage(valueNumber(summaryRow[10]));
const currentYearCompleted = roundMileage(valueNumber(summaryRow[11]));
return {
targetId: target.id,
targetName: target.target_name,
displayName: sheetSpec.displayName,
vehicleCount: vehicles.length,
operatingCount: operating.length,
inventoryCount: inventory.length,
dailyMileage,
operatingMileage: roundMileage(operating.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)),
inventoryMileage: roundMileage(inventory.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)),
yesterdayMileage,
dayOverDayDelta: 0,
dayOverDayRate: null,
lowMileageCount: operating.filter(vehicle => vehicle.mileageBand === 'LOW').length,
highMileageCount: operating.filter(vehicle => vehicle.mileageBand === 'HIGH').length,
cumulativeMileage: roundMileage(valueNumber(summaryRow[3])),
totalMileageTarget: roundMileage(valueNumber(summaryRow[2])),
completionRate: Math.round(valueNumber(summaryRow[4]) * 10000) / 100,
qualifiedCount: valueNumber(summaryRow[7]),
halfQualifiedCount: valueNumber(summaryRow[8]),
currentYearTarget,
currentYearCompleted,
remainingMileage: roundMileage(valueNumber(summaryRow[12])),
dailyRequiredMileage: roundMileage(valueNumber(summaryRow[14])),
assessmentPeriods: periods,
trend,
inventoryRegions: inventoryBreakdown(spec.date, sheetSpec.displayName),
departments: breakdown(operating, 'department'),
vehicles: vehicles.sort((a, b) => b.dailyMileage - a.dailyMileage),
};
});
const qualityNotes = [
{ level: 'info' as const, message: '历史日报由已审核人工台账导入,车辆规模与当日台账保持一致。' },
];
if (spec.date === '2026-03-30') {
qualityNotes.push({ level: 'info', message: '136台冷链库存按车辆明细修正为45台;100km车辆按低里程口径统计。' });
}
qualityNotes.push({ level: 'info', message: '190台冷链累计里程采用190辆明细求和及汇总表值,忽略明细页失效的手工合计单元格。' });
const report = buildDailyMileageReport({
reportDate: spec.date,
status: 'ARCHIVED',
source: 'XLSX_IMPORT',
generatedAt: generatedAt(spec.date),
sourceUpdatedAt: null,
groups,
qualityNotes,
});
const inserted = await saveDailyReportSnapshot(report, 'XLSX_IMPORT', { replace });
console.log(`${spec.date}: ${inserted ? (replace ? 'replaced' : 'imported') : 'already exists'}; ${report.totals.vehicleCount} vehicles; ${report.totals.dailyMileage} km`);
}
const args = process.argv.slice(2);
const replace = args.includes('--replace');
const specs = parseArgs(args.filter(value => value !== '--replace'));
if (specs.length === 0) throw new Error('请提供至少一个 YYYY-MM-DD:/path/file.xlsx 参数');
const [targets] = await pool.query<TargetRow[]>(
'SELECT id, target_name FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
);
try {
for (const spec of specs) await importWorkbook(spec, targets);
} finally {
await pool.end();
}
-16
View File
@@ -1,16 +0,0 @@
import { readFile } from 'node:fs/promises';
// Import the pool only after an explicit apply flag, to avoid accidental connections in review mode.
const sql = await readFile(new URL('../migrations/20260928_asset_ai_questions.sql', import.meta.url), 'utf8');
if (!process.argv.includes('--apply')) {
console.log(sql);
console.log('Review only. To create this audit table in the configured DB, pass --apply.');
} else {
const { default: pool } = await import('../src/server/db.js');
try {
await pool.query(sql);
const [rows] = await pool.query('SELECT COUNT(*) AS columns_count FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=?', ['bi_asset_ai_questions']);
console.log('AI question audit table ready.', rows);
} finally {
await pool.end();
}
}
-13
View File
@@ -1,13 +0,0 @@
import { readFile } from 'node:fs/promises';
const migration = process.argv.includes('--daily') ? '20261030_asset_history_daily.sql' : '20261019_asset_history.sql';
const sql = await readFile(new URL(`../migrations/${migration}`, import.meta.url), 'utf8');
if (!process.argv.includes('--apply')) {
console.log(sql);
console.log('Review only. Pass --apply only after approving the configured database migration.');
} else {
const { default: pool } = await import('../src/server/db.js');
try {
for (const statement of sql.split(/;\s*(?:\n|$)/).filter(s => s.trim())) await pool.query(statement);
console.log('Asset history schema ready. Capture is still disabled unless ASSET_HISTORY_CAPTURE_ENABLED=1.');
} finally { await pool.end(); }
}
+144 -26
View File
@@ -1,30 +1,142 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { Shell } from "./components/Shell"; import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt, MapPinned } from "lucide-react";
import { Shell, type ModuleConfig } from "./components/Shell";
import AuthProvider from "./auth/AuthProvider"; import AuthProvider from "./auth/AuthProvider";
import PermissionPreview, { PermissionPreviewPage } from "./auth/PermissionPreview";
import { useAuth } from "./auth/useAuth"; import { useAuth } from "./auth/useAuth";
import UnauthorizedPage from "./auth/UnauthorizedPage"; import UnauthorizedPage from "./auth/UnauthorizedPage";
import { canAccessEnergy } from "./shared/auth/roles"; import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles";
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface"; import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
import { buildModules } from "./app/modules";
import {
normalizeBrowserPath,
readBrowserRoute,
} from "./app/routing";
const AssetsModule = lazy(() => import("./modules/assets/AssetsModule"));
const MileageModule = lazy(() => import("./modules/mileage/MileageModule"));
const SchedulingModule = lazy(() => import("./modules/scheduling/SchedulingModule"));
const HydrogenModule = lazy(() => import("./modules/energy/HydrogenModule"));
const ElectricModule = lazy(() => import("./modules/energy/ElectricModule"));
const EtcModule = lazy(() => import("./modules/energy/EtcModule"));
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage")); const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage")); const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
normalizeBrowserPath(); const VehicleHeatmapModule = lazy(() => import("./modules/vehicle-heatmap/VehicleHeatmapModule"));
const HydrogenHeatmapModule = lazy(() => import("./modules/hydrogen-heatmap/HydrogenHeatmapModule"));
const ASSETS_MODULE: ModuleConfig = {
id: "assets",
label: "资产管理",
icon: Truck,
component: AssetsModule,
};
const MILEAGE_MODULE: ModuleConfig = {
id: "mileage",
label: "里程管理",
icon: Route,
component: MileageModule,
};
const VEHICLE_HEATMAP_MODULE: ModuleConfig = {
id: "vehicle-heatmap",
label: "车辆",
icon: MapPinned,
component: VehicleHeatmapModule,
};
const SCHEDULING_MODULE: ModuleConfig = {
id: "scheduling",
label: "智能调度",
icon: Activity,
component: SchedulingModule,
};
const HYDROGEN_MODULE: ModuleConfig = {
id: "hydrogen",
label: "氢能",
icon: Fuel,
component: HydrogenModule,
};
const HYDROGEN_HEATMAP_MODULE: ModuleConfig = {
id: "hydrogen-heatmap",
label: "加氢",
icon: MapPinned,
component: HydrogenHeatmapModule,
};
const ELECTRIC_MODULE: ModuleConfig = {
id: "electric",
label: "电能",
icon: BatteryCharging,
component: ElectricModule,
};
const ETC_MODULE: ModuleConfig = {
id: "etc",
label: "ETC",
icon: Receipt,
component: EtcModule,
};
/**
* 把旧路径 / 根路径归一化到 `/asset` 或 `/energy` 主路径,
* 必要时携带 hash 一段定位到具体模块。已是主路径或后台管理页则不动。
*/
function normalizePath() {
if (typeof window === "undefined") return;
const { pathname, hash, search } = window.location;
// 主路径 & 隐藏后台页保持不变
if (pathname === "/asset" || pathname === "/energy") return;
if (pathname === "/ele/import" || pathname === "/admin/feedback") return;
const legacyMap: Record<string, { path: string; hash?: string }> = {
"/": { path: "/asset" },
"/vehicle": { path: "/asset", hash: "assets" },
"/assets": { path: "/asset", hash: "assets" },
"/mileage": { path: "/asset", hash: "mileage" },
"/scheduling": { path: "/asset", hash: "scheduling" },
};
// 未知路径兜底到 /asset(保留原 hash 让 Shell 内部继续解析)
const target = legacyMap[pathname] ?? { path: "/asset" };
const finalHash = target.hash ? `#${target.hash}` : hash || "";
window.history.replaceState(null, "", `${target.path}${search}${finalHash}`);
}
normalizePath();
type PathSet = "asset" | "energy";
function getPathSet(): PathSet {
return window.location.pathname === "/energy" ? "energy" : "asset";
}
function getRouteKey(): string {
if (typeof window === "undefined") return "";
const path = window.location.pathname;
const hash = window.location.hash;
if (
path === "/ele/import" ||
hash === "#/ele/import" ||
hash === "#ele/import"
)
return "ele/import";
if (
path === "/admin/feedback" ||
hash === "#/admin/feedback" ||
hash === "#admin/feedback"
)
return "admin/feedback";
return "";
}
function AuthGate() { function AuthGate() {
const { isLoading, isAuthenticated, error, user } = useAuth(); const { isLoading, isAuthenticated, error, user } = useAuth();
const [route, setRoute] = useState(readBrowserRoute); const [routeKey, setRouteKey] = useState(getRouteKey);
const { routeKey, pathSet } = route; const [pathSet, setPathSet] = useState<PathSet>(getPathSet);
// 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效 // 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效
useEffect(() => { useEffect(() => {
const update = () => { const update = () => {
setRoute(readBrowserRoute()); setRouteKey(getRouteKey());
setPathSet(getPathSet());
}; };
window.addEventListener("hashchange", update); window.addEventListener("hashchange", update);
window.addEventListener("popstate", update); window.addEventListener("popstate", update);
@@ -35,13 +147,26 @@ function AuthGate() {
}, []); }, []);
useEffect(() => { useEffect(() => {
document.title = routeKey === "who" ? "权限视角 - 羚牛氢能 BI" : pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI"; document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
}, [pathSet, routeKey]); }, [pathSet]);
const modules = useMemo( const modules = useMemo<ModuleConfig[]>(() => {
() => buildModules(pathSet, user?.roles), if (pathSet === "energy") {
[pathSet, user?.roles], return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
); }
const heatmapChildren = canAccessEnergy(user?.roles)
? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE]
: [VEHICLE_HEATMAP_MODULE];
const heatmapModule: ModuleConfig = {
id: "heatmap",
label: "热力图",
icon: MapPinned,
children: heatmapChildren,
};
const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE, heatmapModule];
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
return result;
}, [pathSet, user?.roles]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -69,8 +194,6 @@ function AuthGate() {
return <UnauthorizedPage message={error || undefined} />; return <UnauthorizedPage message={error || undefined} />;
} }
if (routeKey === "who") return <PermissionPreviewPage />;
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现 // 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
if (routeKey === "ele/import") { if (routeKey === "ele/import") {
return ( return (
@@ -96,15 +219,10 @@ function AuthGate() {
return <Shell key={pathSet} modules={modules} />; return <Shell key={pathSet} modules={modules} />;
} }
function AuthenticatedWorkspace() {
const { identityVersion = 0 } = useAuth();
return <PermissionPreview><AuthGate key={identityVersion} /></PermissionPreview>;
}
export default function App() { export default function App() {
return ( return (
<AuthProvider> <AuthProvider>
<AuthenticatedWorkspace /> <AuthGate />
</AuthProvider> </AuthProvider>
); );
} }
-39
View File
@@ -1,39 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildModules } from './modules.js';
function moduleIds(roles?: string[]): string[] {
return buildModules('asset', roles).map(module => module.id);
}
function heatmapChildIds(roles?: string[]): string[] {
const heatmap = buildModules('asset', roles).find(module => module.id === 'heatmap');
return heatmap?.children?.map(module => module.id) ?? [];
}
test('keeps the base asset navigation and role-gated scheduling module', () => {
assert.deepEqual(moduleIds(), ['assets', 'faults', 'mileage', 'heatmap']);
assert.deepEqual(moduleIds(['BI-SCHEDULE-OPT']), [
'assets',
'faults',
'mileage',
'heatmap',
'scheduling',
]);
});
test('keeps energy heatmap visibility and the independent energy navigation', () => {
assert.deepEqual(heatmapChildIds(), ['vehicle-heatmap']);
assert.deepEqual(heatmapChildIds(['BI-LEADER-ENERGY']), [
'hydrogen-heatmap',
'vehicle-heatmap',
]);
assert.deepEqual(heatmapChildIds(['所有权限']), [
'hydrogen-heatmap',
'vehicle-heatmap',
]);
assert.deepEqual(
buildModules('energy', []).map(module => module.id),
['hydrogen', 'electric', 'etc'],
);
});
-75
View File
@@ -1,75 +0,0 @@
import { lazy } from 'react';
import {
Activity,
BatteryCharging,
Fuel,
MapPinned,
Receipt,
Route,
Truck,
Wrench,
} from 'lucide-react';
import type { ModuleConfig } from '../components/Shell';
import { canAccessEnergy, canAccessScheduling } from '../shared/auth/roles';
import type { PathSet } from './routing';
const AssetsModule = lazy(() => import('../modules/assets/AssetsModule'));
const FaultManagement = lazy(() => import('../modules/assets/faults/FaultManagement'));
const MileageModule = lazy(() => import('../modules/mileage/MileageModule'));
const SchedulingModule = lazy(() => import('../modules/scheduling/SchedulingModule'));
const HydrogenModule = lazy(() => import('../modules/energy/HydrogenModule'));
const ElectricModule = lazy(() => import('../modules/energy/ElectricModule'));
const EtcModule = lazy(() => import('../modules/energy/EtcModule'));
const VehicleHeatmapModule = lazy(() => import('../modules/vehicle-heatmap/VehicleHeatmapModule'));
const HydrogenHeatmapModule = lazy(() => import('../modules/hydrogen-heatmap/HydrogenHeatmapModule'));
const ASSET_MODULES: ModuleConfig[] = [
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
{ id: 'faults', label: '故障管理', icon: Wrench, component: FaultManagement },
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
];
const VEHICLE_HEATMAP_MODULE: ModuleConfig = {
id: 'vehicle-heatmap',
label: '车辆',
icon: MapPinned,
component: VehicleHeatmapModule,
};
const HYDROGEN_HEATMAP_MODULE: ModuleConfig = {
id: 'hydrogen-heatmap',
label: '加氢',
icon: MapPinned,
component: HydrogenHeatmapModule,
};
const SCHEDULING_MODULE: ModuleConfig = {
id: 'scheduling',
label: '智能调度',
icon: Activity,
component: SchedulingModule,
};
const ENERGY_MODULES: ModuleConfig[] = [
{ id: 'hydrogen', label: '氢能', icon: Fuel, component: HydrogenModule },
{ id: 'electric', label: '电能', icon: BatteryCharging, component: ElectricModule },
{ id: 'etc', label: 'ETC', icon: Receipt, component: EtcModule },
];
/** 根据主路径和角色生成导航;返回新数组,避免调用方修改静态注册表。 */
export function buildModules(
pathSet: PathSet,
roles: readonly string[] | null | undefined,
): ModuleConfig[] {
if (pathSet === 'energy') return [...ENERGY_MODULES];
const heatmapChildren = canAccessEnergy(roles)
? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE]
: [VEHICLE_HEATMAP_MODULE];
const modules: ModuleConfig[] = [
...ASSET_MODULES,
{ id: 'heatmap', label: '热力图', icon: MapPinned, children: heatmapChildren },
];
if (canAccessScheduling(roles)) modules.push(SCHEDULING_MODULE);
return modules;
}
-22
View File
@@ -1,22 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getPathSet, getRouteKey, resolveCanonicalUrl } from './routing.js';
test('normalizes legacy asset paths without losing search parameters', () => {
assert.equal(resolveCanonicalUrl({ pathname: '/mileage', search: '?date=2026-08-12', hash: '' }), '/asset?date=2026-08-12#mileage');
assert.equal(resolveCanonicalUrl({ pathname: '/unknown', search: '?x=1', hash: '#mileage' }), '/asset?x=1#mileage');
});
test('keeps canonical and hidden administration paths unchanged', () => {
assert.equal(resolveCanonicalUrl({ pathname: '/asset', search: '', hash: '#assets' }), null);
assert.equal(resolveCanonicalUrl({ pathname: '/admin/feedback', search: '', hash: '' }), null);
assert.equal(resolveCanonicalUrl({ pathname: '/who', search: '', hash: '' }), null);
});
test('derives path groups and hidden routes from path or hash', () => {
assert.equal(getPathSet('/energy'), 'energy');
assert.equal(getPathSet('/asset'), 'asset');
assert.equal(getRouteKey('/asset', '#/ele/import'), 'ele/import');
assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback');
assert.equal(getRouteKey('/who', ''), 'who');
assert.equal(getRouteKey('/asset', '#mileage'), '');
});
-57
View File
@@ -1,57 +0,0 @@
export type PathSet = 'asset' | 'energy';
interface BrowserLocation {
pathname: string;
search: string;
hash: string;
}
const ROOT_PATHS = new Set(['/asset', '/energy', '/ele/import', '/admin/feedback', '/who']);
const LEGACY_PATHS: Record<string, { path: string; hash?: string }> = {
'/': { path: '/asset' },
'/vehicle': { path: '/asset', hash: 'assets' },
'/assets': { path: '/asset', hash: 'assets' },
'/mileage': { path: '/asset', hash: 'mileage' },
'/scheduling': { path: '/asset', hash: 'scheduling' },
};
/** 返回旧地址应替换成的规范地址;主路径返回 null。 */
export function resolveCanonicalUrl(location: BrowserLocation): string | null {
if (ROOT_PATHS.has(location.pathname)) return null;
const target = LEGACY_PATHS[location.pathname] ?? { path: '/asset' };
const finalHash = target.hash ? `#${target.hash}` : location.hash;
return `${target.path}${location.search}${finalHash}`;
}
export function normalizeBrowserPath(): void {
if (typeof window === 'undefined') return;
const target = resolveCanonicalUrl(window.location);
if (target) window.history.replaceState(null, '', target);
}
export function getPathSet(pathname: string): PathSet {
return pathname === '/energy' ? 'energy' : 'asset';
}
export function getRouteKey(pathname: string, hash: string): string {
if (pathname === '/who') return 'who';
if (pathname === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') {
return 'ele/import';
}
if (
pathname === '/admin/feedback'
|| hash === '#/admin/feedback'
|| hash === '#admin/feedback'
) {
return 'admin/feedback';
}
return '';
}
export function readBrowserRoute(): { routeKey: string; pathSet: PathSet } {
return {
routeKey: getRouteKey(window.location.pathname, window.location.hash),
pathSet: getPathSet(window.location.pathname),
};
}
+31 -130
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, type ReactNode } from 'react'; import { useState, useEffect, useRef, type ReactNode } from 'react';
import { AuthContext, type AuthState, type AuthUser } from './useAuth'; import { AuthContext, type AuthState } from './useAuth';
import { setTokenGetter, setPreviewTokenGetter, invalidateAuthenticatedRequests, fetchPreviewControl } from './api-client'; import { setTokenGetter } from './api-client';
const AUTH_API = '/api/auth'; const AUTH_API = '/api/auth';
@@ -13,147 +13,43 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
}); });
const tokenRef = useRef<string | null>(null); const tokenRef = useRef<string | null>(null);
const authStarted = useRef(false); const authStarted = useRef(false);
const previewTokenRef = useRef<string | null>(null);
const actorRef = useRef<AuthUser | null>(null);
const transitionRef = useRef(0);
const [identityVersion, setIdentityVersion] = useState(0);
const [preview, setPreview] = useState<{ user: AuthUser; expiresAt: string } | null>(null);
const [canPreview, setCanPreview] = useState(false);
const [previewNotice, setPreviewNotice] = useState<string | null>(null);
function resetPreview(message: string | null = null) {
transitionRef.current += 1;
previewTokenRef.current = null;
sessionStorage.removeItem('bi_preview_token');
invalidateAuthenticatedRequests();
setIdentityVersion(v => v + 1);
setPreview(null);
setPreviewNotice(message);
}
async function loadPreviewContext(user: AuthUser | null) {
actorRef.current = user;
try {
const context = await fetchPreviewControl<{ canPreview: boolean; actor: AuthUser }>('context');
actorRef.current = context.actor;
setCanPreview(context.canPreview);
if (context.canPreview) {
const savedPreview = sessionStorage.getItem('bi_preview_token');
if (savedPreview) {
previewTokenRef.current = savedPreview;
try {
const current = await fetchPreviewControl<{ user: AuthUser; expiresAt: string }>('current', undefined, true);
setPreview(current);
} catch {
resetPreview('上次权限视角已失效,已返回本人视角。');
}
}
} else {
resetPreview();
}
} catch {
resetPreview();
setCanPreview(false);
}
}
async function finishAuthentication(user: AuthUser | null) {
await loadPreviewContext(user);
if (!tokenRef.current) return;
setState({ isLoading: false, isAuthenticated: true, user: actorRef.current, error: null });
}
async function startPreview(userId: string) {
const transition = ++transitionRef.current;
const next = await fetchPreviewControl<{ previewToken: string; user: AuthUser; expiresAt: string }>('start', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }),
});
if (transition !== transitionRef.current) return;
invalidateAuthenticatedRequests();
previewTokenRef.current = next.previewToken;
sessionStorage.setItem('bi_preview_token', next.previewToken);
setPreview({ user: next.user, expiresAt: next.expiresAt });
setIdentityVersion(v => v + 1);
setPreviewNotice(null);
}
async function stopPreview() {
const stopping = fetchPreviewControl('stop', { method: 'POST' }, true);
resetPreview();
await stopping.catch(() => { setPreviewNotice('已退出本地预览;服务端凭证将自动到期。'); });
}
useEffect(() => {
if (!preview) return;
const token = previewTokenRef.current;
const validate = async () => {
try {
const current = await fetchPreviewControl<{ user: AuthUser; expiresAt: string }>('current', undefined, true);
if (token !== previewTokenRef.current) return;
if (JSON.stringify(current.user) !== JSON.stringify(preview.user)) {
invalidateAuthenticatedRequests();
setPreview(current);
setIdentityVersion(v => v + 1);
}
} catch {
if (token === previewTokenRef.current) resetPreview('权限视角验证失败,已返回本人视角,请重新选择。');
}
};
const timer = window.setInterval(validate, 60_000);
const expiry = window.setTimeout(() => resetPreview('权限视角已到期,已返回本人视角。'), Math.max(0, Date.parse(preview.expiresAt) - Date.now()));
window.addEventListener('focus', validate);
return () => { clearInterval(timer); clearTimeout(expiry); window.removeEventListener('focus', validate); };
}, [preview]);
useEffect(() => { useEffect(() => {
// 设置全局 token getter // 设置全局 token getter
setTokenGetter(() => tokenRef.current); setTokenGetter(() => tokenRef.current);
setPreviewTokenGetter(() => previewTokenRef.current); // 防止 StrictMode 双重调用(jumpToken 一次性使用)
if (authStarted.current) return;
authStarted.current = true;
// 监听 401 事件 // 监听 401 事件
const onUnauthorized = () => { const onUnauthorized = () => {
resetPreview();
setCanPreview(false);
tokenRef.current = null; tokenRef.current = null;
sessionStorage.removeItem('bi_jwt'); sessionStorage.removeItem('bi_jwt');
sessionStorage.removeItem('bi_user');
actorRef.current = null;
setState({ isLoading: false, isAuthenticated: false, user: null, error: '会话已过期' }); setState({ isLoading: false, isAuthenticated: false, user: null, error: '会话已过期' });
}; };
window.addEventListener('auth:unauthorized', onUnauthorized); window.addEventListener('auth:unauthorized', onUnauthorized);
const onPreviewEnded = (event: Event) => { authenticate();
if ((event as CustomEvent).detail === 'PREVIEW_REVOKED') setCanPreview(false);
resetPreview('预览权限已失效,已返回本人视角。'); return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
};
const onReadOnly = () => setPreviewNotice('权限视角仅供查看数据,请退出预览后进行修改操作。');
window.addEventListener('auth:preview-ended', onPreviewEnded);
window.addEventListener('auth:preview-readonly', onReadOnly);
// StrictMode 重新安装监听,但一次性 jumpToken 仅交换一次。
if (!authStarted.current) { authStarted.current = true; void authenticate(); }
return () => {
window.removeEventListener('auth:unauthorized', onUnauthorized);
window.removeEventListener('auth:preview-ended', onPreviewEnded);
window.removeEventListener('auth:preview-readonly', onReadOnly);
};
}, []); }, []);
async function authenticate() { async function authenticate() {
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效 // 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') { if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
const devUser: AuthUser = { setState({
isLoading: false,
isAuthenticated: true,
user: {
userId: 'dev-local', userId: 'dev-local',
userName: '本地开发', userName: '本地开发',
permissionLevel: 'full', permissionLevel: 'full',
depName: '', depName: '',
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'], roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
}; },
// 预览授权仍由后端独立开发开关决定,不使用真实用户令牌。 error: null,
tokenRef.current = null; });
await loadPreviewContext(devUser);
actorRef.current ||= devUser;
setState({ isLoading: false, isAuthenticated: true, user: actorRef.current, error: null });
return; return;
} }
@@ -161,20 +57,22 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
const savedToken = sessionStorage.getItem('bi_jwt'); const savedToken = sessionStorage.getItem('bi_jwt');
if (savedToken) { if (savedToken) {
tokenRef.current = savedToken; tokenRef.current = savedToken;
// 从受保护的身份接口验证 JWT;不使用本地缓存用户决定权限。 // 验证 token 是否仍然有效(尝试请求 health)
try { try {
const res = await fetch('/api/auth/me', { const res = await fetch('/api/health', {
headers: { Authorization: `Bearer ${savedToken}` }, headers: { Authorization: `Bearer ${savedToken}` },
}); });
if (res.ok) { if (res.ok) {
const verifiedUser: AuthUser = await res.json(); const savedUser = sessionStorage.getItem('bi_user');
if (!verifiedUser.userId) throw new Error('无效的用户身份'); setState({
await finishAuthentication(verifiedUser); isLoading: false,
isAuthenticated: true,
user: savedUser ? JSON.parse(savedUser) : null,
error: null,
});
return; return;
} }
} catch { /* token 无效,继续流程 */ } } catch { /* token 无效,继续流程 */ }
tokenRef.current = null;
resetPreview();
sessionStorage.removeItem('bi_jwt'); sessionStorage.removeItem('bi_jwt');
sessionStorage.removeItem('bi_user'); sessionStorage.removeItem('bi_user');
} }
@@ -210,16 +108,19 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
: `${window.location.pathname}${window.location.hash}`; : `${window.location.pathname}${window.location.hash}`;
window.history.replaceState({}, '', cleanUrl); window.history.replaceState({}, '', cleanUrl);
await finishAuthentication(data.user); setState({
isLoading: false,
isAuthenticated: true,
user: data.user,
error: null,
});
} catch (e) { } catch (e) {
setState({ isLoading: false, isAuthenticated: false, user: null, error: '认证过程出错' }); setState({ isLoading: false, isAuthenticated: false, user: null, error: '认证过程出错' });
} }
} }
return ( return (
<AuthContext.Provider value={{ ...state, actor: actorRef.current || state.user, <AuthContext.Provider value={state}>
user: preview?.user || state.user, preview, canPreview, identityVersion, previewNotice,
startPreview, stopPreview, clearPreviewNotice: () => setPreviewNotice(null) }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
-31
View File
@@ -1,31 +0,0 @@
.permission-preview-banner{position:fixed;inset:0 0 auto;z-index:200;display:flex;align-items:center;gap:12px;min-height:56px;padding:9px 20px;background:#eff6ff;border-bottom:1px solid #adc8f4;color:#1e40af;font-size:12px;box-shadow:0 2px 8px #1d4ed10a}
.permission-preview-identity{flex:1;min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px}.permission-preview-identity strong{font-size:13px}.permission-preview-identity small{margin-left:14px;color:#64748b}
.permission-preview-banner button{border:1px solid #b9cef1;border-radius:6px;background:#fff;padding:6px 10px;white-space:nowrap}.permission-preview-banner button:hover{background:#dbeafe}
.permission-preview-active{padding-top:56px}.permission-preview-active .app-shell>nav.fixed.top-0{top:56px;height:calc(100% - 56px)}.permission-preview-active .app-shell-content>.fixed.top-0{top:56px}
.permission-preview-notice{position:fixed;top:64px;left:50%;transform:translateX(-50%);z-index:210;display:flex;align-items:center;gap:14px;padding:10px 14px;border:1px solid #f5d79e;border-radius:8px;background:#fff9ed;color:#92400e;font-size:12px;box-shadow:0 3px 12px #0f172a15;max-width:90vw}.permission-preview-notice button{flex-shrink:0}
.permission-preview-dialog{position:fixed;inset:0;margin:auto;width:min(700px,calc(100vw - 32px));max-height:calc(100dvh - 48px);padding:0;border:1px solid #d7e1ee;border-radius:14px;background:#fff;color:#334155;box-shadow:0 24px 90px #0f172a35;font-size:12px}
.permission-preview-dialog header{display:flex;justify-content:space-between;align-items:flex-start;padding:20px 22px 16px;border-bottom:1px solid #e1e8f0}.permission-preview-dialog h2{display:flex;align-items:center;gap:8px;margin:0;font-size:16px;font-weight:700;color:#1e293b}.permission-preview-dialog header p{margin:6px 0 0;color:#64748b}.permission-preview-dialog header button{padding:3px;color:#64748b}
.permission-preview-body{padding:18px 22px;overflow:auto;min-height:0}.permission-preview-guide{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.permission-preview-guide>div{padding:12px;border:1px solid #dce6f3;border-radius:8px;background:#f8fafc}.permission-preview-guide strong{display:block;color:#1e40af;font-size:12px}.permission-preview-guide code{display:block;margin-top:5px;font:11px ui-monospace,monospace;color:#64748b}.permission-preview-guide p{margin:7px 0 0;line-height:1.6;color:#64748b}.permission-preview-hint{margin:12px 0 16px;color:#64748b;font-size:11px;line-height:1.7}
.permission-preview-search{display:flex;gap:10px;margin-bottom:12px}.permission-preview-search label{display:flex;align-items:center;gap:8px;flex:1;border:1px solid #ced9e8;border-radius:7px;padding:0 10px;color:#94a3b8}.permission-preview-search input{width:100%;min-width:0;border:0;outline:0;padding:10px 0;color:#334155;background:transparent}.permission-preview-search label:focus-within{outline:2px solid #93b5ff;outline-offset:1px}.permission-preview-search select{border:1px solid #ced9e8;border-radius:7px;background:white;padding:8px;color:#475569}
.permission-preview-users{min-height:140px;border:1px solid #dce4ef;border-radius:8px;overflow:hidden}.permission-preview-users>label{display:flex;align-items:center;gap:10px;padding:11px 13px;border-bottom:1px solid #e5ebf3;cursor:pointer}.permission-preview-users>label:last-child{border-bottom:0}.permission-preview-users>label:hover{background:#f8faff}.permission-preview-users>label.is-selected{background:#eff6ff}.permission-preview-users input{accent-color:#2563eb}.permission-preview-users label>div{min-width:0;flex:1}.permission-preview-users strong{display:block;font-size:13px;color:#334155}.permission-preview-users span{display:block;font-size:11px;color:#64748b;margin-top:3px;overflow-wrap:anywhere}.permission-preview-users small{font-weight:400;font-size:10px;color:#2563eb}.permission-preview-users em{font-style:normal;padding:4px 7px;border-radius:5px;background:#f1f5f9;color:#475569;white-space:nowrap;font-size:11px}.permission-preview-empty{padding:35px 10px;text-align:center;color:#94a3b8}
.permission-preview-pages{display:flex;align-items:center;justify-content:flex-end;gap:12px;margin-top:12px;color:#64748b}.permission-preview-pages button{border:1px solid #dce4ef;padding:5px 9px;border-radius:5px}.permission-preview-dialog button:disabled{opacity:.45;cursor:not-allowed}.permission-preview-error{padding:9px 11px;margin:10px 0;background:#fff1f2;color:#be123c;border-radius:6px;display:flex;justify-content:space-between;gap:10px}.permission-preview-error button{text-decoration:underline}
.permission-preview-dialog footer{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 22px;border-top:1px solid #dce4ef;background:#f8fafc}.permission-preview-dialog footer strong{display:block;color:#334155;font-size:12px}.permission-preview-dialog footer span{display:block;color:#64748b;font-size:11px;margin-top:3px}.permission-preview-primary{padding:9px 13px;border-radius:7px;background:#2563eb;color:#fff;white-space:nowrap}.permission-preview-dialog button:focus-visible,.permission-preview-banner button:focus-visible{outline:2px solid #2563eb;outline-offset:3px}
@media(max-width:640px){.permission-preview-banner{gap:7px;padding:8px 10px;min-height:76px}.permission-preview-identity{display:block}.permission-preview-identity>span{display:block;margin-top:4px;font-size:10px}.permission-preview-identity small{display:block;margin:2px 0 0}.permission-preview-banner button{padding:6px;font-size:11px}.permission-preview-active{padding-top:76px}.permission-preview-notice{top:84px}.permission-preview-dialog{width:calc(100vw - 16px);max-height:calc(100dvh - 20px)}.permission-preview-body{padding:14px}.permission-preview-dialog header,.permission-preview-dialog footer{padding:14px}.permission-preview-guide{gap:6px}.permission-preview-guide>div{padding:8px}.permission-preview-guide code{font-size:9px}.permission-preview-guide p{font-size:10px}.permission-preview-search{gap:6px}.permission-preview-search select{max-width:116px}}
.permission-preview-active .asset-analysis-overlay,.permission-preview-active .bi-flow-modal-backdrop{top:56px}
@media(max-width:640px){.permission-preview-active .asset-analysis-overlay,.permission-preview-active .bi-flow-modal-backdrop{top:76px}}
.permission-preview-active .asset-analysis-dialog{min-height:0;max-height:calc(100dvh - 104px)}
.permission-preview-active .bi-flow-modal{max-height:calc(100dvh - 88px)}
@media(max-width:640px){.permission-preview-active .asset-analysis-dialog{max-height:calc(100dvh - 124px)}.permission-preview-active .bi-flow-modal{max-height:calc(100dvh - 108px)}}
.permission-preview-page{min-height:100dvh;padding:32px 20px;background:#f4f7fb;display:flex;align-items:flex-start;justify-content:center}
.permission-preview-active .permission-preview-page{min-height:calc(100dvh - 56px)}
.permission-preview-page .permission-preview-page-panel{position:static;inset:auto;display:flex;flex-direction:column;width:min(820px,100%);max-height:none;margin:0;border-color:#dce5f0;box-shadow:0 10px 35px #33415508}
.permission-preview-page-panel .permission-preview-body{overflow:visible}
.permission-preview-dialog header .permission-preview-back{display:inline-flex;align-items:center;justify-content:center;gap:7px;flex-shrink:0;min-height:44px;padding:10px 16px;border:1px solid #ced9e8;border-radius:8px;background:#f8fafc;font-size:14px;font-weight:600;color:#475569;white-space:nowrap;cursor:pointer;transition:background .15s,border-color .15s}.permission-preview-dialog header .permission-preview-back:hover:not(:disabled){background:#eff6ff;border-color:#adc8f4;color:#1d4ed8}
.permission-preview-denied{width:min(500px,100%);padding:36px;background:white;border:1px solid #dce5f0;border-radius:12px;text-align:center;color:#64748b}.permission-preview-denied>svg{margin:auto;color:#94a3b8}.permission-preview-denied h1{font-size:18px;color:#334155;margin:16px 0 8px}.permission-preview-denied p{font-size:13px}.permission-preview-denied a{display:inline-block;margin-top:20px;font-size:12px;color:#2563eb}
@media(max-width:640px){.permission-preview-page{padding:16px 10px}.permission-preview-active .permission-preview-page{min-height:calc(100dvh - 76px)}}
.permission-preview-page .permission-preview-page-panel{max-height:calc(100dvh - 64px)}
.permission-preview-page-panel .permission-preview-body{overflow:auto}
.permission-preview-page-panel>header,.permission-preview-page-panel>footer{flex-shrink:0}
.permission-preview-active .permission-preview-page-panel{max-height:calc(100dvh - 120px)}
@media(max-width:640px){.permission-preview-page .permission-preview-page-panel{max-height:calc(100dvh - 32px)}.permission-preview-active .permission-preview-page-panel{max-height:calc(100dvh - 108px)}}
-85
View File
@@ -1,85 +0,0 @@
import { useEffect, useState, type ReactNode } from 'react';
import { ArrowLeft, Eye, Search, ShieldCheck, X } from 'lucide-react';
import { fetchPreviewControl } from './api-client';
import { useAuth, type AuthUser } from './useAuth';
import './PermissionPreview.css';
const scopes = [
{ level: 'full', title: '全量数据', role: 'BI-Leader', detail: '查看全量业务数据。' },
{ level: 'department', title: '部门数据', role: 'BI-Leader-Dep', detail: '运营数据限本部门,库存查看全量。' },
{ level: 'personal', title: '本人数据', role: '其他角色默认', detail: '运营数据限本人负责,库存查看全量。' },
];
export function permissionScope(user: AuthUser) {
return scopes.find(s => s.level === user.permissionLevel)?.title || '本人数据';
}
export default function PermissionPreview({ children }: { children: ReactNode }) {
const { isAuthenticated, actor, preview, previewNotice, clearPreviewNotice, stopPreview } = useAuth();
return <div className={preview ? 'permission-preview-active' : undefined}>
{isAuthenticated && preview && <aside className="permission-preview-banner" aria-label="当前权限视角">
<Eye size={17} /><div className="permission-preview-identity"><strong>只读预览:{preview.user.userName}</strong><span>{preview.user.depName || '未分配部门'} · {permissionScope(preview.user)}<small>登录人:{actor?.userName}</small></span></div>
<button onClick={() => window.location.assign('/who')}>切换人员</button><button onClick={() => void stopPreview?.()}>退出预览</button>
</aside>}
{previewNotice && <div className="permission-preview-notice" role="status">{previewNotice}<button onClick={clearPreviewNotice} aria-label="关闭权限提示"><X size={14} /></button></div>}
{children}
</div>;
}
export function PermissionPreviewPage() {
const { canPreview } = useAuth();
return <main className="permission-preview-page">
{canPreview ? <PreviewPicker onClose={() => window.location.assign('/asset#assets')} /> : <section className="permission-preview-denied"><ShieldCheck size={28} /><h1>无权限访问</h1><p>当前账号未获授权使用权限视角。</p><a href="/asset#assets">返回 BI</a></section>}
</main>;
}
function PreviewPicker({ onClose }: { onClose: () => void }) {
const { startPreview, preview } = useAuth();
const [query, setQuery] = useState('');
const [level, setLevel] = useState('');
const [page, setPage] = useState(1);
const [users, setUsers] = useState<AuthUser[]>([]);
const [hasMore, setHasMore] = useState(false);
const [selected, setSelected] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [retry, setRetry] = useState(0);
const hasQuery = query.trim().length > 0;
useEffect(() => {
const controller = new AbortController();
setError(''); setUsers([]); setSelected(null); setHasMore(false);
if (!query.trim()) { setLoading(false); return; }
setLoading(true);
const timer = window.setTimeout(async () => {
try {
const params = new URLSearchParams({ q: query.trim(), page: String(page) });
if (level) params.set('level', level);
const result = await fetchPreviewControl<{ users: AuthUser[]; page: number; hasMore: boolean }>(`users?${params}`, { signal: controller.signal });
if (!controller.signal.aborted) { setUsers(result.users); setHasMore(result.hasMore); }
} catch (e) {
if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败');
} finally { if (!controller.signal.aborted) setLoading(false); }
}, 250);
return () => { clearTimeout(timer); controller.abort(); };
}, [query, level, page, retry]);
async function enter() {
if (!selected || busy) return;
setBusy(true); setError('');
try { await startPreview?.(selected.userId); onClose(); }
catch (e) { setError(e instanceof Error ? e.message : '切换失败,请重试'); setBusy(false); }
}
return <section className="permission-preview-dialog permission-preview-page-panel" aria-labelledby="permission-preview-title">
<header><div><h2 id="permission-preview-title"><ShieldCheck size={19} />权限视角</h2><p>选择人员,查看其实际页面权限与数据范围。</p></div><button disabled={busy} className="permission-preview-back" onClick={onClose}><ArrowLeft size={18} aria-hidden="true" />返回 BI</button></header>
<div className="permission-preview-body">
<div className="permission-preview-guide" aria-label="三类数据权限说明">{scopes.map(scope => <div key={scope.level}><strong>{scope.title}</strong><code>{scope.role}</code><p>{scope.detail}</p></div>)}</div>
<p className="permission-preview-hint">菜单和运营数据随目标用户权限变化;库存(含待交车、异动)统一展示全量,仍可按所属公司筛选。预览期间仅可查看,不能修改业务数据。</p>
<div className="permission-preview-search"><label><Search size={16} /><input autoFocus type="search" value={query} disabled={busy} onChange={e => { setQuery(e.target.value); setPage(1); }} placeholder="搜索中文姓名、用户名或部门" aria-label="搜索人员" /></label><select aria-label="数据范围筛选" value={level} disabled={busy} onChange={e => { setLevel(e.target.value); setPage(1); }}><option value="">全部数据范围</option>{scopes.map(s => <option key={s.level} value={s.level}>{s.title}</option>)}</select></div>
{error && <div className="permission-preview-error" role="alert">{error}<button disabled={busy} onClick={() => setRetry(v => v + 1)}>重试</button></div>}
{!hasQuery ? <p className="permission-preview-empty">输入中文姓名、用户名或部门关键词,模糊搜索人员。</p> : <div className="permission-preview-users" role="radiogroup" aria-label="选择预览用户" aria-busy={loading}>
{loading ? <p className="permission-preview-empty">正在加载人员…</p> : !users.length ? <p className="permission-preview-empty">未找到匹配人员,请调整搜索条件。</p> : users.map(user => <label key={user.userId} className={selected?.userId === user.userId ? 'is-selected' : ''}>
<input type="radio" name="preview-user" value={user.userId} checked={selected?.userId === user.userId} disabled={busy} onChange={() => setSelected(user)} />
<div><strong>{user.userName} {preview?.user.userId === user.userId && <small>当前视角</small>}</strong><span>{user.loginName || user.userId}</span></div><em>{permissionScope(user)}</em>
</label>)}
</div>}
{hasQuery && (hasMore || page > 1) && <div className="permission-preview-pages"><button disabled={page === 1 || loading || busy} onClick={() => setPage(v => v - 1)}>上一页</button><span>第 {page} 页</span><button disabled={!hasMore || loading || busy} onClick={() => setPage(v => v + 1)}>下一页</button></div>}
</div>
<footer><div>{selected ? <><strong>即将进入:{selected.userName}</strong><span>{permissionScope(selected)}</span></> : <span>请选择一位用户开始预览</span>}</div><button className="permission-preview-primary" disabled={!selected || busy || loading} onClick={() => void enter()}>{busy ? '正在切换…' : '进入该用户视角'}</button></footer>
</section>;
}
+99
View File
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
ApiRequestError,
_clearApiClientInflightForTests,
fetchJson,
setTokenGetter,
} from './api-client.js';
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
test('deduplicates concurrent GET requests for the same URL and token', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
let resolveFetch: ((response: Response) => void) | undefined;
globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => {
calls += 1;
assert.equal(new Headers(init?.headers).get('authorization'), 'Bearer test-token');
return new Promise<Response>(resolve => { resolveFetch = resolve; });
}) as typeof fetch;
setTokenGetter(() => 'test-token');
_clearApiClientInflightForTests();
try {
const first = fetchJson<{ value: number }>('/api/test');
const second = fetchJson<{ value: number }>('/api/test');
assert.equal(calls, 1);
resolveFetch?.(jsonResponse({ value: 42 }));
assert.deepEqual(await first, { value: 42 });
assert.deepEqual(await second, { value: 42 });
} finally {
globalThis.fetch = originalFetch;
setTokenGetter(() => null);
_clearApiClientInflightForTests();
}
});
test('does not retain a failed GET request', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = (async () => {
calls += 1;
if (calls === 1) throw new Error('network down');
return jsonResponse({ ok: true });
}) as typeof fetch;
setTokenGetter(() => null);
_clearApiClientInflightForTests();
try {
await assert.rejects(fetchJson('/api/retry'), /network down/);
assert.deepEqual(await fetchJson('/api/retry'), { ok: true });
assert.equal(calls, 2);
} finally {
globalThis.fetch = originalFetch;
_clearApiClientInflightForTests();
}
});
test('surfaces structured API errors without retaining the failed request', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = (async () => {
calls += 1;
if (calls > 1) return jsonResponse({ ok: true });
return new Response(JSON.stringify({
error: '当前无法读取氢能业务数据,请稍后重试',
code: 'HYDROGEN_SOURCE_UNAVAILABLE',
retryable: true,
}), {
status: 503,
headers: { 'content-type': 'application/json' },
});
}) as typeof fetch;
_clearApiClientInflightForTests();
try {
await assert.rejects(
fetchJson('/api/energy/hydrogen/overview'),
(error: unknown) => {
assert.ok(error instanceof ApiRequestError);
assert.equal(error.message, '当前无法读取氢能业务数据,请稍后重试');
assert.equal(error.status, 503);
assert.equal(error.code, 'HYDROGEN_SOURCE_UNAVAILABLE');
assert.equal(error.retryable, true);
return true;
},
);
assert.deepEqual(await fetchJson('/api/energy/hydrogen/overview'), { ok: true });
assert.equal(calls, 2);
} finally {
globalThis.fetch = originalFetch;
_clearApiClientInflightForTests();
}
});
+73 -51
View File
@@ -1,61 +1,83 @@
/** 登录身份和预览身份分别传递;切换视角会取消旧视角的在途请求。 */ /** 全局认证 fetch 客户端 */
let tokenGetter: () => string | null = () => null;
let previewTokenGetter: () => string | null = () => null;
let identityAbort = new AbortController();
let identityVersion = 0;
export function setTokenGetter(fn: () => string | null) { tokenGetter = fn; }
export function setPreviewTokenGetter(fn: () => string | null) { previewTokenGetter = fn; }
export function invalidateAuthenticatedRequests() {
identityVersion += 1;
identityAbort.abort();
identityAbort = new AbortController();
}
const expiredPreviewCodes = new Set(['PREVIEW_EXPIRED', 'PREVIEW_REVOKED', 'PREVIEW_INVALID', 'PREVIEW_TARGET_UNAVAILABLE']);
async function authenticatedRequest(url: string, options: RequestInit | undefined, preview: boolean, business: boolean): Promise<Response> { let tokenGetter: () => string | null = () => null;
const version = identityVersion; const inflightGetRequests = new Map<string, Promise<unknown>>();
const headers = new Headers(options?.headers);
const token = tokenGetter(); export class ApiRequestError extends Error {
if (token) headers.set('Authorization', `Bearer ${token}`); readonly status: number;
headers.delete('X-BI-Preview'); readonly code: string | null;
const previewToken = preview ? previewTokenGetter() : null; readonly retryable: boolean;
if (previewToken) headers.set('X-BI-Preview', previewToken);
const signal = business constructor(message: string, status: number, code: string | null, retryable: boolean) {
? options?.signal ? AbortSignal.any([options.signal, identityAbort.signal]) : identityAbort.signal super(message);
: options?.signal; this.name = 'ApiRequestError';
const res = await fetch(url, { ...options, headers, signal }); this.status = status;
if (business && version !== identityVersion) throw new DOMException('权限视角已切换', 'AbortError'); this.code = code;
if (res.status === 401 || res.status === 403 || res.status === 410) { this.retryable = retryable;
const body = await res.clone().json().catch(() => ({}));
if (business && version !== identityVersion) throw new DOMException('权限视角已切换', 'AbortError');
if (expiredPreviewCodes.has(body.code)) {
// 已切换到其他人的旧响应不能结束新预览。
if (previewToken && previewToken === previewTokenGetter()) window.dispatchEvent(new CustomEvent('auth:preview-ended', { detail: body.code }));
throw new Error('权限视角已失效,请重新选择用户');
} }
}
interface ApiErrorPayload {
error?: unknown;
message?: unknown;
code?: unknown;
retryable?: unknown;
}
export function setTokenGetter(fn: () => string | null) {
tokenGetter = fn;
}
async function executeJsonRequest<T>(url: string, options: RequestInit | undefined, token: string | null): Promise<T> {
const res = await fetch(url, {
...options,
headers: {
...options?.headers,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (res.status === 401) { if (res.status === 401) {
window.dispatchEvent(new CustomEvent('auth:unauthorized')); window.dispatchEvent(new CustomEvent('auth:unauthorized'));
throw new Error('Unauthorized'); throw new Error('Unauthorized');
} }
if (body.code === 'PREVIEW_READ_ONLY') { if (!res.ok) {
window.dispatchEvent(new CustomEvent('auth:preview-readonly')); let payload: ApiErrorPayload = {};
throw new Error('权限视角为只读,请退出预览后操作'); try {
const parsed: unknown = await res.json();
if (typeof parsed === 'object' && parsed !== null) payload = parsed as ApiErrorPayload;
} catch {
payload = {};
} }
const message = typeof payload?.error === 'string'
? payload.error
: typeof payload?.message === 'string'
? payload.message
: `请求失败(${res.status})`;
const code = typeof payload?.code === 'string' ? payload.code : null;
const retryable = typeof payload?.retryable === 'boolean' ? payload.retryable : res.status >= 500;
throw new ApiRequestError(message, res.status, code, retryable);
} }
return res;
}
export function fetchAuthenticated(url: string, options?: RequestInit): Promise<Response> {
return authenticatedRequest(url, options, true, true);
}
/** 管理接口始终使用实际登录身份;仅 current/stop 附带当前预览凭证。 */
export async function fetchPreviewControl<T>(path: string, options?: RequestInit, includePreview = false): Promise<T> {
const res = await authenticatedRequest(`/api/permission-preview/${path}`, options, includePreview, false);
const data = await res.json();
if (!res.ok) throw new Error(data.error || data.message || '权限视角请求失败,请重试');
return data as T;
}
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetchAuthenticated(url, options);
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
return res.json(); return res.json();
} }
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const token = tokenGetter();
const method = (options?.method || 'GET').toUpperCase();
const canDedupe = method === 'GET' && options?.body == null && options?.signal == null;
if (!canDedupe) return executeJsonRequest<T>(url, options, token);
const key = `${token || 'anonymous'}:${url}`;
const existing = inflightGetRequests.get(key) as Promise<T> | undefined;
if (existing) return existing;
const request = executeJsonRequest<T>(url, options, token)
.finally(() => {
if (inflightGetRequests.get(key) === request) inflightGetRequests.delete(key);
});
inflightGetRequests.set(key, request);
return request;
}
export function _clearApiClientInflightForTests(): void {
inflightGetRequests.clear();
}
+18 -20
View File
@@ -1,27 +1,25 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
export interface AuthUser {
userId: string;
userName: string;
loginName?: string;
permissionLevel: string;
depCode?: string;
depName: string;
roles?: string[];
}
export interface AuthState { export interface AuthState {
isLoading: boolean; isLoading: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
user: AuthUser | null; user: {
userId: string;
userName: string;
permissionLevel: string;
depName: string;
roles?: string[];
} | null;
error: string | null; error: string | null;
actor?: AuthUser | null;
canPreview?: boolean;
preview?: { user: AuthUser; expiresAt: string } | null;
identityVersion?: number;
previewNotice?: string | null;
startPreview?: (userId: string) => Promise<void>;
stopPreview?: () => Promise<void>;
clearPreviewNotice?: () => void;
} }
export const AuthContext = createContext<AuthState>({ isLoading: true, isAuthenticated: false, user: null, error: null });
export function useAuth() { return useContext(AuthContext); } export const AuthContext = createContext<AuthState>({
isLoading: true,
isAuthenticated: false,
user: null,
error: null,
});
export function useAuth() {
return useContext(AuthContext);
}
+12
View File
@@ -0,0 +1,12 @@
import type { MetricDomain } from '../shared/analytics/catalog';
import DataSourceStatusButton from './DataSourceStatusButton';
import MetricCatalogButton from './MetricCatalogButton';
export default function BiHeaderActions({ domain }: { domain: MetricDomain }) {
return (
<>
<DataSourceStatusButton domain={domain} />
<MetricCatalogButton domain={domain} />
</>
);
}
+228
View File
@@ -0,0 +1,228 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Activity, AlertTriangle, CheckCircle2, Clock3, Loader2, RefreshCw, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { fetchJson } from '../auth/api-client';
import type { MetricDomain } from '../shared/analytics/catalog';
import {
BI_SOURCE_BY_DOMAIN,
type DataSourceId,
type DataSourceState,
} from '../shared/analytics/source-status';
interface SourceTelemetry {
source: DataSourceId;
state: DataSourceState;
attempts: number;
failures: number;
failureRate: number | null;
lastSuccessAt: string | null;
lastFailureAt: string | null;
}
interface SourceTelemetryResponse {
semantics: 'observed-requests';
windowSize: number;
processStartedAt: string;
checkedAt: string;
sources: SourceTelemetry[];
}
const STATE_META = {
available: {
label: '最近请求成功',
icon: CheckCircle2,
className: 'border-emerald-200 bg-emerald-50 text-emerald-700',
},
failing: {
label: '最近请求失败',
icon: AlertTriangle,
className: 'border-rose-200 bg-rose-50 text-rose-700',
},
unobserved: {
label: '启动后尚无真实请求',
icon: Clock3,
className: 'border-slate-200 bg-slate-50 text-slate-600',
},
} as const;
function formatObservedAt(value: string | null): string {
if (!value) return '暂无';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(date);
}
export default function DataSourceStatusButton({ domain }: { domain: MetricDomain }) {
const [open, setOpen] = useState(false);
const [data, setData] = useState<SourceTelemetryResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const focusCloseButton = useCallback((node: HTMLButtonElement | null) => node?.focus(), []);
const sourceMeta = BI_SOURCE_BY_DOMAIN[domain];
const source = useMemo(
() => data?.sources.find(item => item.source === sourceMeta.source) || null,
[data, sourceMeta.source],
);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setData(await fetchJson<SourceTelemetryResponse>('/api/analytics/data-sources'));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : String(loadError));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) void load();
}, [load, open]);
useEffect(() => {
if (!open) return undefined;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
triggerRef.current?.focus();
};
}, [open]);
const stateMeta = source ? STATE_META[source.state] : STATE_META.unobserved;
const StateIcon = stateMeta.icon;
const triggerTone = source?.state === 'failing'
? 'border-rose-200 bg-rose-50 text-rose-600'
: source?.state === 'available'
? 'border-emerald-200 bg-emerald-50 text-emerald-600'
: 'border-slate-200 bg-white text-slate-500';
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
className={`inline-flex h-9 w-9 items-center justify-center rounded-xl border shadow-sm transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 ${triggerTone}`}
aria-label="查看数据源运行状态"
title="数据源运行状态"
>
<Activity size={16} />
</button>
{createPortal(<AnimatePresence>
{open ? (
<div className="fixed inset-0 z-[10020]">
<motion.button
type="button"
aria-label="点击遮罩关闭数据源状态"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setOpen(false)}
className="absolute inset-0 h-full w-full bg-slate-950/35 backdrop-blur-[2px]"
/>
<motion.section
role="dialog"
aria-modal="true"
aria-labelledby={`${domain}-source-status-title`}
initial={{ opacity: 0, x: 32 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 32 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="absolute inset-y-0 right-0 flex w-full max-w-sm flex-col bg-white shadow-2xl"
>
<header className="flex shrink-0 items-center gap-3 border-b border-slate-100 px-4 py-4">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<Activity size={17} />
</span>
<div className="min-w-0 flex-1">
<h2 id={`${domain}-source-status-title`} className="text-sm font-black text-slate-900">数据源运行状态</h2>
<p className="mt-0.5 truncate text-[10px] font-bold text-slate-400">{sourceMeta.label}</p>
</div>
<button
type="button"
onClick={() => void load()}
disabled={loading}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50 disabled:opacity-50"
aria-label="刷新数据源状态"
title="刷新"
>
<RefreshCw size={15} className={loading ? 'animate-spin' : ''} />
</button>
<button
ref={focusCloseButton}
type="button"
onClick={() => setOpen(false)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50"
aria-label="关闭数据源状态"
title="关闭"
>
<X size={16} />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
{error ? (
<div className="rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs font-bold text-rose-700">{error}</div>
) : loading && !data ? (
<div className="flex items-center justify-center gap-2 py-16 text-xs font-bold text-slate-400">
<Loader2 size={16} className="animate-spin" />正在读取运行状态
</div>
) : source ? (
<>
<div className={`flex items-center gap-2 rounded-lg border px-3 py-3 ${stateMeta.className}`}>
<StateIcon size={18} />
<span className="text-sm font-black">{stateMeta.label}</span>
</div>
<dl className="mt-5 divide-y divide-slate-100 border-y border-slate-100 text-xs">
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400">观察窗口</dt>
<dd className="font-black text-slate-700">最近 {data?.windowSize ?? 100} 次真实读取</dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400">请求 / 失败</dt>
<dd className="font-black tabular-nums text-slate-700">{source.attempts} / {source.failures}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400">滚动失败率</dt>
<dd className="font-black tabular-nums text-slate-700">{source.failureRate == null ? '暂无' : `${(source.failureRate * 100).toFixed(1)}%`}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400">最后成功</dt>
<dd className="font-black tabular-nums text-slate-700">{formatObservedAt(source.lastSuccessAt)}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="font-bold text-slate-400">最后失败</dt>
<dd className="font-black tabular-nums text-slate-700">{formatObservedAt(source.lastFailureAt)}</dd>
</div>
</dl>
<p className="mt-5 text-[10px] font-bold leading-relaxed text-slate-400">
状态来自本进程真实数据读取;缓存命中不计入,最近成功不代表持续可达。
</p>
</>
) : null}
</div>
</motion.section>
</div>
) : null}
</AnimatePresence>, document.body)}
</>
);
}
+354 -38
View File
@@ -1,20 +1,31 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { fetchJson, fetchAuthenticated } from '../auth/api-client'; import { motion, AnimatePresence } from 'motion/react';
import {
MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles,
ImagePlus, Loader2, Inbox, Lightbulb, Bug, Palette, NotebookPen, Settings2,
type LucideIcon,
} from 'lucide-react';
import { fetchJson } from '../auth/api-client';
import { useAuth } from '../auth/useAuth'; import { useAuth } from '../auth/useAuth';
import { canManageFeedback } from '../shared/auth/roles'; import { canManageFeedback } from '../shared/auth/roles';
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer'; import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
import FeedbackLauncher from './feedback/FeedbackLauncher'; import RotatingFooterHint from './RotatingFooterHint';
import FeedbackModal from './feedback/FeedbackModal';
import type { FeedbackStep, FeedbackType, UploadedImg } from './feedback/types';
const MAX_SCREENSHOTS = 6; const MAX_SCREENSHOTS = 6;
const MAX_IMG_SIZE_MB = 5; const MAX_IMG_SIZE_MB = 5;
interface UploadedImg {
url: string;
thumbDataUrl: string;
}
async function uploadImage(file: File): Promise<string> { async function uploadImage(file: File): Promise<string> {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
const res = await fetchAuthenticated('/api/feedback/upload', { const token = sessionStorage.getItem('bi_jwt');
const res = await fetch('/api/feedback/upload', {
method: 'POST', method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd, body: fd,
}); });
const json = await res.json(); const json = await res.json();
@@ -31,6 +42,38 @@ function readAsDataUrl(file: File): Promise<string> {
}); });
} }
type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
interface TypeOption {
key: FeedbackType;
icon: LucideIcon;
label: string;
sub: string;
iconBg: string;
iconFg: string;
ring: string;
}
const TYPE_OPTIONS: TypeOption[] = [
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
];
const MODULE_LABELS: Record<string, string> = {
assets: '资产管理',
mileage: '里程管理',
energy: '能源管理',
scheduling: '智能调度',
ele: '充电导入',
'': '通用',
};
function detectModule(): string { function detectModule(): string {
const hash = (window.location.hash || '').slice(1); const hash = (window.location.hash || '').slice(1);
const path = window.location.pathname; const path = window.location.pathname;
@@ -51,7 +94,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [step, setStep] = useState<FeedbackStep>(1); const [step, setStep] = useState<1 | 2 | 3>(1); // 1=选类型, 2=写内容, 3=成功页
const [type, setType] = useState<FeedbackType | null>(null); const [type, setType] = useState<FeedbackType | null>(null);
const [mod, setMod] = useState<string>(''); const [mod, setMod] = useState<string>('');
const [content, setContent] = useState(''); const [content, setContent] = useState('');
@@ -85,12 +128,14 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
} }
}; };
// Open: detect current module
useEffect(() => { useEffect(() => {
if (open && step === 1) { if (open && step === 1) {
setMod(moduleProp ?? detectModule()); setMod(moduleProp ?? detectModule());
} }
}, [open, step, moduleProp]); }, [open, step, moduleProp]);
// Lock scroll when open
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const prev = document.body.style.overflow; const prev = document.body.style.overflow;
@@ -108,7 +153,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
const close = () => { const close = () => {
setOpen(false); setOpen(false);
setTimeout(reset, 300); setTimeout(reset, 300); // 等动画完
}; };
const submit = async () => { const submit = async () => {
@@ -148,48 +193,319 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
} }
}; };
const back = () => setStep((Math.max(1, step - 1)) as FeedbackStep); const back = () => setStep((Math.max(1, step - 1)) as typeof step);
const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true; const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true;
const progress = step >= 3 ? 100 : (step / 2) * 100; const progress = step >= 3 ? 100 : (step / 2) * 100;
return ( return (
<> <>
<FeedbackLauncher {/* Floating Action Button */}
isAdmin={isAdmin} <div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
menuOpen={menuOpen} <motion.button
onCloseMenu={() => setMenuOpen(false)} initial={{ scale: 0, opacity: 0 }}
onOpenFeedback={() => { setMenuOpen(false); setOpen(true); }} animate={{ scale: 1, opacity: 1 }}
onOpenHistory={() => { setMenuOpen(false); setHistoryOpen(true); }} transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
onToggleMenu={() => setMenuOpen(current => !current)} whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.92 }}
onClick={() => setMenuOpen(m => !m)}
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
aria-label="反馈"
title="提建议 / 我的反馈"
>
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
</motion.button>
<AnimatePresence>
{menuOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 8 }}
transition={{ duration: 0.15 }}
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
>
<button
onClick={() => { setMenuOpen(false); setOpen(true); }}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
>
<Sparkles size={14} className="text-blue-500" /> 提个建议
</button>
<button
onClick={() => { setMenuOpen(false); setHistoryOpen(true); }}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
>
<Inbox size={14} className="text-emerald-500" /> 我的反馈
</button>
{isAdmin && (
<>
<div className="h-px bg-slate-100 my-0.5" />
<a
href="#/admin/feedback"
onClick={() => setMenuOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
>
<Settings2 size={14} className="text-violet-500" /> 反馈管理
</a>
</>
)}
</motion.div>
)}
</AnimatePresence>
{/* 点击外面关菜单 */}
{menuOpen && (
<div
className="fixed inset-0 z-[-1]"
onClick={() => setMenuOpen(false)}
/> />
)}
</div>
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} /> <FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
<FeedbackModal {/* Modal */}
canNext={canNext} <AnimatePresence>
content={content} {open && (
error={error} <motion.div
fileRef={fileRef} initial={{ opacity: 0 }}
maxScreenshots={MAX_SCREENSHOTS} animate={{ opacity: 1 }}
module={mod} exit={{ opacity: 0 }}
open={open} className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
progress={progress} // 不允许点击背景关闭:避免用户输入到一半误触遮罩丢失内容
screenshots={shots} >
step={step} <motion.div
submitting={submitting} initial={{ y: '100%', opacity: 0 }}
textareaRef={taRef} animate={{ y: 0, opacity: 1 }}
type={type} exit={{ y: '100%', opacity: 0 }}
uploading={uploading} transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
onAddFiles={addFiles} className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
onBack={back} onClick={(e) => e.stopPropagation()}
onChangeContent={setContent} >
onChangeModule={setMod} {/* Drag handle */}
onClose={close} <div className="flex justify-center pt-2.5 pb-1">
onNext={next} <div className="w-10 h-1 rounded-full bg-slate-300" />
onRemoveScreenshot={(index) => setShots(prev => prev.filter((_, current) => current !== index))} </div>
onSelectType={(selectedType) => { setType(selectedType); setStep(2); }}
{/* Header + progress */}
<div className="px-4 pb-3 border-b border-slate-100">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
<Sparkles size={14} className="text-white" />
</div>
<div>
<div className="text-sm font-black text-slate-800 leading-tight">
{step === 3 ? '收到啦~' : '提个建议'}
</div>
<div className="text-[10px] text-slate-400 font-bold">
{step === 1 && '第一步 / 共 2 步'}
{step === 2 && '第二步 / 共 2 步'}
{step === 3 && '感谢你的反馈'}
</div>
</div>
</div>
<button onClick={close} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
<X size={18} />
</button>
</div>
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={false}
animate={{ width: `${progress}%` }}
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
/> />
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-4 py-4">
<AnimatePresence mode="wait">
{step === 1 && (
<motion.div key="s1" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }}>
<p className="text-[13px] font-bold text-slate-700 mb-1.5">想反馈点什么?</p>
<RotatingFooterHint className="justify-start mb-4" />
<div className="grid grid-cols-1 gap-2">
{TYPE_OPTIONS.map((opt, i) => {
const Icon = opt.icon;
const selected = type === opt.key;
return (
<motion.button
key={opt.key}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04, duration: 0.2 }}
whileHover={{ y: -1 }}
whileTap={{ scale: 0.99 }}
onClick={() => { setType(opt.key); setStep(2); }}
className={`text-left p-3.5 rounded-2xl border bg-white transition-all flex items-center gap-3 group ${selected ? `ring-2 ${opt.ring} border-transparent shadow-sm` : 'border-slate-100 hover:border-slate-200 hover:shadow-sm'}`}
>
<div className={`w-10 h-10 rounded-xl ${opt.iconBg} flex items-center justify-center flex-shrink-0`}>
<Icon size={18} className={opt.iconFg} strokeWidth={2.2} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-bold text-slate-800 leading-tight">{opt.label}</div>
<div className="text-[11px] text-slate-400 font-medium mt-0.5">{opt.sub}</div>
</div>
<ChevronRight size={15} className="text-slate-300 flex-shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
</motion.button>
);
})}
</div>
</motion.div>
)}
{step === 2 && (
<motion.div key="s2" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }} className="space-y-3">
<div>
<p className="text-[12px] font-bold text-slate-600 mb-2">说说具体内容</p>
<textarea
ref={taRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
const imgs: File[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === 'file' && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) imgs.push(f);
}
}
if (imgs.length > 0) {
e.preventDefault();
addFiles(imgs);
}
}}
autoFocus
rows={5}
maxLength={1000}
placeholder={
type === 'dimension' ? '比如:希望按客户/区域/日期范围 等等切片看里程数据…'
: type === 'bug' ? '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)'
: type === 'ux' ? '比如:能不能把外部 tab 默认收起,加载快一点…'
: '随便聊聊你的想法'
}
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
/>
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
</div>
{/* 截图上传 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-bold text-slate-400 uppercase">截图(可选)</p>
<span className="text-[10px] text-slate-300 font-bold">{shots.length}/{MAX_SCREENSHOTS},可粘贴</span>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
/>
<div className="flex flex-wrap gap-1.5">
{shots.map((s, i) => (
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
<button
onClick={() => setShots(prev => prev.filter((_, idx) => idx !== i))}
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
>
<X size={10} />
</button>
</div>
))}
{shots.length < MAX_SCREENSHOTS && (
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
>
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
</button>
)}
</div>
</div>
<div>
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">所在板块</p>
<div className="flex gap-1 flex-wrap">
{Object.entries(MODULE_LABELS).map(([k, label]) => (
<button
key={k}
onClick={() => setMod(k)}
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${mod === k ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
>
{label}
</button>
))}
</div>
</div>
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
</motion.div>
)}
{step === 3 && (
<motion.div key="s3" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.3 }} className="text-center py-6">
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
>
<Check size={28} strokeWidth={3} className="text-white" />
</motion.div>
<div className="text-base font-black text-slate-800 mb-1">谢谢你的反馈 ❤️</div>
<div className="text-[12px] text-slate-500 font-bold leading-relaxed">产品同学会认真看每一条<br />有进展可以在「我的反馈」里查看</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Footer */}
{step < 3 && (
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
{step > 1 && (
<button
onClick={back}
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
>
<ChevronLeft size={14} /> 上一步
</button>
)}
<div className="flex-1" />
<button
onClick={next}
disabled={!canNext || submitting}
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
>
{submitting ? '提交中…' : step === 2 ? '提交' : '下一步'}
{!submitting && step !== 2 && <ChevronRight size={14} />}
</button>
</div>
)}
{step === 3 && (
<div className="px-4 py-3 border-t border-slate-100">
<button
onClick={close}
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
>
完成
</button>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</> </>
); );
} }
+166
View File
@@ -0,0 +1,166 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { BookOpen, Database, Loader2, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { fetchJson } from '../auth/api-client';
import type { MetricDefinition, MetricDomain } from '../shared/analytics/catalog';
interface MetricCatalogResponse {
catalogVersion: number;
domains: MetricDomain[];
metrics: MetricDefinition[];
}
const AGGREGATION_LABEL: Record<MetricDefinition['aggregation'], string> = {
sum: '求和',
count: '计数',
derived: '派生计算',
'weighted-ratio': '加权比率',
'distinct-count': '去重计数',
latest: '取最新值',
};
const TIME_LABEL: Record<MetricDefinition['timeSemantics'], string> = {
flow: '区间流量',
snapshot: '时点快照',
freshness: '数据新鲜度',
};
export default function MetricCatalogButton({ domain }: { domain: MetricDomain }) {
const [open, setOpen] = useState(false);
const [data, setData] = useState<MetricCatalogResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const setCloseRef = useCallback((node: HTMLButtonElement | null) => {
closeRef.current = node;
node?.focus();
}, []);
useEffect(() => {
if (!open || data) return undefined;
let cancelled = false;
setError(null);
fetchJson<MetricCatalogResponse>(`/api/analytics/metrics?domain=${domain}`)
.then(result => { if (!cancelled) setData(result); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [open, data, domain]);
useEffect(() => {
if (!open) return undefined;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
triggerRef.current?.focus();
};
}, [open]);
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
aria-label="查看指标口径"
title="指标口径"
>
<BookOpen size={16} />
</button>
{createPortal(<AnimatePresence>
{open && (
<div className="fixed inset-0 z-[10020]">
<motion.button
type="button"
aria-label="点击遮罩关闭指标口径"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setOpen(false)}
className="absolute inset-0 h-full w-full bg-slate-950/35 backdrop-blur-[2px]"
/>
<motion.section
role="dialog"
aria-modal="true"
aria-labelledby={`${domain}-metric-catalog-title`}
initial={{ opacity: 0, x: 32 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 32 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="absolute inset-y-0 right-0 flex w-full max-w-lg flex-col bg-white shadow-2xl"
>
<header className="flex shrink-0 items-center gap-3 border-b border-slate-100 px-4 py-4 md:px-5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<BookOpen size={17} />
</span>
<div className="min-w-0 flex-1">
<h2 id={`${domain}-metric-catalog-title`} className="text-sm font-black text-slate-900">指标口径</h2>
<p className="mt-0.5 text-[10px] font-bold text-slate-400">
{data ? `目录版本 v${data.catalogVersion} · ${data.metrics.length} 项指标` : '读取已发布指标目录'}
</p>
</div>
<button
ref={setCloseRef}
type="button"
onClick={() => setOpen(false)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50 hover:text-slate-800"
aria-label="关闭指标口径"
title="关闭"
>
<X size={16} />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-2 md:px-5">
{error ? (
<div className="my-4 rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs font-bold text-rose-700">{error}</div>
) : !data ? (
<div className="flex items-center justify-center gap-2 py-16 text-xs font-bold text-slate-400">
<Loader2 size={16} className="animate-spin" />正在加载指标口径
</div>
) : (
data.metrics.map(metric => (
<article key={metric.id} className="border-b border-slate-100 py-4 last:border-b-0">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<h3 className="text-sm font-black text-slate-900">{metric.label}</h3>
<div className="mt-1 font-mono text-[9px] font-bold text-slate-400">{metric.id}</div>
</div>
<div className="flex flex-wrap gap-1 text-[9px] font-black">
<span className="rounded bg-blue-50 px-1.5 py-1 text-blue-600">{AGGREGATION_LABEL[metric.aggregation]}</span>
<span className="rounded bg-slate-100 px-1.5 py-1 text-slate-600">{TIME_LABEL[metric.timeSemantics]}</span>
<span className="rounded bg-emerald-50 px-1.5 py-1 text-emerald-700">{metric.unit}</span>
</div>
</div>
<p className="mt-3 text-[11px] font-bold leading-relaxed text-slate-600">{metric.description}</p>
<div className="mt-3 rounded-lg bg-slate-950 px-3 py-2 font-mono text-[10px] leading-relaxed text-slate-200 break-words">
{metric.formula}
</div>
<div className="mt-3 flex items-start gap-2 text-[10px] font-bold text-slate-500">
<Database size={12} className="mt-0.5 shrink-0 text-slate-400" />
<span className="break-words">{metric.sources.join(' · ')}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{metric.dimensions.map(dimension => (
<span key={dimension} className="rounded border border-slate-200 px-1.5 py-0.5 text-[9px] font-bold text-slate-500">{dimension}</span>
))}
</div>
</article>
))
)}
</div>
</motion.section>
</div>
)}
</AnimatePresence>, document.body)}
</>
);
}
+5 -4
View File
@@ -3,6 +3,7 @@ import { motion } from 'motion/react';
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react'; import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
import { useAuth } from '../auth/useAuth'; import { useAuth } from '../auth/useAuth';
import { DemoModeProvider } from './Blur'; import { DemoModeProvider } from './Blur';
import FeedbackFab from './FeedbackFab';
import { cn } from '../lib/cn'; import { cn } from '../lib/cn';
import { LoadingState } from './ui/surface'; import { LoadingState } from './ui/surface';
@@ -90,7 +91,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
return ( return (
<DemoModeProvider enabled={false}> <DemoModeProvider enabled={false}>
<div className="app-shell enterprise-grid-bg flex min-h-screen"> <div className="enterprise-grid-bg flex min-h-screen">
{/* 全局水印 */} {/* 全局水印 */}
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}> <div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
<div className="absolute inset-0" style={{ <div className="absolute inset-0" style={{
@@ -155,7 +156,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
</nav> </nav>
{/* 内容区 */} {/* 内容区 */}
<main className="app-shell-content min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}> <main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
<div className="pointer-events-none fixed left-20 right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex"> <div className="pointer-events-none fixed left-20 right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex">
<span className="text-slate-600">羚牛氢能 BI</span> <span className="text-slate-600">羚牛氢能 BI</span>
<span className="mx-2 text-slate-300">/</span> <span className="mx-2 text-slate-300">/</span>
@@ -164,11 +165,11 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}> <Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
{ActiveComponent && <ActiveComponent />} {ActiveComponent && <ActiveComponent />}
</Suspense> </Suspense>
<FeedbackFab module={activeModule} />
</main> </main>
{/* 移动端底部导航 (md 以下) */} {/* 移动端底部导航 (md 以下) */}
<nav className="app-mobile-nav fixed inset-x-0 bottom-0 z-50 border-t border-white/70 bg-white/92 px-3 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-18px_40px_rgba(15,23,42,0.08)] backdrop-blur-xl md:hidden"> <nav className="fixed inset-x-0 bottom-0 z-50 border-t border-white/70 bg-white/92 px-3 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-18px_40px_rgba(15,23,42,0.08)] backdrop-blur-xl md:hidden">
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}> <div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}>
{modules.map((m) => { {modules.map((m) => {
const Icon = m.icon; const Icon = m.icon;
@@ -1,148 +0,0 @@
import type { RefObject } from 'react';
import { motion } from 'motion/react';
import { ImagePlus, Loader2, X } from 'lucide-react';
import type { FeedbackType, UploadedImg } from './types';
const MODULE_LABELS: Record<string, string> = {
assets: '资产管理',
mileage: '里程管理',
energy: '能源管理',
scheduling: '智能调度',
ele: '充电导入',
'': '通用',
};
function getPlaceholder(type: FeedbackType | null): string {
if (type === 'dimension') return '比如:希望按客户/区域/日期范围 等等切片看里程数据…';
if (type === 'bug') return '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)';
if (type === 'ux') return '比如:能不能把外部 tab 默认收起,加载快一点…';
return '随便聊聊你的想法';
}
interface Props {
content: string;
error: string | null;
fileRef: RefObject<HTMLInputElement | null>;
maxScreenshots: number;
module: string;
screenshots: UploadedImg[];
textareaRef: RefObject<HTMLTextAreaElement | null>;
type: FeedbackType | null;
uploading: boolean;
onAddFiles: (files: FileList | File[]) => void | Promise<void>;
onChangeContent: (content: string) => void;
onChangeModule: (module: string) => void;
onRemoveScreenshot: (index: number) => void;
}
export default function FeedbackFormStep({
content,
error,
fileRef,
maxScreenshots,
module,
screenshots,
textareaRef,
type,
uploading,
onAddFiles,
onChangeContent,
onChangeModule,
onRemoveScreenshot,
}: Props) {
return (
<motion.div
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -12 }}
transition={{ duration: 0.2 }}
className="space-y-3"
>
<div>
<p className="text-[12px] font-bold text-slate-600 mb-2">说说具体内容</p>
<textarea
ref={textareaRef}
value={content}
onChange={(e) => onChangeContent(e.target.value)}
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
const imgs: File[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === 'file' && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) imgs.push(f);
}
}
if (imgs.length > 0) {
e.preventDefault();
onAddFiles(imgs);
}
}}
autoFocus
rows={5}
maxLength={1000}
placeholder={getPlaceholder(type)}
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
/>
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-bold text-slate-400 uppercase">截图(可选)</p>
<span className="text-[10px] text-slate-300 font-bold">{screenshots.length}/{maxScreenshots},可粘贴</span>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => { if (e.target.files) onAddFiles(e.target.files); e.target.value = ''; }}
/>
<div className="flex flex-wrap gap-1.5">
{screenshots.map((s, i) => (
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
<button
onClick={() => onRemoveScreenshot(i)}
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
>
<X size={10} />
</button>
</div>
))}
{screenshots.length < maxScreenshots && (
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
>
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
</button>
)}
</div>
</div>
<div>
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">所在板块</p>
<div className="flex gap-1 flex-wrap">
{Object.entries(MODULE_LABELS).map(([key, label]) => (
<button
key={key}
onClick={() => onChangeModule(key)}
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${module === key ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
>
{label}
</button>
))}
</div>
</div>
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
</motion.div>
);
}
@@ -1,88 +0,0 @@
import { AnimatePresence, motion } from 'motion/react';
import {
Inbox,
MessageCircleHeart,
Settings2,
Sparkles,
} from 'lucide-react';
interface Props {
isAdmin: boolean;
menuOpen: boolean;
onCloseMenu: () => void;
onOpenFeedback: () => void;
onOpenHistory: () => void;
onToggleMenu: () => void;
}
export default function FeedbackLauncher({
isAdmin,
menuOpen,
onCloseMenu,
onOpenFeedback,
onOpenHistory,
onToggleMenu,
}: Props) {
return (
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.92 }}
onClick={onToggleMenu}
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
aria-label="反馈"
title="提建议 / 我的反馈"
>
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
</motion.button>
<AnimatePresence>
{menuOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 8 }}
transition={{ duration: 0.15 }}
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
>
<button
onClick={onOpenFeedback}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
>
<Sparkles size={14} className="text-blue-500" /> 提个建议
</button>
<button
onClick={onOpenHistory}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
>
<Inbox size={14} className="text-emerald-500" /> 我的反馈
</button>
{isAdmin && (
<>
<div className="h-px bg-slate-100 my-0.5" />
<a
href="#/admin/feedback"
onClick={onCloseMenu}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
>
<Settings2 size={14} className="text-violet-500" /> 反馈管理
</a>
</>
)}
</motion.div>
)}
</AnimatePresence>
{menuOpen && (
<div
className="fixed inset-0 z-[-1]"
onClick={onCloseMenu}
/>
)}
</div>
);
}
-177
View File
@@ -1,177 +0,0 @@
import type { RefObject } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { ChevronLeft, ChevronRight, Sparkles, X } from 'lucide-react';
import FeedbackFormStep from './FeedbackFormStep';
import FeedbackSuccessStep from './FeedbackSuccessStep';
import FeedbackTypeStep from './FeedbackTypeStep';
import type { FeedbackStep, FeedbackType, UploadedImg } from './types';
interface Props {
canNext: boolean;
content: string;
error: string | null;
fileRef: RefObject<HTMLInputElement | null>;
maxScreenshots: number;
module: string;
open: boolean;
progress: number;
screenshots: UploadedImg[];
step: FeedbackStep;
submitting: boolean;
textareaRef: RefObject<HTMLTextAreaElement | null>;
type: FeedbackType | null;
uploading: boolean;
onAddFiles: (files: FileList | File[]) => void | Promise<void>;
onBack: () => void;
onChangeContent: (content: string) => void;
onChangeModule: (module: string) => void;
onClose: () => void;
onNext: () => void;
onRemoveScreenshot: (index: number) => void;
onSelectType: (type: FeedbackType) => void;
}
export default function FeedbackModal({
canNext,
content,
error,
fileRef,
maxScreenshots,
module,
open,
progress,
screenshots,
step,
submitting,
textareaRef,
type,
uploading,
onAddFiles,
onBack,
onChangeContent,
onChangeModule,
onClose,
onNext,
onRemoveScreenshot,
onSelectType,
}: Props) {
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
>
<motion.div
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-center pt-2.5 pb-1">
<div className="w-10 h-1 rounded-full bg-slate-300" />
</div>
<div className="px-4 pb-3 border-b border-slate-100">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
<Sparkles size={14} className="text-white" />
</div>
<div>
<div className="text-sm font-black text-slate-800 leading-tight">
{step === 3 ? '收到啦~' : '提个建议'}
</div>
<div className="text-[10px] text-slate-400 font-bold">
{step === 1 && '第一步 / 共 2 步'}
{step === 2 && '第二步 / 共 2 步'}
{step === 3 && '感谢你的反馈'}
</div>
</div>
</div>
<button onClick={onClose} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
<X size={18} />
</button>
</div>
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={false}
animate={{ width: `${progress}%` }}
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
<AnimatePresence mode="wait">
{step === 1 && (
<FeedbackTypeStep
key="s1"
selectedType={type}
onSelect={onSelectType}
/>
)}
{step === 2 && (
<FeedbackFormStep
key="s2"
content={content}
error={error}
fileRef={fileRef}
maxScreenshots={maxScreenshots}
module={module}
screenshots={screenshots}
textareaRef={textareaRef}
type={type}
uploading={uploading}
onAddFiles={onAddFiles}
onChangeContent={onChangeContent}
onChangeModule={onChangeModule}
onRemoveScreenshot={onRemoveScreenshot}
/>
)}
{step === 3 && <FeedbackSuccessStep key="s3" />}
</AnimatePresence>
</div>
{step < 3 && (
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
{step > 1 && (
<button
onClick={onBack}
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
>
<ChevronLeft size={14} /> 上一步
</button>
)}
<div className="flex-1" />
<button
onClick={onNext}
disabled={!canNext || submitting}
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
>
{submitting ? '提交中…' : step === 2 ? '提交' : '下一步'}
{!submitting && step !== 2 && <ChevronRight size={14} />}
</button>
</div>
)}
{step === 3 && (
<div className="px-4 py-3 border-t border-slate-100">
<button
onClick={onClose}
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
>
完成
</button>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -1,24 +0,0 @@
import { motion } from 'motion/react';
import { Check } from 'lucide-react';
export default function FeedbackSuccessStep() {
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
className="text-center py-6"
>
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
>
<Check size={28} strokeWidth={3} className="text-white" />
</motion.div>
<div className="text-base font-black text-slate-800 mb-1">谢谢你的反馈 ❤️</div>
<div className="text-[12px] text-slate-500 font-bold leading-relaxed">产品同学会认真看每一条<br />有进展可以在「我的反馈」里查看</div>
</motion.div>
);
}
@@ -1,78 +0,0 @@
import { motion } from 'motion/react';
import {
Bug,
ChevronRight,
Lightbulb,
NotebookPen,
Palette,
type LucideIcon,
} from 'lucide-react';
import RotatingFooterHint from '../RotatingFooterHint';
import type { FeedbackType } from './types';
interface TypeOption {
key: FeedbackType;
icon: LucideIcon;
label: string;
sub: string;
iconBg: string;
iconFg: string;
ring: string;
}
const TYPE_OPTIONS: TypeOption[] = [
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
];
interface Props {
selectedType: FeedbackType | null;
onSelect: (type: FeedbackType) => void;
}
export default function FeedbackTypeStep({ selectedType, onSelect }: Props) {
return (
<motion.div
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -12 }}
transition={{ duration: 0.2 }}
>
<p className="text-[13px] font-bold text-slate-700 mb-1.5">想反馈点什么?</p>
<RotatingFooterHint className="justify-start mb-4" />
<div className="grid grid-cols-1 gap-2">
{TYPE_OPTIONS.map((opt, i) => {
const Icon = opt.icon;
const selected = selectedType === opt.key;
return (
<motion.button
key={opt.key}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04, duration: 0.2 }}
whileHover={{ y: -1 }}
whileTap={{ scale: 0.99 }}
onClick={() => onSelect(opt.key)}
className={`text-left p-3.5 rounded-2xl border bg-white transition-all flex items-center gap-3 group ${selected ? `ring-2 ${opt.ring} border-transparent shadow-sm` : 'border-slate-100 hover:border-slate-200 hover:shadow-sm'}`}
>
<div className={`w-10 h-10 rounded-xl ${opt.iconBg} flex items-center justify-center flex-shrink-0`}>
<Icon size={18} className={opt.iconFg} strokeWidth={2.2} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-bold text-slate-800 leading-tight">{opt.label}</div>
<div className="text-[11px] text-slate-400 font-medium mt-0.5">{opt.sub}</div>
</div>
<ChevronRight size={15} className="text-slate-300 flex-shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
</motion.button>
);
})}
</div>
</motion.div>
);
}
-8
View File
@@ -1,8 +0,0 @@
export type FeedbackStep = 1 | 2 | 3;
export type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
export interface UploadedImg {
url: string;
thumbDataUrl: string;
}
-66
View File
@@ -1,66 +0,0 @@
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<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
-168
View File
@@ -1,168 +0,0 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Dialog as DialogPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">关闭</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">关闭</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
-13
View File
@@ -1,13 +0,0 @@
import { cn } from "cn"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+90 -33
View File
@@ -1,5 +1,5 @@
import { useState, type ComponentType, type ReactNode } from 'react'; import { useState, type ComponentType, type ReactNode } from 'react';
import { AlertCircle, Info, Loader2, SearchX, X } from 'lucide-react'; import { AlertCircle, Info, Loader2, RefreshCw, SearchX, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { cn } from '../../lib/cn'; import { cn } from '../../lib/cn';
@@ -15,7 +15,6 @@ export function PageFrame({
children, children,
maxWidth = 'max-w-6xl', maxWidth = 'max-w-6xl',
compactInfo = false, compactInfo = false,
hideHeader = false,
}: { }: {
title: string; title: string;
subtitle?: string; subtitle?: string;
@@ -26,14 +25,13 @@ export function PageFrame({
children: ReactNode; children: ReactNode;
maxWidth?: string; maxWidth?: string;
compactInfo?: boolean; compactInfo?: boolean;
hideHeader?: boolean;
}) { }) {
const [infoOpen, setInfoOpen] = useState(false); const [infoOpen, setInfoOpen] = useState(false);
return ( return (
<div className="min-h-screen bg-[var(--app-bg)] text-slate-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}> <div className="min-h-screen bg-[var(--app-bg)] text-slate-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className={cn(maxWidth, 'mx-auto flex flex-col gap-4 pb-20 md:pb-6')}> <div className={cn(maxWidth, 'mx-auto flex flex-col gap-4 pb-20 md:pb-6')}>
{!hideHeader ? <motion.header <motion.header
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: 'easeOut' }} transition={{ duration: 0.28, ease: 'easeOut' }}
@@ -58,7 +56,7 @@ export function PageFrame({
</div> </div>
<h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1> <h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1>
</div> </div>
{actions ? <div className="hidden shrink-0 items-center gap-2 md:flex">{actions}</div> : null} {actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
{subtitle ? ( {subtitle ? (
<button <button
type="button" type="button"
@@ -108,7 +106,7 @@ export function PageFrame({
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null} {actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
</div> </div>
)} )}
</motion.header> : null} </motion.header>
{children} {children}
</div> </div>
</div> </div>
@@ -122,7 +120,7 @@ export function SurfaceCard({
children, children,
className, className,
}: { }: {
title?: ReactNode; title?: string;
subtitle?: string; subtitle?: string;
actions?: ReactNode; actions?: ReactNode;
children: ReactNode; children: ReactNode;
@@ -151,8 +149,6 @@ export function MetricTile({
helper, helper,
icon: Icon, icon: Icon,
tone = 'blue', tone = 'blue',
className,
compact = false,
}: { }: {
label: string; label: string;
value: ReactNode; value: ReactNode;
@@ -160,8 +156,6 @@ export function MetricTile({
helper?: string; helper?: string;
icon?: SurfaceIcon; icon?: SurfaceIcon;
tone?: 'blue' | 'emerald' | 'amber' | 'rose' | 'slate'; tone?: 'blue' | 'emerald' | 'amber' | 'rose' | 'slate';
className?: string;
compact?: boolean;
}) { }) {
const toneClass = { const toneClass = {
blue: 'bg-blue-50 text-blue-600 ring-blue-100', blue: 'bg-blue-50 text-blue-600 ring-blue-100',
@@ -172,29 +166,22 @@ export function MetricTile({
}[tone]; }[tone];
return ( return (
<div className={cn( <div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md">
'rounded-2xl border border-slate-100 bg-white shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md',
compact ? 'p-3' : 'p-4',
className,
)}>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-[11px] font-black text-slate-400">{label}</div> <div className="min-w-0 text-[11px] font-black text-slate-400">{label}</div>
{Icon ? ( {Icon ? (
<span className={cn('inline-flex shrink-0 items-center justify-center rounded-xl ring-1', compact ? 'h-8 w-8' : 'h-9 w-9', toneClass)}> <span className={cn('inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1', toneClass)}>
<Icon size={compact ? 16 : 18} /> <Icon size={18} />
</span> </span>
) : null} ) : null}
</div> </div>
<div className={cn('flex min-w-0 items-end gap-1', compact ? 'mt-2' : 'mt-3')}> <div className="mt-3 flex min-w-0 items-end gap-1">
<span className={cn( <span className="min-w-0 whitespace-nowrap text-[clamp(1.65rem,6vw,2rem)] font-black leading-none tracking-tight text-slate-950 tabular-nums">
'min-w-0 whitespace-nowrap font-black leading-none tracking-tight text-slate-950 tabular-nums',
compact ? 'text-[clamp(1.45rem,6vw,1.7rem)]' : 'text-[clamp(1.65rem,6vw,2rem)]',
)}>
{value} {value}
</span> </span>
{unit ? <span className="shrink-0 pb-0.5 text-[11px] font-black text-slate-400">{unit}</span> : null} {unit ? <span className="shrink-0 pb-0.5 text-[11px] font-black text-slate-400">{unit}</span> : null}
</div> </div>
{helper ? <div className={cn('font-bold leading-relaxed text-slate-500', compact ? 'mt-2 text-[10px]' : 'mt-3 text-[11px]')}>{helper}</div> : null} {helper ? <div className="mt-3 text-[11px] font-bold leading-relaxed text-slate-500">{helper}</div> : null}
</div> </div>
); );
} }
@@ -203,23 +190,51 @@ export function SegmentedNav<T extends string>({
tabs, tabs,
active, active,
onChange, onChange,
idPrefix,
ariaLabel,
className, className,
}: { }: {
tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[]; tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[];
active: T; active: T;
onChange: (id: T) => void; onChange: (id: T) => void;
idPrefix: string;
ariaLabel: string;
className?: string; className?: string;
}) { }) {
const activateFromKeyboard = (event: React.KeyboardEvent<HTMLButtonElement>, currentIndex: number) => {
let nextIndex: number | null = null;
if (event.key === 'ArrowRight') nextIndex = (currentIndex + 1) % tabs.length;
if (event.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
if (event.key === 'Home') nextIndex = 0;
if (event.key === 'End') nextIndex = tabs.length - 1;
if (nextIndex === null) return;
event.preventDefault();
const next = tabs[nextIndex];
onChange(next.id);
window.requestAnimationFrame(() => document.getElementById(`${idPrefix}-tab-${next.id}`)?.focus());
};
return ( return (
<div className={cn('rounded-2xl border border-white/70 bg-white/90 p-1 shadow-sm backdrop-blur-xl', className)}> <div className={cn('rounded-2xl border border-white/70 bg-white/90 p-1 shadow-sm backdrop-blur-xl', className)}>
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}> <div
{tabs.map(({ id, label, icon: Icon }) => { role="tablist"
aria-label={ariaLabel}
className="grid gap-1"
style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}
>
{tabs.map(({ id, label, icon: Icon }, index) => {
const isActive = active === id; const isActive = active === id;
return ( return (
<button <button
key={id} key={id}
id={`${idPrefix}-tab-${id}`}
type="button" type="button"
role="tab"
aria-selected={isActive}
aria-controls={`${idPrefix}-panel-${id}`}
tabIndex={isActive ? 0 : -1}
onClick={() => onChange(id)} onClick={() => onChange(id)}
onKeyDown={event => activateFromKeyboard(event, index)}
className={cn( className={cn(
'relative flex min-h-10 items-center justify-center gap-1.5 rounded-xl px-2 text-[12px] font-black transition-colors', 'relative flex min-h-10 items-center justify-center gap-1.5 rounded-xl px-2 text-[12px] font-black transition-colors',
isActive ? 'text-blue-700' : 'text-slate-400 hover:bg-slate-50 hover:text-slate-600', isActive ? 'text-blue-700' : 'text-slate-400 hover:bg-slate-50 hover:text-slate-600',
@@ -246,9 +261,20 @@ export function SkeletonBlock({ className }: { className?: string }) {
return <div className={cn('overflow-hidden rounded-xl bg-slate-100 shimmer', className)} />; return <div className={cn('overflow-hidden rounded-xl bg-slate-100 shimmer', className)} />;
} }
export function LoadingState({ label = '数据加载中' }: { label?: string }) { type StateVariant = 'card' | 'inline';
export function LoadingState({
label = '数据加载中',
variant = 'card',
}: {
label?: string;
variant?: StateVariant;
}) {
return ( return (
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm"> <div className={cn(
'p-8 text-center',
variant === 'card' && 'rounded-2xl border border-slate-100 bg-white shadow-sm',
)}>
<Loader2 className="mx-auto mb-3 animate-spin text-blue-500" size={22} /> <Loader2 className="mx-auto mb-3 animate-spin text-blue-500" size={22} />
<div className="text-xs font-black text-slate-500">{label}</div> <div className="text-xs font-black text-slate-500">{label}</div>
</div> </div>
@@ -258,12 +284,17 @@ export function LoadingState({ label = '数据加载中' }: { label?: string })
export function EmptyState({ export function EmptyState({
title = '暂无数据', title = '暂无数据',
description = '换个筛选条件或稍后再试', description = '换个筛选条件或稍后再试',
variant = 'card',
}: { }: {
title?: string; title?: string;
description?: string; description?: string;
variant?: StateVariant;
}) { }) {
return ( return (
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm"> <div className={cn(
'p-8 text-center',
variant === 'card' && 'rounded-2xl border border-slate-100 bg-white shadow-sm',
)}>
<SearchX className="mx-auto mb-3 text-slate-300" size={26} /> <SearchX className="mx-auto mb-3 text-slate-300" size={26} />
<div className="text-sm font-black text-slate-700">{title}</div> <div className="text-sm font-black text-slate-700">{title}</div>
<div className="mt-1 text-xs font-bold text-slate-400">{description}</div> <div className="mt-1 text-xs font-bold text-slate-400">{description}</div>
@@ -271,14 +302,40 @@ export function EmptyState({
); );
} }
export function ErrorState({ message }: { message: string }) { export function ErrorState({
message,
title = '加载失败',
onRetry,
retrying = false,
variant = 'card',
}: {
message: string;
title?: string;
onRetry?: () => void;
retrying?: boolean;
variant?: StateVariant;
}) {
return ( return (
<div className="rounded-2xl border border-rose-100 bg-rose-50 p-4 text-rose-700 shadow-sm"> <div className={cn(
'bg-rose-50 p-4 text-rose-700',
variant === 'card' && 'rounded-2xl border border-rose-100 shadow-sm',
)}>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertCircle size={18} className="mt-0.5 shrink-0" /> <AlertCircle size={18} className="mt-0.5 shrink-0" />
<div> <div className="min-w-0 flex-1">
<div className="text-sm font-black">加载失败</div> <div className="text-sm font-black">{title}</div>
<div className="mt-1 text-xs font-bold leading-relaxed">{message}</div> <div className="mt-1 text-xs font-bold leading-relaxed">{message}</div>
{onRetry ? (
<button
type="button"
onClick={onRetry}
disabled={retrying}
className="mt-3 inline-flex h-8 items-center gap-1.5 rounded-lg border border-rose-200 bg-white px-3 text-xs font-black text-rose-700 transition-colors hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw size={13} className={retrying ? 'animate-spin' : ''} />
重试
</button>
) : null}
</div> </div>
</div> </div>
</div> </div>
+4 -3
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react'; import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
import { fetchJson, fetchAuthenticated } from '../../auth/api-client'; import { fetchJson } from '../../auth/api-client';
import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface'; import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface';
interface FeedbackItem { interface FeedbackItem {
@@ -49,9 +49,10 @@ function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
} }
async function patchItem(id: number, data: { status?: string; reply?: string }): Promise<void> { async function patchItem(id: number, data: { status?: string; reply?: string }): Promise<void> {
const res = await fetchAuthenticated(`/api/feedback/${id}`, { const token = sessionStorage.getItem('bi_jwt');
const res = await fetch(`/api/feedback/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
const json = await res.json(); const json = await res.json();
File diff suppressed because it is too large Load Diff
@@ -1,53 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { statisticsExportRows, vehicleExportRows } from './analysis-export';
import type { VehicleListItem } from './types';
test('statistics export retains all ordered groups and repeats parent labels for spreadsheet filtering', () => {
const groups = Array.from({ length: 105 }, (_, i) => ({ values: ['华东', `城市${i}`], count: 1, extra: { 待交车: 0, 异动: Number(i === 104) } }));
const rows = statisticsExportRows(['区域', '城市'], groups, 105);
assert.equal(rows.length, 105);
assert.deepEqual(rows[104], { 区域: '华东', 城市: '城市104', '数量(台)': 1, 待交车: 0, 异动: 1, '占比(%)': 1 });
assert.equal(rows.reduce((n, row) => n + Number(row['数量(台)']), 0), 105);
assert.deepEqual(statisticsExportRows([], [{ values: [], count: 3 }], 3, '车次'), [{ 统计范围: '当前筛选结果', '数量(车次)': 3, '占比(%)': 100 }]);
});
test('vehicle exports preserve full records, server masking and pending/movement fields without treating missing time as zero', () => {
const vehicles = Array.from({ length: 45 }, (_, id) => ({ id, plateNumber: `车-${id}`, vin: '001234', status: 'Inventory', operationStatus: '3', customerName: '测试***公司' } as VehicleListItem));
vehicles[0].pendingDelivery = { contractNo: '001-CONTRACT', customerName: '待交***公司' };
vehicles[44].abnormalMove = { id: 'move', type: '维修', status: '待结束', work: '更换配件', elapsedDays: null } as NonNullable<VehicleListItem['abnormalMove']>;
const rows = vehicleExportRows(vehicles);
assert.equal(rows.length, 45);
assert.equal(rows[0].VIN, '001234');
assert.equal(rows[0].客户, '测试***公司');
assert.equal(rows[0].待交车合同, '001-CONTRACT');
assert.equal(rows[44].异动类型, '维修');
assert.equal(rows[44]['已异动时长(天)'], null);
assert.equal(rows[44].实际完成时间, '未完成');
assert.equal(rows[0].状态, '在库');
assert.equal(rows[0].运营状态, '可运营');
});
test('one Excel workbook contains statistics and all details in two named sheets', async () => {
const { createAnalysisWorkbook } = await import('./analysis-export');
const XLSX = await import('xlsx');
const statistics = statisticsExportRows(['区域'], [{ values: ['华东'], count: 105 }], 105);
const details = Array.from({ length: 105 }, (_, i) => ({ VIN: `000${i}`, 合同: i === 104 ? 'LAST-CONTRACT' : 'CONTRACT', 项目名称: '项目甲' }));
const workbook = await createAnalysisWorkbook(statistics, details);
const saved = XLSX.read(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }), { type: 'buffer' });
assert.deepEqual(saved.SheetNames, ['统计表', '详情表']);
assert.deepEqual(XLSX.utils.sheet_to_json(saved.Sheets['统计表']), statistics);
assert.deepEqual(XLSX.utils.sheet_to_json(saved.Sheets['详情表']), details);
const empty = await createAnalysisWorkbook([], []);
assert.deepEqual(empty.SheetNames, ['统计表', '详情表']);
});
test('inventory export uses assigned operations managers and a dash when unset', () => {
const vehicles = ['张三、李四', null, ' '].map(opsManagers => ({
opsManagers, customerManager: '业务负责人', customerName: '客户', status: 'Inventory',
} as VehicleListItem));
const rows = vehicleExportRows(vehicles, true);
assert.deepEqual(rows.map(row => row.运维负责人), ['张三、李四', '-', '-']);
assert.ok(rows.every(row => !('业务负责人' in row) && !('客户' in row)));
assert.equal(vehicleExportRows(vehicles)[0].业务负责人, '业务负责人');
});
-54
View File
@@ -1,54 +0,0 @@
import type { VehicleListItem } from './types';
export type ExportRow = Record<string, string | number | null | undefined>;
/** Use complete filtered groups, never the progressively rendered slice. Repeat parent labels for Excel filtering. */
export function statisticsExportRows(labels: string[], groups: { values: string[]; count: number; extra?: ExportRow }[], total: number, unit = '台'): ExportRow[] {
return groups.map(group => ({
...Object.fromEntries(labels.length ? labels.map((label, index) => [label, group.values[index]]) : [['统计范围', '当前筛选结果']]),
[`数量(${unit})`]: group.count,
...group.extra,
'占比(%)': total ? Number((group.count / total * 100).toFixed(1)) : 0,
}));
}
export function vehicleExportRows(vehicles: VehicleListItem[], inventory = false): ExportRow[] {
const status: Record<string, string> = { Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' };
const operation: Record<string, string> = { '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' };
const hasPending = vehicles.some(v => v.pendingDelivery);
const hasMovement = vehicles.some(v => v.abnormalMove);
return vehicles.map(v => ({
车牌: v.plateNumber, VIN: v.vin, 品牌: v.brandLabel, 车型: v.type, 型号: v.model,
省份: v.province, 城市: v.city || v.location,
状态: status[v.status] || v.status, 运营状态: operation[v.operationStatus || ''] || v.operationStatus,
...(inventory ? { 运维负责人: v.opsManagers?.trim() || '-' } : { 客户: v.customerName, 签约公司: v.orgName, 部门: v.departmentName, 业务负责人: v.customerManager }),
所属公司: v.subjectOrg, 合同: v.contractNo,
...(hasPending ? { 待交车合同: v.pendingDelivery?.contractNo, 待交车客户: v.pendingDelivery?.customerName } : {}),
...(hasMovement ? {
异动类型: v.abnormalMove?.type, 审批通过时间: v.abnormalMove?.approvalPassedAt,
'已异动时长(天)': v.abnormalMove?.elapsedDays == null ? null : Number(v.abnormalMove.elapsedDays.toFixed(1)),
具体工作: v.abnormalMove?.work, 申请人: v.abnormalMove?.applicant,
预计完成时间: v.abnormalMove?.targetCompletionTime,
实际完成时间: v.abnormalMove?.actualCompletionTime || (v.abnormalMove?.status === '待结束' ? '未完成' : null),
} : {}),
}));
}
export async function createAnalysisWorkbook(statistics: ExportRow[], details: ExportRow[]) {
const XLSX = await import('xlsx');
const workbook = XLSX.utils.book_new();
for (const [name, rows] of [['统计表', statistics], ['详情表', details]] as const) {
const sheet = XLSX.utils.json_to_sheet(rows.map(row => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, value ?? '—']))));
sheet['!cols'] = Object.keys(rows[0] || {}).map(label => ({ wch: /时间|合同|项目|工作/.test(label) ? 28 : 18 }));
if (sheet['!ref']) sheet['!autofilter'] = { ref: sheet['!ref'] };
XLSX.utils.book_append_sheet(workbook, sheet, name);
}
return workbook;
}
export async function downloadAnalysisWorkbook(statistics: ExportRow[], details: ExportRow[], title: string) {
const workbook = await createAnalysisWorkbook(statistics, details);
const XLSX = await import('xlsx');
const safeTitle = title.replace(/[\\/:*?"<>|\x00-\x1f]/g, '-').slice(0, 120);
XLSX.writeFile(workbook, `${safeTitle}.xlsx`, { compression: true });
}
-208
View File
@@ -1,208 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AssetAnalysisCell } from '../../shared/assets/analytics';
import {
aggregateAnalysisRows,
filterAnalysisCells,
filterCustomCells,
getAnalysisDimensions,
getAnalysisSlice,
getAvailableDimensions,
getCustomAnalysis,
getDefaultDimensions,
summarizeAnalyticsCells,
} from './analytics-model';
function cell(overrides: Partial<AssetAnalysisCell>): AssetAnalysisCell {
return {
type: '18T', model: '车型甲', region: '华东', city: '嘉兴市',
assetStatus: 'inventory', assetDisposition: '在库', operationKind: '可运营',
inventoryState: 'available', abnormalType: '不适用', abnormalAge: 'unknown',
department: '业务一部', manager: '负责人甲', customer: '客户甲', quantity: 1,
...overrides,
};
}
const cells = [
cell({ quantity: 5 }),
cell({ inventoryState: 'abnormal', abnormalType: '维修', abnormalAge: '30d+', quantity: 3 }),
cell({ region: '华南', city: '广州市', inventoryState: 'pending', quantity: 2 }),
cell({ assetStatus: 'operating', assetDisposition: '租赁', operationKind: '租赁', inventoryState: 'not-applicable', quantity: 10 }),
cell({ type: '49T', region: '华南', city: '深圳市', assetStatus: 'operating', assetDisposition: '自营', operationKind: '自营', inventoryState: 'not-applicable', quantity: 4 }),
cell({ assetStatus: 'other', assetDisposition: '其他', operationKind: '未分类', inventoryState: 'not-applicable', quantity: 1 }),
];
test('inventory subset counts reconcile at group and filtered-total levels without adding to inventory', () => {
assert.ok(!getAvailableDimensions('inventory').includes('manager'));
assert.ok(!getAvailableDimensions('inventory').includes('customer'));
assert.ok(getAvailableDimensions('operating').includes('manager'));
assert.ok(getAvailableDimensions('operating').includes('customer'));
const result = getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region', 'type'] });
assert.equal(result.total, 10);
assert.equal(result.summary.pending, 2);
assert.equal(result.summary.abnormal, 3);
assert.equal(result.rows.reduce((sum, row) => sum + row.totals.pending, 0), 2);
assert.equal(result.rows.reduce((sum, row) => sum + row.totals.abnormal, 0), 3);
const filtered = getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region', 'type'], filterValues: { region: ['华东'] } });
assert.equal(filtered.total, 8);
assert.equal(filtered.summary.pending, 0);
assert.equal(filtered.summary.abnormal, 3);
assert.equal(filtered.rows[0].totals.pending, 0);
assert.equal(filtered.rows[0].totals.abnormal, 3);
});
test('management totals reconcile without double-counting inventory subsets', () => {
const summary = summarizeAnalyticsCells(cells);
assert.equal(summary.assets, 25);
assert.equal(summary.operating, 14);
assert.equal(summary.inventory, 10);
assert.equal(summary.other, 1);
assert.equal(summary.abnormal, 3);
assert.equal(summary.pending, 2);
assert.equal(summary.abnormalRate, 30);
assert.equal(summary.pendingRate, 20);
assert.ok(Math.abs(summary.operatingRate - 56) < 1e-10);
});
test('operating drill keeps region, city, type and operation kind as aggregate levels', () => {
let slice = getAnalysisSlice(cells, { view: 'operating' });
assert.equal(slice.dimension, 'region');
assert.equal(slice.total, 14);
assert.deepEqual(slice.rows.map(row => row.count), [10, 4]);
assert.equal(slice.rows.reduce((sum, row) => sum + row.share, 0), 100);
for (const expectedDimension of ['city', 'type', 'operationKind']) {
assert.equal(slice.isLeaf, false);
slice = getAnalysisSlice(cells, { view: 'operating', path: slice.rows[0].nextPath });
assert.equal(slice.dimension, expectedDimension);
assert.equal(slice.total, 10);
}
slice = getAnalysisSlice(cells, { view: 'operating', path: slice.rows[0].nextPath });
assert.equal(slice.isLeaf, true);
assert.equal(slice.dimension, null);
assert.deepEqual(slice.rows, []);
assert.equal(summarizeAnalyticsCells(filterAnalysisCells(cells, slice.filters, slice.metric)).assets, slice.total);
});
test('asset uses mutually exclusive stock, lease and self-operated dispositions', () => {
const slice = getAnalysisSlice(cells, { view: 'assets', filters: { type: '18T' } });
assert.equal(slice.dimension, 'assetDisposition');
assert.equal(slice.rows.reduce((sum, row) => sum + row.count, 0), slice.total);
assert.deepEqual(slice.rows.map(row => row.value).sort(), ['其他', '在库', '租赁'].sort());
});
test('fixed global dimensions are skipped and conflicting stale paths cannot widen results', () => {
const slice = getAnalysisSlice(cells, {
view: 'operating', filters: { region: '华南', type: '49T' },
path: [{ dimension: 'region', value: '华南' }, { dimension: 'city', value: '深圳市' }],
});
assert.equal(slice.dimension, 'operationKind');
assert.equal(slice.total, 4);
assert.deepEqual(slice.path, [{ dimension: 'city', value: '深圳市' }]);
const conflict = getAnalysisSlice(cells, {
view: 'operating', filters: { region: '华南' }, path: [{ dimension: 'region', value: '华东' }],
});
assert.equal(conflict.total, 0);
assert.deepEqual(conflict.rows, []);
});
test('abnormal pivot retains all three dimensions once and ends at vehicle type', () => {
for (const start of ['abnormalType', 'region', 'abnormalAge'] as const) {
const dimensions = getAnalysisDimensions('abnormal', start);
assert.equal(dimensions[0], start);
assert.equal(dimensions.at(-1), 'type');
assert.equal(new Set(dimensions).size, dimensions.length);
}
const missingTime = cell({ inventoryState: 'abnormal', abnormalType: '维修', abnormalAge: 'unknown', quantity: 2 });
const slice = getAnalysisSlice([...cells, missingTime], { view: 'abnormal', abnormalStart: 'abnormalAge' });
assert.equal(slice.total, 5);
assert.equal(slice.rows.find(row => row.value === 'unknown')?.label, '审批时间缺失');
assert.equal(slice.rows.find(row => row.value === 'unknown')?.count, 2);
});
test('chronological label sort preserves all duration buckets without rounding totals', () => {
const durationCells = ['unknown', '30d+', '0-3d', '15-30d', '7-15d', '3-7d'].map(abnormalAge => cell({
inventoryState: 'abnormal', abnormalAge: abnormalAge as AssetAnalysisCell['abnormalAge'],
}));
const rows = aggregateAnalysisRows(durationCells, 'abnormalAge', 'abnormal', 'label');
assert.deepEqual(rows.map(row => row.value), ['0-3d', '3-7d', '7-15d', '15-30d', '30d+', 'unknown']);
assert.equal(rows.reduce((sum, row) => sum + row.count, 0), 6);
assert.equal(summarizeAnalyticsCells([]).operatingRate, 0);
});
test('custom analysis combines independent dimensions and conserves the selected metric', () => {
const analysis = getCustomAnalysis(cells, { view: 'assets', dimensions: ['region', 'type'] });
assert.deepEqual(analysis.dimensions, ['region', 'type']);
assert.equal(analysis.total, 25);
assert.equal(analysis.rows.length, 3);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), analysis.total);
assert.equal(analysis.rows.find(row => row.values.region === '华东')?.count, 19);
assert.equal(analysis.rows.find(row => row.values.region === '华南' && row.values.type === '18T')?.count, 2);
assert.equal(analysis.rows.find(row => row.values.region === '华南' && row.values.type === '49T')?.count, 4);
assert.ok(Math.abs(analysis.rows.reduce((sum, row) => sum + row.share, 0) - 100) < 1e-10);
});
test('custom composite keys cannot collide when dimension values contain delimiters', () => {
const collisionCells = [
cell({ region: 'A · B', city: 'C', quantity: 2 }),
cell({ region: 'A', city: 'B · C', quantity: 3 }),
cell({ region: '["A","B"]', city: '"C"', quantity: 4 }),
];
const analysis = getCustomAnalysis(collisionCells, { view: 'assets', dimensions: ['region', 'city'] });
assert.equal(analysis.rows.length, 3);
assert.equal(new Set(analysis.rows.map(row => row.key)).size, 3);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), 9);
});
test('custom filters OR values within a dimension and AND across dimensions', () => {
const filterValues = { region: ['华东', '华南'], type: ['18T'], inventoryState: ['available', 'pending'] };
const filtered = filterCustomCells(cells, filterValues, 'inventory');
assert.equal(summarizeAnalyticsCells(filtered).inventory, 7);
const analysis = getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region'], filterValues });
assert.equal(analysis.total, 7);
assert.deepEqual(analysis.rows.map(row => row.count), [5, 2]);
assert.equal(analysis.rows[0].share, 5 / 7 * 100);
assert.equal(getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region'], filterValues: { region: ['不存在区域'] } }).total, 0);
});
test('empty filter selections impose no restriction and filter-only analysis gives one total', () => {
const analysis = getCustomAnalysis(cells, { view: 'operating', dimensions: [], filterValues: { region: [] } });
assert.equal(analysis.total, 14);
assert.equal(analysis.rows.length, 1);
assert.equal(analysis.rows[0].label, '合计');
assert.equal(analysis.rows[0].count, 14);
assert.equal(analysis.rows[0].share, 100);
assert.deepEqual(analysis.rows[0].values, {});
assert.equal(analysis.summary.assets, 25);
assert.equal(getCustomAnalysis([], { view: 'operating', dimensions: [] }).rows.length, 0);
assert.equal(getCustomAnalysis([cell({})], { view: 'operating', dimensions: [] }).rows.length, 0);
});
test('custom shares use the selected metric rather than all assets in the scope', () => {
const analysis = getCustomAnalysis(cells, { view: 'operating', dimensions: ['region'] });
assert.equal(analysis.total, 14);
assert.equal(analysis.summary.assets, 25);
assert.equal(analysis.rows.find(row => row.values.region === '华东')?.share, 10 / 14 * 100);
assert.equal(analysis.rows.find(row => row.values.region === '华南')?.share, 4 / 14 * 100);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), analysis.total);
});
test('custom dimensions remove unknown, duplicate and inapplicable values without losing counts', () => {
const analysis = getCustomAnalysis(cells, {
view: 'operating',
dimensions: ['region', 'bogus', 'region', 'abnormalAge', 'type'] as Parameters<typeof getCustomAnalysis>[1]['dimensions'],
});
assert.deepEqual(analysis.dimensions, ['region', 'type']);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), 14);
for (const view of ['assets', 'operating', 'inventory', 'abnormal', 'pending', 'department', 'customer'] as const) {
assert.equal(getDefaultDimensions(view).length, 1);
assert.ok(getAvailableDimensions(view).includes(getDefaultDimensions(view)[0]));
}
});
test('custom sorting supports count order and semantic duration order', () => {
const ascending = getCustomAnalysis(cells, { view: 'assets', dimensions: ['region', 'type'], sort: 'count-asc' });
assert.deepEqual(ascending.rows.map(row => row.count), [2, 4, 19]);
const durationCells = ['unknown', '30d+', '0-3d'].map(abnormalAge => cell({ inventoryState: 'abnormal', abnormalAge: abnormalAge as AssetAnalysisCell['abnormalAge'] }));
const byLabel = getCustomAnalysis(durationCells, { view: 'abnormal', dimensions: ['abnormalAge'], sort: 'label' });
assert.deepEqual(byLabel.rows.map(row => row.values.abnormalAge), ['0-3d', '30d+', 'unknown']);
});
-365
View File
@@ -1,365 +0,0 @@
import type {
AssetAnalysisCell,
AssetAnalysisDimension,
AssetAnalysisFilters,
AssetAnalysisMetric,
} from '../../shared/assets/analytics';
import { ASSET_ANALYSIS_DIMENSIONS } from '../../shared/assets/analytics';
export type AnalysisView = AssetAnalysisMetric | 'department' | 'customer';
export type AnalysisSort = 'count-desc' | 'count-asc' | 'label';
export type AbnormalStartDimension = 'abnormalType' | 'region' | 'abnormalAge';
export interface AnalysisPathStep {
dimension: AssetAnalysisDimension;
value: string;
}
export interface AnalysisTotals {
assets: number;
operating: number;
inventory: number;
abnormal: number;
pending: number;
other: number;
/** All rates are percentages, not fractions. */
operatingRate: number;
inventoryRate: number;
/** Abnormal and pending are inventory subsets; their denominator is inventory. */
abnormalRate: number;
pendingRate: number;
}
export interface AnalysisAggregateRow {
value: string;
label: string;
count: number;
/** Share of the current metric total, as a percentage. */
share: number;
totals: AnalysisTotals;
}
export interface AnalysisRow extends AnalysisAggregateRow {
nextPath: AnalysisPathStep[];
isLeaf: boolean;
}
export interface AnalysisOptions {
view: AnalysisView;
filters?: AssetAnalysisFilters;
path?: readonly AnalysisPathStep[];
abnormalStart?: AbnormalStartDimension;
sort?: AnalysisSort;
}
export interface AnalysisSlice {
metric: AssetAnalysisMetric;
dimension: AssetAnalysisDimension | null;
dimensions: AssetAnalysisDimension[];
path: AnalysisPathStep[];
filters: AssetAnalysisFilters;
rows: AnalysisRow[];
total: number;
summary: AnalysisTotals;
isLeaf: boolean;
breadcrumbs: Array<AnalysisPathStep & { label: string }>;
}
export const ANALYSIS_META: Record<AnalysisView, {
title: string;
description: string;
metric: AssetAnalysisMetric;
unit: '台';
}> = {
assets: { title: '资产结构', description: '从车型看在库、租赁与自营结构,再定位区域和城市。', metric: 'assets', unit: '台' },
operating: { title: '运营分析', description: '按区域、城市、车型和运营状态逐层比较运营分布。', metric: 'operating', unit: '台' },
inventory: { title: '库存分析', description: '按区域、城市和车型定位库存。', metric: 'inventory', unit: '台' },
abnormal: { title: '异动分析', description: '交叉分析异动类型、区域与已异动时长,识别集中与积压。', metric: 'abnormal', unit: '台' },
pending: { title: '待交车分析', description: '按大区、城市和车型查看待交车分布。', metric: 'pending', unit: '台' },
department: { title: '部门分析', description: '从部门到业务负责人和车型,查看运营资产配置。', metric: 'operating', unit: '台' },
customer: { title: '客户分析', description: '从客户到车型和区域,查看运营资产集中度。', metric: 'operating', unit: '台' },
};
export const DIMENSION_LABELS: Record<AssetAnalysisDimension, string> = {
type: '车型', model: '型号', region: '区域', city: '城市',
assetStatus: '资产状态', assetDisposition: '资产用途', operationKind: '运营状态',
inventoryState: '库存状态', abnormalType: '异动类型', abnormalAge: '已异动时长',
department: '部门', manager: '业务负责人', customer: '客户',
};
const VALUE_LABELS: Partial<Record<AssetAnalysisDimension, Record<string, string>>> = {
assetStatus: { operating: '运营中', inventory: '在库', other: '其他状态' },
inventoryState: {
abnormal: '异动', pending: '待交车', available: '可运营', awaiting: '待运营',
unclassified: '未分类库存', 'not-applicable': '不适用',
},
abnormalAge: {
'0-3d': '不足 3 天', '3-7d': '3–7 天', '7-15d': '7–15 天',
'15-30d': '15–30 天', '30d+': '30 天及以上', unknown: '审批时间缺失',
},
};
const DIMENSION_ORDER: Partial<Record<AssetAnalysisDimension, readonly string[]>> = {
region: ['华东', '华南', '华北', '华中', '西南', '西北', '其他'],
abnormalAge: ['0-3d', '3-7d', '7-15d', '15-30d', '30d+', 'unknown'],
assetStatus: ['inventory', 'operating', 'other'],
assetDisposition: ['在库', '租赁', '自营', '其他运营', '其他'],
inventoryState: ['available', 'awaiting', 'pending', 'abnormal', 'unclassified', 'not-applicable'],
};
const PATHS: Record<Exclude<AnalysisView, 'abnormal'>, readonly AssetAnalysisDimension[]> = {
assets: ['type', 'assetDisposition', 'region', 'city'],
operating: ['region', 'city', 'type', 'operationKind'],
inventory: ['region', 'city', 'type', 'inventoryState'],
pending: ['region', 'city', 'type'],
department: ['department', 'manager', 'type'],
customer: ['customer', 'type', 'region'],
};
export function getAnalysisDimensions(
view: AnalysisView,
abnormalStart: AbnormalStartDimension = 'abnormalType',
): AssetAnalysisDimension[] {
if (view !== 'abnormal') return [...PATHS[view]];
const comparisons: AbnormalStartDimension[] = ['abnormalType', 'region', 'abnormalAge'];
return [abnormalStart, ...comparisons.filter(dimension => dimension !== abnormalStart), 'type'];
}
export function getDimensionValueLabel(dimension: AssetAnalysisDimension, value: string): string {
return VALUE_LABELS[dimension]?.[value] ?? (value || '未标注');
}
/** Counts derive from the same mutually exclusive state dimensions as the server. */
export function cellMatchesMetric(cell: AssetAnalysisCell, metric: AssetAnalysisMetric): boolean {
switch (metric) {
case 'assets': return true;
case 'operating': return cell.assetStatus === 'operating';
case 'inventory': return cell.assetStatus === 'inventory';
case 'abnormal': return cell.assetStatus === 'inventory' && cell.inventoryState === 'abnormal';
case 'pending': return cell.assetStatus === 'inventory' && cell.inventoryState === 'pending';
}
}
export function filterAnalysisCells(
cells: readonly AssetAnalysisCell[],
filters: AssetAnalysisFilters = {},
metric: AssetAnalysisMetric = 'assets',
): AssetAnalysisCell[] {
const entries = Object.entries(filters).filter(([, value]) => value !== undefined && value !== '') as Array<[AssetAnalysisDimension, string]>;
return cells.filter(cell => cellMatchesMetric(cell, metric) && entries.every(([dimension, value]) => cell[dimension] === value));
}
const percent = (count: number, total: number): number => total > 0 ? count / total * 100 : 0;
export function summarizeAnalyticsCells(cells: readonly AssetAnalysisCell[]): AnalysisTotals {
const totals: AnalysisTotals = {
assets: 0, operating: 0, inventory: 0, abnormal: 0, pending: 0, other: 0,
operatingRate: 0, inventoryRate: 0, abnormalRate: 0, pendingRate: 0,
};
for (const cell of cells) {
totals.assets += cell.quantity;
if (cell.assetStatus === 'operating') totals.operating += cell.quantity;
else if (cell.assetStatus === 'inventory') {
totals.inventory += cell.quantity;
if (cell.inventoryState === 'abnormal') totals.abnormal += cell.quantity;
if (cell.inventoryState === 'pending') totals.pending += cell.quantity;
} else totals.other += cell.quantity;
}
totals.operatingRate = percent(totals.operating, totals.assets);
totals.inventoryRate = percent(totals.inventory, totals.assets);
totals.abnormalRate = percent(totals.abnormal, totals.inventory);
totals.pendingRate = percent(totals.pending, totals.inventory);
return totals;
}
export function aggregateAnalysisRows(
cells: readonly AssetAnalysisCell[],
dimension: AssetAnalysisDimension,
metric: AssetAnalysisMetric = 'assets',
sort: AnalysisSort = 'count-desc',
): AnalysisAggregateRow[] {
const groups = new Map<string, AssetAnalysisCell[]>();
for (const cell of cells) {
const value = cell[dimension];
const group = groups.get(value);
if (group) group.push(cell);
else groups.set(value, [cell]);
}
const total = summarizeAnalyticsCells(cells)[metric];
const rows: AnalysisAggregateRow[] = [];
for (const [value, group] of groups) {
const totals = summarizeAnalyticsCells(group);
const count = totals[metric];
if (count > 0) rows.push({ value, label: getDimensionValueLabel(dimension, value), count, share: percent(count, total), totals });
}
const compareLabels = (a: AnalysisAggregateRow, b: AnalysisAggregateRow) => {
const order = DIMENSION_ORDER[dimension];
if (order) {
const aIndex = order.indexOf(a.value);
const bIndex = order.indexOf(b.value);
const result = (aIndex < 0 ? order.length : aIndex) - (bIndex < 0 ? order.length : bIndex);
if (result !== 0) return result;
}
return a.label.localeCompare(b.label, 'zh-CN', { numeric: true });
};
return rows.sort((a, b) => {
const difference = sort === 'count-desc' ? b.count - a.count : sort === 'count-asc' ? a.count - b.count : 0;
return difference || compareLabels(a, b);
});
}
/** Keep global filters and drill selections intersected; a stale path cannot widen scope. */
export function getAnalysisSlice(cells: readonly AssetAnalysisCell[], options: AnalysisOptions): AnalysisSlice {
const { view, abnormalStart = 'abnormalType', sort = 'count-desc' } = options;
const metric = ANALYSIS_META[view].metric;
const filters: AssetAnalysisFilters = {};
for (const [key, value] of Object.entries(options.filters ?? {})) {
if (value !== undefined && value !== '') filters[key as AssetAnalysisDimension] = value;
}
const dimensions = getAnalysisDimensions(view, abnormalStart).filter(dimension => filters[dimension] === undefined);
const path: AnalysisPathStep[] = [];
let conflictingPath = false;
for (const step of options.path ?? []) {
const globalValue = filters[step.dimension];
if (globalValue !== undefined) {
if (globalValue !== step.value) conflictingPath = true;
continue;
}
// Only a prefix of this analysis schema is valid. Unrelated old paths are discarded.
if (dimensions[path.length] !== step.dimension) break;
path.push({ dimension: step.dimension, value: step.value });
}
for (const step of path) filters[step.dimension] = step.value;
const scopedCells = conflictingPath ? [] : filterAnalysisCells(cells, filters);
const summary = summarizeAnalyticsCells(scopedCells);
const dimension = dimensions[path.length] ?? null;
const isLeaf = dimension === null;
const rows: AnalysisRow[] = dimension === null ? [] : aggregateAnalysisRows(scopedCells, dimension, metric, sort).map(row => ({
...row,
nextPath: [...path, { dimension, value: row.value }],
isLeaf: path.length + 1 === dimensions.length,
}));
return {
metric, dimension, dimensions, path, filters, rows, total: summary[metric], summary, isLeaf,
breadcrumbs: path.map(step => ({ ...step, label: getDimensionValueLabel(step.dimension, step.value) })),
};
}
export type AnalysisFilterValues = Partial<Record<AssetAnalysisDimension, string[]>>;
export interface CustomAnalysisRow {
key: string;
values: Partial<Record<AssetAnalysisDimension, string>>;
label: string;
count: number;
share: number;
totals: AnalysisTotals;
}
export interface CustomAnalysisOptions {
view: AnalysisView;
dimensions: readonly AssetAnalysisDimension[];
filterValues?: AnalysisFilterValues;
sort?: AnalysisSort;
}
export interface CustomAnalysis {
rows: CustomAnalysisRow[];
total: number;
summary: AnalysisTotals;
dimensions: AssetAnalysisDimension[];
metric: AssetAnalysisMetric;
}
const COMMON_DIMENSIONS: readonly AssetAnalysisDimension[] = [
'type', 'model', 'region', 'city', 'department', 'manager', 'customer',
];
const BUSINESS_DIMENSIONS: Record<AnalysisView, readonly AssetAnalysisDimension[]> = {
assets: ['assetDisposition', 'operationKind'],
operating: ['operationKind'],
inventory: ['operationKind'],
abnormal: ['abnormalType', 'abnormalAge'],
pending: [],
department: ['operationKind'],
customer: ['operationKind'],
};
/** Dimensions are choices, not a required drill sequence. */
export function getAvailableDimensions(view: AnalysisView): AssetAnalysisDimension[] {
const common = ['inventory', 'pending', 'abnormal'].includes(view)
? COMMON_DIMENSIONS.filter(dimension => dimension !== 'department' && dimension !== 'manager' && dimension !== 'customer')
: COMMON_DIMENSIONS;
return [...common, ...BUSINESS_DIMENSIONS[view]];
}
export function getDefaultDimensions(view: AnalysisView): AssetAnalysisDimension[] {
if (view === 'assets') return ['type'];
if (view === 'abnormal') return ['abnormalType'];
if (view === 'department') return ['department'];
if (view === 'customer') return ['customer'];
return ['region'];
}
/** Multiple values of one dimension are OR; selections across dimensions are AND. */
export function filterCustomCells(
cells: readonly AssetAnalysisCell[],
filterValues: AnalysisFilterValues = {},
metric: AssetAnalysisMetric = 'assets',
): AssetAnalysisCell[] {
const constraints: Array<[AssetAnalysisDimension, Set<string>]> = [];
for (const dimension of ASSET_ANALYSIS_DIMENSIONS) {
const selected = filterValues[dimension];
if (!Array.isArray(selected) || selected.length === 0) continue;
constraints.push([dimension, new Set(selected)]);
}
return cells.filter(cell => cellMatchesMetric(cell, metric)
&& constraints.every(([dimension, selected]) => selected.has(cell[dimension])));
}
export function compareCustomLabels(a: CustomAnalysisRow, b: CustomAnalysisRow, dimensions: readonly AssetAnalysisDimension[]): number {
for (const dimension of dimensions) {
const aValue = a.values[dimension]!;
const bValue = b.values[dimension]!;
const order = DIMENSION_ORDER[dimension];
if (order) {
const aIndex = order.indexOf(aValue);
const bIndex = order.indexOf(bValue);
const difference = (aIndex < 0 ? order.length : aIndex) - (bIndex < 0 ? order.length : bIndex);
if (difference !== 0) return difference;
}
const difference = getDimensionValueLabel(dimension, aValue).localeCompare(getDimensionValueLabel(dimension, bValue), 'zh-CN', { numeric: true });
if (difference !== 0) return difference;
}
return a.key.localeCompare(b.key);
}
export function getCustomAnalysis(cells: readonly AssetAnalysisCell[], options: CustomAnalysisOptions): CustomAnalysis {
const metric = ANALYSIS_META[options.view].metric;
const available = new Set(getAvailableDimensions(options.view));
const dimensions = [...new Set(options.dimensions.filter(dimension => available.has(dimension)))];
const scopedCells = filterCustomCells(cells, options.filterValues);
const summary = summarizeAnalyticsCells(scopedCells);
const total = summary[metric];
const groups = new Map<string, { values: CustomAnalysisRow['values']; cells: AssetAnalysisCell[] }>();
for (const cell of scopedCells) {
// Structured JSON preserves values containing separators or quotes.
const entries = dimensions.map(dimension => [dimension, cell[dimension]] as const);
const key = JSON.stringify(entries);
const group = groups.get(key);
if (group) group.cells.push(cell);
else groups.set(key, { values: Object.fromEntries(entries), cells: [cell] });
}
const rows: CustomAnalysisRow[] = [];
for (const [key, group] of groups) {
const totals = summarizeAnalyticsCells(group.cells);
const count = totals[metric];
if (count <= 0) continue;
const label = dimensions.length === 0 ? '合计' : dimensions.map(dimension => getDimensionValueLabel(dimension, group.values[dimension]!)).join(' · ');
rows.push({ key, values: group.values, label, count, share: percent(count, total), totals });
}
const sort = options.sort ?? 'count-desc';
rows.sort((a, b) => {
const difference = sort === 'count-desc' ? b.count - a.count : sort === 'count-asc' ? a.count - b.count : 0;
return difference || compareCustomLabels(a, b, dimensions);
});
return { rows, total, summary, dimensions, metric };
}
+2 -13
View File
@@ -12,7 +12,6 @@ import { fetchJson } from '../../auth/api-client';
const BASE = '/api/vehicles'; const BASE = '/api/vehicles';
export interface SubjectOption { export interface SubjectOption {
historicalOnly?: boolean;
name: string; name: string;
total: number; total: number;
inventory: number; inventory: number;
@@ -38,8 +37,6 @@ export async function fetchByType(subject?: string | null): Promise<TypeSummary[
} }
export async function fetchVehicleList(params: { export async function fetchVehicleList(params: {
inventoryKeys?: string[];
brand?: string;
batch?: string; batch?: string;
model?: string; model?: string;
location?: string; location?: string;
@@ -56,7 +53,6 @@ export async function fetchVehicleList(params: {
source?: string; source?: string;
}): Promise<VehicleListItem[]> { }): Promise<VehicleListItem[]> {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params.brand) query.set('brand', params.brand);
if (params.batch) query.set('batch', params.batch); if (params.batch) query.set('batch', params.batch);
if (params.model) query.set('model', params.model); if (params.model) query.set('model', params.model);
if (params.location) query.set('location', params.location); if (params.location) query.set('location', params.location);
@@ -71,11 +67,7 @@ export async function fetchVehicleList(params: {
if (params.attendance) query.set('attendance', params.attendance); if (params.attendance) query.set('attendance', params.attendance);
if (params.subject) query.set('subject', params.subject); if (params.subject) query.set('subject', params.subject);
if (params.source) query.set('source', params.source); if (params.source) query.set('source', params.source);
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`, params.inventoryKeys ? { return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inventoryKeys: params.inventoryKeys }),
} : undefined);
} }
export interface WeeklyDetailItem { export interface WeeklyDetailItem {
@@ -108,8 +100,6 @@ export interface FlowDetailItem {
department: string; department: string;
manager: string; manager: string;
customerName: string | null; customerName: string | null;
contractNo: string | null;
projectName: string | null;
} }
export interface FlowStatsResponse { export interface FlowStatsResponse {
@@ -163,14 +153,13 @@ export async function fetchRegionChart(
export async function fetchWeeklyDetail( export async function fetchWeeklyDetail(
type: string, type: string,
filters?: { model?: string; batch?: string; location?: string; source?: string; subject?: string | null }, filters?: { model?: string; batch?: string; location?: string; source?: string },
): Promise<WeeklyDetailItem[]> { ): Promise<WeeklyDetailItem[]> {
const params = new URLSearchParams({ type }); const params = new URLSearchParams({ type });
if (filters?.model && filters.model !== 'All') params.set('model', filters.model); if (filters?.model && filters.model !== 'All') params.set('model', filters.model);
if (filters?.batch && filters.batch !== 'All') params.set('batch', filters.batch); if (filters?.batch && filters.batch !== 'All') params.set('batch', filters.batch);
if (filters?.location && filters.location !== 'All') params.set('location', filters.location); if (filters?.location && filters.location !== 'All') params.set('location', filters.location);
if (filters?.source) params.set('source', filters.source); if (filters?.source) params.set('source', filters.source);
if (filters?.subject) params.set('subject', filters.subject);
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`); return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
} }
@@ -1,951 +0,0 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
ArrowUp,
ArrowUpRight,
BarChart3,
Building2,
CircleHelp,
ChevronDown,
RotateCcw,
Database,
History,
MessageSquare,
Moon,
Plus,
Sparkles,
Square,
Sun,
Truck,
X,
ArrowLeft,
} from "lucide-react";
import type {
AssistantResult,
AssistantAction,
Dimension,
} from "../../../shared/assets/assistant";
import {
CATEGORY_LABELS,
GROUP_LABELS,
} from "../../../shared/assets/assistant";
import { askAssetAssistant, AssistantRequestError } from "./client";
import { fetchAuthenticated } from "../../../auth/api-client";
import { useAuth } from "../../../auth/useAuth";
import { ThinkingState } from "./ThinkingState";
import { CompoundResultView } from "./CompoundResultView";
import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation";
import { useMobileLayout } from "../useMobileLayout";
import "./assistant.css";
import "./mobile-assistant.css";
type Turn = {
id: number;
question: string;
started: number;
steps: string[];
result?: AssistantResult;
resultHistory?: AssistantResult[];
updating?: boolean;
updateError?: string;
error?: string;
expired?: boolean;
stopped?: boolean;
baseResultId?: string;
action?: AssistantAction;
};
const PROMPTS = [
{
icon: BarChart3,
title: "现在多少车在运营、多少车在库存?",
mobileTitle: "运营与库存",
text: BUSINESS_QUESTIONS[0],
detail: "总资产 · 运营 · 库存 · 运营率",
},
{
icon: Truck,
title: "库存集中在哪些车型和区域?",
mobileTitle: "库存分布",
text: BUSINESS_QUESTIONS[1],
detail: "库存分布 · 继续查看车辆",
},
{
icon: History,
title: "最近7天交车、还车分别多少?",
mobileTitle: "近 7 天交还车",
text: BUSINESS_QUESTIONS[2],
detail: "按日趋势 · 业务明细",
},
{
icon: ListIcon,
title: "有哪些车辆需要跟进?",
mobileTitle: "待跟进车辆",
text: BUSINESS_QUESTIONS[3],
detail: "异动详情 · 预计完成时间",
},
];
function ListIcon({ size }: { size?: number }) {
return <Database size={size} />;
}
export default function AssetAssistant({
subject,
close,
appearance,
}: {
subject: string | null;
close: () => void;
appearance: "light" | "dark";
}) {
const { user } = useAuth();
const dataScopeLabel = !user ? "正在确认数据范围"
: user.permissionLevel === "full" ? "全量数据"
: user.permissionLevel === "department" ? "部门数据" : "本人数据";
const mobile = useMobileLayout();
const [contextExpanded, setContextExpanded] = useState(false);
const page = useRef<HTMLElement>(null);
useLayoutEffect(() => {
const viewport = window.visualViewport;
const mobile = window.matchMedia('(max-width: 767px)');
let frame = 0;
let unlock: (() => void) | undefined;
const updateViewport = () => {
frame = 0;
if (!mobile.matches || (viewport && viewport.scale !== 1)) return;
page.current?.style.setProperty('--aq-viewport-height', `${viewport?.height ?? window.innerHeight}px`);
page.current?.style.setProperty('--aq-viewport-top', `${viewport?.offsetTop ?? 0}px`);
};
const scheduleViewport = () => {
if (!frame) frame = requestAnimationFrame(updateViewport);
};
const syncMobile = () => {
unlock?.();
unlock = undefined;
if (mobile.matches) {
const scrollY = window.scrollY;
const body = document.body;
const previous = body.style.top;
body.style.top = `${-scrollY}px`;
body.classList.add('aq-mobile-open');
unlock = () => {
body.classList.remove('aq-mobile-open');
body.style.top = previous;
window.scrollTo({ top: scrollY, behavior: 'instant' });
};
}
updateViewport();
};
syncMobile();
mobile.addEventListener('change', syncMobile);
viewport?.addEventListener('resize', scheduleViewport);
viewport?.addEventListener('scroll', scheduleViewport);
window.addEventListener('resize', scheduleViewport);
return () => {
cancelAnimationFrame(frame);
mobile.removeEventListener('change', syncMobile);
viewport?.removeEventListener('resize', scheduleViewport);
viewport?.removeEventListener('scroll', scheduleViewport);
window.removeEventListener('resize', scheduleViewport);
unlock?.();
};
}, []);
const [theme, setTheme] = useState(appearance);
const [turns, setTurns] = useState<Turn[]>([]);
const turnsRef = useRef(turns);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [expandedTurns, setExpandedTurns] = useState<Set<number>>(
() => new Set(),
);
const [independent, setIndependent] = useState(false);
const [capability, setCapability] = useState<{
engine: "deepseek" | "local";
model: string | null;
message: string;
}>();
const [showInfo, setShowInfo] = useState(false);
const [capabilityError, setCapabilityError] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const [activeResult, setActiveResult] = useState<AssistantResult>();
const sessionRef = useRef<string | undefined>(undefined);
const sessions = useRef(new Map<string, string | null>());
const nextId = useRef(0);
const composer = useRef<HTMLTextAreaElement>(null);
const scroller = useRef<HTMLDivElement>(null);
const autoFollow = useRef<number | null>(null);
const readerRevision = useRef(0);
const scrollFrame = useRef<number | null>(null);
const [viewedTurn, setViewedTurn] = useState<number>();
useLayoutEffect(() => {
const input = composer.current;
if (!input) return;
input.style.height = "0px";
input.style.height = `${Math.min(132, Math.max(42, input.scrollHeight))}px`;
}, [draft]);
useEffect(() => () => {
if (scrollFrame.current !== null) cancelAnimationFrame(scrollFrame.current);
}, []);
const scrollToTurn = (id: number, resultId?: string) => {
const container = scroller.current;
const target = (resultId ? document.getElementById(`aq-query-${resultId}`) || document.getElementById(`aq-answer-${resultId}`) : undefined) || document.getElementById(`aq-turn-${id}`);
if (!container || !target) return;
const anchor = resultId ? target.closest(".aq-result") || target : target;
container.scrollTo({ top: container.scrollTop + anchor.getBoundingClientRect().top - container.getBoundingClientRect().top - 12, behavior: "instant" });
setViewedTurn(id);
};
const trackViewedTurn = () => {
if (scrollFrame.current !== null) return;
scrollFrame.current = requestAnimationFrame(() => {
scrollFrame.current = null;
const container = scroller.current;
if (!container) return;
const top = container.getBoundingClientRect().top + 24;
const visible = turnsRef.current.find(turn => {
const el = document.getElementById(`aq-turn-${turn.id}`);
return el && el.getBoundingClientRect().bottom > top;
});
if (visible) setViewedTurn(visible.id);
});
};
useEffect(() => {
turnsRef.current = turns;
}, [turns]);
const scopeResult =
activeResult?.plan.kind === "clarify"
? activeResult.breadcrumbs
?.map(
(crumb) =>
findConversationResult(turns, crumb.resultId),
)
.filter((result) => result && result.plan.kind !== "clarify")
.at(-1)
: activeResult;
const lastPlan = scopeResult?.plan;
const correctedBy = useMemo(() => {
const corrections = new Map<string, Turn>();
for (const turn of turns) {
const previousId = turn.result?.correction ? turn.result.correction.resultId || turn.baseResultId : undefined;
if (previousId) corrections.set(previousId, turn);
}
return corrections;
}, [turns]);
useEffect(() => {
composer.current?.focus({ preventScroll: true });
}, []);
useEffect(() => {
const controller = new AbortController();
void fetchAuthenticated("/api/asset-assistant/capabilities", {
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) throw new Error("服务状态不可用");
setCapability(await response.json());
})
.catch(() => {
if (!controller.signal.aborted) setCapabilityError(true);
});
return () => controller.abort();
}, []);
const revealTurn = (id: number) => {
setExpandedTurns((old) => new Set(old).add(id));
autoFollow.current = null;
setViewedTurn(id);
requestAnimationFrame(() => scrollToTurn(id, turnsRef.current.find(turn => turn.id === id)?.result?.resultId));
};
const startTopic = () => {
autoFollow.current = null;
setActiveResult(undefined);
sessionRef.current = undefined;
setIndependent(true);
composer.current?.focus({ preventScroll: true });
};
useEffect(
() => () => {
abortRef.current?.abort();
for (const [sessionId, sessionSubject] of sessions.current)
void fetchAuthenticated(
`/api/asset-assistant/session/${sessionId}?subject=${encodeURIComponent(sessionSubject || "")}`,
{ method: "DELETE" },
).catch(() => {});
},
[],
);
const stop = () => {
autoFollow.current = null;
abortRef.current?.abort();
abortRef.current = null;
setBusy(false);
setTurns((previous) =>
previous.map((t) =>
t.updating ? { ...t, updating: false, updateError: "已停止更新,保留原结果。" } : !t.result && !t.error
? { ...t, error: "已停止生成。可以修改问题,或重新生成。", stopped: true }
: t,
),
);
};
const ask = async (
message: string,
base: AssistantResult | null = activeResult ?? null,
action?: AssistantAction,
) => {
if (!message.trim() || abortRef.current) return;
setIndependent(false);
const inlineTurn = action && base?.resultId
? turnsRef.current.find(turn => !!findConversationResult([turn], base.resultId!))
: undefined;
const id = inlineTurn?.id ?? ++nextId.current;
autoFollow.current = inlineTurn ? null : id;
if (!inlineTurn) setViewedTurn(id);
const controller = new AbortController();
abortRef.current = controller;
if (!inlineTurn) setDraft("");
setBusy(true);
if (inlineTurn) setTurns(old => old.map(turn => turn.id === id ? { ...turn, updating: true, updateError: undefined } : turn));
else setTurns((old) => [
...old,
{
id,
question: message.trim(),
started: Date.now(),
baseResultId: base?.resultId,
action,
steps: ["正在理解你的问题"],
},
]);
requestAnimationFrame(() => { if (autoFollow.current === id) scrollToTurn(id); });
try {
await askAssetAssistant(
{
...(action ? { action } : { message }),
subject,
sessionId: base?.sessionId,
baseResultId: base?.resultId,
},
controller.signal,
(event) => {
if (controller.signal.aborted) return;
if (event.type === "result") {
if (inlineTurn) {
const container = scroller.current;
const anchor = (base?.resultId ? document.getElementById(`aq-query-${base.resultId}`)?.closest(".aq-result") : undefined) || document.getElementById(`aq-turn-${id}`);
const top = anchor?.getBoundingClientRect().top;
const revision = readerRevision.current;
requestAnimationFrame(() => {
if (container && anchor && top !== undefined && readerRevision.current === revision) {
container.scrollTop += anchor.getBoundingClientRect().top - top;
}
});
}
if (autoFollow.current === id)
requestAnimationFrame(() => requestAnimationFrame(() => {
if (autoFollow.current === id) scrollToTurn(id, event.result.resultId);
}));
sessionRef.current = event.result.sessionId;
if (event.result.sessionId)
sessions.current.set(event.result.sessionId, subject);
setActiveResult(event.result);
}
setTurns((old) =>
old.map((turn) =>
turn.id !== id
? turn
: event.type === "step"
? { ...turn, steps: [...turn.steps, event.label] }
: event.type === "error"
? inlineTurn ? { ...turn, updating: false, updateError: event.message } : { ...turn, error: event.message }
: event.type === "result"
? { ...replaceAnalysisResult(turn, event.result, action ? base?.resultId : undefined), updating: false, updateError: undefined }
: turn,
),
);
},
);
} catch (error) {
if (!controller.signal.aborted)
setTurns((old) =>
old.map((t) =>
t.id === id
? inlineTurn ? { ...t, updating: false, updateError: error instanceof Error ? error.message : "更新失败,请重试。" } : {
...t,
expired:
error instanceof AssistantRequestError &&
error.status === 409,
error:
error instanceof Error
? error.message
: "查询失败,请重试。",
}
: t,
),
);
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
setBusy(false);
if (autoFollow.current === id) composer.current?.focus({ preventScroll: true });
}
}
};
const reset = () => {
autoFollow.current = null;
setViewedTurn(undefined);
abortRef.current?.abort();
abortRef.current = null;
setBusy(false);
setIndependent(false);
if (sessionRef.current)
void fetchAuthenticated(
`/api/asset-assistant/session/${sessionRef.current}?subject=${encodeURIComponent(subject || "")}`,
{ method: "DELETE" },
).catch(() => {});
sessionRef.current = undefined;
setActiveResult(undefined);
setTurns([]);
setExpandedTurns(new Set());
setDraft("");
composer.current?.focus({ preventScroll: true });
};
useEffect(() => {
reset();
}, [subject]);
return (
<section
ref={page}
className={`aq-shell aq-page aq-${theme}`}
aria-label="AI问数"
>
<aside className="aq-sidebar">
<div className="aq-brand">
<div className="aq-brand-mark">
<Sparkles size={19} />
</div>
<div>
<strong>羚牛·资产智能</strong>
</div>
</div>
<button className="aq-new" onClick={reset}>
<Plus size={16} />
新对话<span>+</span>
</button>
<div className="aq-side-label">工作空间</div>
<button
className="aq-nav-active"
onClick={() => {
scroller.current?.scrollTo({ top: 0 });
composer.current?.focus({ preventScroll: true });
}}
>
<MessageSquare size={16} />
AI问数<span className="aq-beta">BETA</span>
</button>
<button className="aq-nav" onClick={close}>
<BarChart3 size={16} />
返回资产总览
<ArrowUpRight size={14} />
</button>
<div className="aq-side-label aq-history-label">
本次对话<span>{turns.length.toString().padStart(2, "0")}</span>
</div>
<div
className="aq-history"
aria-label="本次对话的全部问题"
tabIndex={0}
>
{turns.length ? (
turns.map((turn, index) => (
<button key={turn.id} title={turn.question} aria-current={viewedTurn === turn.id ? "true" : undefined} onClick={() => revealTurn(turn.id)}>
<span className="aq-history-number">{String(index + 1).padStart(2, "0")}</span>
<span>{conversationTitle(turn.question, turn.result)}</span>
</button>
))
) : (
<p>
从一个问题开始,
<br />
让数据给你答案。
</p>
)}
</div>
<div className="aq-side-bottom">
<div>
<Database size={16} />
<span>
资产台账<strong>真实业务数据</strong>
</span>
<i />
</div>
<p>
<Building2 size={13} />
沿用当前账号数据权限
</p>
</div>
</aside>
<div className="aq-workspace">
<header className="aq-header">
<div>
<Sparkles size={17} />
<h1>AI问数</h1>
<span className="aq-header-divider" />
<span className="aq-header-subtitle">用对话分析资产数据</span>
</div>
<div>
<button
className="aq-icon-button aq-mobile-new"
onClick={reset}
aria-label="新对话"
>
<Plus size={17} />
</button>
<button
className="aq-icon-button"
onClick={() => setShowInfo(!showInfo)}
aria-label="查询能力与数据说明"
aria-expanded={showInfo}
>
<CircleHelp size={17} />
</button>
<button
className="aq-icon-button"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
aria-label={theme === "light" ? "切换深色" : "切换浅色"}
>
{theme === "light" ? <Moon size={17} /> : <Sun size={17} />}
</button>
<button
className="aq-icon-button"
onClick={close}
aria-label="返回资产总览"
>
<ArrowLeft size={19} />
</button>
</div>
</header>
<div className="aq-scope" aria-label="当前可查询数据范围">
<Building2 size={13} />
<span>{subject || "全部所属公司"}</span>
<span className="aq-dot">·</span>
<span>{dataScopeLabel}</span>
</div>
{showInfo && (
<div className="aq-info">
<strong>支持的分析</strong>
<p>
当前资产数量、运营率、库存分布、交退换车趋势与车辆明细。可按区域、城市、车型、型号和部门筛选或分组。
</p>
<p>
资产口径:当前台账;运营率 = 运营车辆 /
筛选车辆。库存包含异动和待交车。可连续追问、切换分组、对比周期并查看明细。暂无数据的历史库存、成本或预测,会说明原因并记录需求。
</p>
<p>
{capability?.message ||
(capabilityError
? "暂时无法读取服务状态,可以直接提问重试。"
: "正在检查分析服务连接状态。")}
</p>
</div>
)}
<div ref={scroller} className="aq-conversation" onScroll={trackViewedTurn}
onWheel={() => { autoFollow.current = null; readerRevision.current++; }}
onTouchMove={() => { autoFollow.current = null; readerRevision.current++; }}
onPointerDown={() => { autoFollow.current = null; readerRevision.current++; }}
onKeyDown={(event) => { if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) { autoFollow.current = null; readerRevision.current++; } }}>
{!turns.length ? (
<div className="aq-welcome">
<div className="aq-welcome-mark">
<Sparkles size={30} strokeWidth={1.3} />
</div>
<h2>关于资产,你想了解什么?</h2>
<p>
用一句话,发现数据里的答案。
<span className="aq-welcome-detail"> 从全局统计,一路看到每一台车。</span>
</p>
<div className="aq-prompt-grid">
{PROMPTS.map((p) => (
<button key={p.title} onClick={() => void ask(p.text)}>
<div>
<p.icon size={18} aria-hidden="true" />
<ArrowUpRight size={14} aria-hidden="true" />
</div>
<strong>{mobile ? p.mobileTitle : p.title}</strong>
<span>{p.detail}</span>
</button>
))}
</div>
</div>
) : (
<div className="aq-turns">
{turns.map((turn, index) => {
const correction = turn.result?.resultId
? correctedBy.get(turn.result.resultId)
: undefined;
const collapsed =
index < turns.length - 1 &&
!!turn.result &&
!expandedTurns.has(turn.id);
return (
<section
id={`aq-turn-${turn.id}`}
key={turn.id}
className="aq-turn"
>
<div className="aq-question">
<span>你</span>
<p>{turn.question}</p>
</div>
{collapsed ? (
<button
className="aq-collapsed-answer"
onClick={() => revealTurn(turn.id)}
aria-expanded={false}
>
<Sparkles size={15} />
<span>
<strong>
{correction ? "此回答已撤回" : turn.result?.title}
</strong>
<span>
{correction?.result?.correction?.message ||
turn.result?.summary}
</span>
</span>
<ChevronDown size={16} />
</button>
) : (
<>
<div className="aq-assistant-label">
<span className="aq-small-mark">
<Sparkles size={15} />
</span>
<strong>资产助手</strong>
<span>
{(turn.result?.engine ?? capability?.engine) ===
"deepseek"
? "DeepSeek"
: (turn.result?.engine ?? capability?.engine) ===
"local"
? "本地分析"
: "连接中"}
</span>
{index < turns.length - 1 && turn.result && (
<button
className="aq-collapse-link"
onClick={() =>
setExpandedTurns((old) => {
const next = new Set(old);
next.delete(turn.id);
return next;
})
}
>
收起
</button>
)}
</div>
<ThinkingState
steps={turn.steps}
working={
!turn.result &&
!turn.error &&
busy &&
index === turns.length - 1
}
started={turn.started}
failed={!!turn.error && !turn.stopped}
stopped={turn.stopped}
outcome={turn.result?.outcome}
/>
{turn.updating && <p className="aq-inline-progress" role="status">正在更新当前卡片…</p>}
{turn.updateError && <p className="aq-inline-progress" role="status">{turn.updateError} 可再次点击分析操作重试。</p>}
{turn.error ? (
<div
className={`aq-error ${turn.stopped ? "aq-stopped" : ""}`}
role={turn.stopped ? "status" : "alert"}
>
<strong>
{turn.stopped
? "已停止生成"
: turn.expired
? "会话已过期"
: "查询失败"}
</strong>
<p>
{turn.expired
? "这次分析已过期,重新查询即可获取最新数据。"
: turn.error}
</p>
<button
disabled={busy}
onClick={() => {
if (turn.expired) {
startTopic();
setDraft(turn.question);
} else
void ask(
turn.question,
turns.find(
(t) =>
t.result?.resultId ===
turn.baseResultId,
)?.result ?? null,
turn.action,
);
}}
>
{turn.expired
? "重新开始查询"
: turn.stopped
? "重新生成"
: "重新尝试"}
<ArrowUpRight size={14} />
</button>
</div>
) : turn.result ? (
<CompoundResultView
result={turn.result}
ask={(text, base) => void ask(text, base)}
operate={(action, label, base) =>
void ask(label, base, action)
}
restore={(resultId) => {
const target = findConversationResult(turnsRef.current, resultId);
if (target)
void ask("返回此前分析", target, {
type: "restore",
});
}}
active={
activeResult?.resultId === turn.result.resultId
}
activeResultId={activeResult?.resultId}
correctionFor={(resultId) => {
const corrected = correctedBy.get(resultId)?.result;
return corrected ? { message: corrected.correction?.message, resultId: corrected.resultId } : undefined;
}}
activate={(base) => {
setActiveResult(base);
sessionRef.current = base.sessionId;
setIndependent(false);
}}
subject={subject}
disabled={busy || !!correction}
correctionMessage={
correction?.result?.correction?.message
}
correctionResultId={correction?.result?.resultId}
showCorrection={(resultId) => {
const target = turnsRef.current.find(
(item) => item.result?.resultId === resultId,
);
if (target?.result) {
setActiveResult(target.result);
sessionRef.current = target.result.sessionId;
setIndependent(false);
revealTurn(target.id);
}
}}
/>
) : null}
</>
)}
</section>
);
})}
</div>
)}
</div>
<div className="aq-composer-area">
{turns.length > 0 && (!mobile || (viewedTurn !== undefined && viewedTurn !== turns.at(-1)?.id)) && (
<button
className="aq-latest-answer"
onClick={() => revealTurn(turns[turns.length - 1].id)}
>
回到最新回答 <ArrowUpRight size={12} />
</button>
)}
{lastPlan && (
<details className="aq-context-disclosure" open={!mobile || contextExpanded} onToggle={event => { if (mobile) setContextExpanded(event.currentTarget.open); }}><summary><span>追问范围:{lastPlan.kind === 'flow' ? '交还车' : CATEGORY_LABELS[lastPlan.category]}{Object.keys(lastPlan.filters).length ? ` · ${Object.entries(lastPlan.filters).map(([dimension,values]) => `${GROUP_LABELS[dimension as Dimension]} ${values.length === 1 ? values[0] : `${values.length}项`}`).join(' · ')}` : ''}</span><ChevronDown size={15} /></summary><div className="aq-context">
<span className="aq-context-heading">当前条件</span>
<span>
{lastPlan.kind === "flow"
? "交退换车"
: CATEGORY_LABELS[lastPlan.category]}
</span>
<span>{GROUP_LABELS[lastPlan.groupBy]}</span>
{lastPlan.start && (
<span>
{lastPlan.start} — {lastPlan.end}
</span>
)}
{Object.entries(lastPlan.filters).map(([dimension, values]) => (
<button
key={dimension}
className="aq-filter-chip"
disabled={busy}
title={`移除${GROUP_LABELS[dimension as Dimension]}筛选`}
onClick={() =>
void ask(
`移除${GROUP_LABELS[dimension as Dimension]}筛选`,
scopeResult ?? null,
{
type: "clear-filter",
dimension: dimension as Dimension,
},
)
}
>
{GROUP_LABELS[dimension as Dimension]}:{values.join("、")}{" "}
<X size={11} />
</button>
))}
{Object.entries(lastPlan.excludeFilters || {}).map(
([dimension, values]) => (
<button
key={`exclude-${dimension}`}
className="aq-filter-chip"
disabled={busy}
title={`移除${GROUP_LABELS[dimension as Dimension]}排除条件`}
onClick={() =>
void ask(
`移除${GROUP_LABELS[dimension as Dimension]}排除条件`,
scopeResult ?? null,
{
type: "clear-filter",
dimension: dimension as Dimension,
},
)
}
>
排除{GROUP_LABELS[dimension as Dimension]}:
{values.join("、")} <X size={11} />
</button>
),
)}
{!!lastPlan.vehicleIds?.length && (
<span>已选 {lastPlan.vehicleIds.length} 台车辆</span>
)}
{lastPlan.exception && (
<span>
{lastPlan.exception === "overdue"
? "已超过预计完成时间"
: lastPlan.exception === "dueSoon"
? "即将到期"
: "未填写预计完成时间"}
</span>
)}
{Object.keys(lastPlan.filters).length > 0 ||
Object.keys(lastPlan.excludeFilters || {}).length > 0 ||
lastPlan.vehicleIds?.length ||
lastPlan.exception ? (
<button
className="aq-filter-chip"
disabled={busy}
onClick={() =>
void ask("清除全部筛选条件", scopeResult ?? null, {
type: "reset-filters",
})
}
>
清除筛选
</button>
) : null}
<button
className="aq-topic-button"
disabled={busy}
onClick={startTopic}
>
<Plus size={12} />
换个话题
</button>
</div></details>
)}
{independent && (
<p className="aq-topic-notice">
<RotateCcw size={12} /> 已开始独立问题,不继承之前的筛选条件
</p>
)}
<form
className="aq-composer"
onSubmit={(e) => {
e.preventDefault();
void ask(draft);
}}
>
<label className="sr-only" htmlFor="aq-question-input">
输入资产问题
</label>
<textarea
ref={composer}
id="aq-question-input"
value={draft}
maxLength={2000}
onChange={(e) => setDraft(e.target.value)}
rows={1}
placeholder={
turns.length
? "继续追问,或换一个问题…"
: "输入你的资产问题…"
}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!e.shiftKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault();
if (!busy) void ask(draft);
}
}}
/>
<div className="aq-composer-bottom">
<div>
<span className="aq-model" title={capability?.message}>
<Sparkles size={13} />
{capability?.engine === "deepseek"
? "DeepSeek"
: capability?.engine === "local"
? "本地分析 · 未连接模型"
: capabilityError
? "服务状态待确认"
: "检查服务中"}
</span>
<span className="aq-composer-source">
<Database size={12} />
资产数据
</span>
</div>
<div>
{draft.length > 1800 && (
<span className="aq-character-count">
{draft.length}/2000
</span>
)}
{busy ? (
<button
type="button"
className="aq-send"
onClick={stop}
aria-label="停止生成"
>
<Square size={15} fill="currentColor" />
</button>
) : (
<button
className="aq-send"
type="submit"
disabled={!draft.trim()}
aria-label="发送问题"
>
<ArrowUp size={19} />
</button>
)}
</div>
</div>
</form>
<div className="aq-composer-foot">
<span>AI 可能产生偏差,统计以业务明细为准。</span>
<span>Enter 发送 · Shift + Enter 换行</span>
</div>
</div>
</div>
</section>
);
}
-11
View File
@@ -1,11 +0,0 @@
import { lazy, Suspense } from "react";
import type { EChartsCoreOption } from "echarts/core";
import { Skeleton } from "@/components/ui/skeleton";
const EChart = lazy(() => import("./EChartImpl"));
export function Chart(props: { option: EChartsCoreOption; label: string }) {
return (
<Suspense fallback={<Skeleton className="h-64" />}>
<EChart {...props} />
</Suspense>
);
}
@@ -1,305 +0,0 @@
import { useState } from "react";
import { ArrowUpRight, Check, Copy, RotateCcw } from "lucide-react";
import type {
AssistantAction,
AssistantResult,
} from "../../../shared/assets/assistant";
import { ResultView, type ResultViewProps } from "./ResultView";
import { GenerativeResultView } from "./GenerativeResultView";
import { useMobileLayout } from '../useMobileLayout';
import { mobileResultSummary } from './mobile-presentation';
import { copyResultText } from "./result-presentation";
export type CompoundResultViewProps = Omit<
ResultViewProps,
"ask" | "operate" | "activate" | "display"
> & {
activeResultId?: string;
correctionFor?: (
resultId: string,
) => { message?: string; resultId?: string } | undefined;
ask: (text: string, base: AssistantResult) => void;
operate: (
action: AssistantAction,
label: string,
base: AssistantResult,
) => void;
activate: (result: AssistantResult) => void;
};
export function CompoundResultView({
result,
activeResultId,
correctionFor,
ask,
operate,
activate,
...props
}: CompoundResultViewProps) {
const mobile = useMobileLayout();
const [selection, setSelection] = useState<{
sectionId: string;
observedActiveId?: string;
}>();
const [notice, setNotice] = useState("");
const sections = result.sections || [];
if (!sections.length) {
const Renderer = result.presentation ? GenerativeResultView : ResultView;
return (
<Renderer
{...props}
result={result}
ask={(text) => ask(text, result)}
operate={(action, label) => operate(action, label, result)}
activate={() => activate(result)}
/>
);
}
// A failed section has no server result ID. Keep explicit user selection while
// the active context is unchanged; follow a different context after inline updates.
const locallySelected = sections.find(
(section) => section.id === selection?.sectionId,
);
const externallyActive = activeResultId
? sections.find(
(section) =>
!!section.result?.resultId &&
section.result.resultId === activeResultId,
)
: undefined;
const selected =
(selection?.observedActiveId === activeResultId
? locallySelected
: externallyActive) ||
locallySelected ||
externallyActive ||
sections[0];
const selectedIndex = sections.indexOf(selected);
const stacked = result.layout === "stack" && !mobile;
const renderSection = (section: typeof selected) => {
const child = section.result;
const correction = child?.resultId
? correctionFor?.(child.resultId)
: undefined;
const Renderer = child?.presentation ? GenerativeResultView : ResultView;
return child ? (
<Renderer
{...props}
key={section.id}
result={child}
display={section.display}
active={activeResultId === child.resultId}
ask={(text) => ask(text, child)}
operate={(action, label) => operate(action, label, child)}
activate={() => activate(child)}
disabled={props.disabled || !!correction}
correctionMessage={correction?.message}
correctionResultId={correction?.resultId}
/>
) : (
<div className="aq-section-error" role="status">
<strong>本项未完成</strong>
<p>{section.error || "这项分析暂未返回结果,可以重新尝试。"}</p>
<button
disabled={props.disabled}
onClick={() => ask(section.question, result)}
>
<RotateCcw size={13} />
重新查询此项
</button>
</div>
);
};
const panelId = `aq-section-panel-${result.resultId || "current"}`;
const tabId = (sectionId: string) =>
`aq-section-tab-${result.resultId || "current"}-${sectionId}`;
const select = (section: typeof selected) => {
setSelection({ sectionId: section.id, observedActiveId: activeResultId });
if (section.result) activate(section.result);
};
return (
<section className="aq-compound-result" aria-label="多项分析结果">
<header
className="aq-compound-heading"
id={result.resultId ? `aq-query-${result.resultId}` : undefined}
>
<div className="aq-compound-title">
<strong>{result.title || "综合分析"}</strong>
<span>{sections.length}项分析</span>
</div>
<p className={mobile ? "aq-mobile-compound-summary" : undefined} id={result.resultId ? `aq-answer-${result.resultId}` : undefined}>
{result.summary}
</p>
</header>
{props.correctionMessage && (
<div className="aq-correction" role="status">
<strong>此回答已撤回</strong>
<p>{props.correctionMessage}</p>
{props.correctionResultId && (
<button
onClick={() => props.showCorrection(props.correctionResultId!)}
>
查看纠正后的结果 <ArrowUpRight size={13} />
</button>
)}
</div>
)}
{result.correction && (
<p className="aq-compound-notice" role="status">
已纠正上一条回答:{result.correction.message}
</p>
)}
{result.outcome === "partial" && (
<p className="aq-compound-notice">
已返回可查结果,其余问题在下方单独说明。
</p>
)}
{!!result.unanswered?.length && (
<div className="aq-compound-unanswered">
<strong>尚未回答</strong>
{result.unanswered.map((item) => (
<p key={item.question}>
<span>{item.question}</span>:{item.reason}
</p>
))}
</div>
)}
{result.warning && (
<p className="aq-compound-notice" role="status">
{result.warning}
</p>
)}
{result.gap && (
<p className="aq-compound-notice">
{result.gap.recorded
? "未满足的需求已记录。"
: "未满足的需求暂未保存。"}
</p>
)}
{!stacked && (
<div
className="aq-section-overview"
role="tablist"
aria-label="选择分析项目"
aria-orientation="vertical"
>
{sections.map((section, index) => {
const chosen = section.id === selected.id;
return (
<button
key={section.id}
id={tabId(section.id)}
role="tab"
aria-selected={chosen}
aria-controls={panelId}
tabIndex={chosen ? 0 : -1}
disabled={props.disabled}
onClick={() => select(section)}
onKeyDown={(event) => {
const direction =
event.key === "ArrowDown" || event.key === "ArrowRight"
? 1
: event.key === "ArrowUp" || event.key === "ArrowLeft"
? -1
: 0;
if (!direction && event.key !== "Home" && event.key !== "End")
return;
event.preventDefault();
const nextIndex =
event.key === "Home"
? 0
: event.key === "End"
? sections.length - 1
: (index + direction + sections.length) %
sections.length;
select(sections[nextIndex]);
document
.getElementById(tabId(sections[nextIndex].id))
?.focus();
}}
>
<span className="aq-section-number">{index + 1}</span>
<span className="aq-section-preview">
<strong>{section.title}</strong>
<span>
{section.result ? (mobile ? mobileResultSummary(section.result) : section.result.summary) : section.error || "本项暂无结果"}
</span>
</span>
{chosen ? <Check size={14} /> : <ArrowUpRight size={14} />}
</button>
);
})}
</div>
)}
<div className="aq-compound-toolbar">
<span>
{stacked ? (
`共 ${sections.length} 项分析 · 点击任一项继续追问`
) : (
<>
当前项目 {selectedIndex + 1}/{sections.length} ·{" "}
<strong>{selected.title}</strong>
</>
)}
</span>
<button
onClick={() => {
void navigator.clipboard
.writeText(
copyResultText(result, props.subject, props.correctionMessage),
)
.then(() => setNotice("已复制全部分析项目、范围和口径。"))
.catch(() => setNotice("复制失败,请稍后重试。"));
}}
>
<Copy size={13} />
复制整份回答
</button>
</div>
{notice && (
<p className="aq-compound-notice" role="status">
{notice}
</p>
)}
{stacked ? (
<div className="aq-section-stack">
{sections.map((section, index) => (
<section
key={section.id}
className={`aq-stacked-section${!!section.result?.resultId && section.result.resultId === activeResultId ? " is-active" : ""}`}
aria-label={section.title}
data-section-id={section.id}
onClickCapture={() => {
if (
section.result &&
!props.disabled &&
!correctionFor?.(section.result.resultId || "")
)
activate(section.result);
}}
>
<header className="aq-stacked-heading">
<span className="aq-section-number">{index + 1}</span>
<strong>{section.title}</strong>
{!!section.result?.resultId &&
section.result.resultId === activeResultId && (
<span className="aq-stacked-active">当前追问范围</span>
)}
</header>
<div className="aq-section-panel">{renderSection(section)}</div>
</section>
))}
</div>
) : (
<div
className="aq-section-panel"
role="tabpanel"
id={panelId}
aria-labelledby={tabId(selected.id)}
>
{renderSection(selected)}
</div>
)}
</section>
);
}
-128
View File
@@ -1,128 +0,0 @@
import { useEffect, useState } from "react";
import { fetchAuthenticated } from "../../../auth/api-client";
import {
GAP_REASONS,
GAP_STATUSES,
type DemandGap,
type GapStatus,
} from "../../../shared/assets/assistant";
export function DemandGaps({ close }: { close: () => void }) {
const [items, setItems] = useState<DemandGap[]>([]),
[all, setAll] = useState(false),
[canManage, setCanManage] = useState(false);
const [error, setError] = useState(""),
[loading, setLoading] = useState(true),
[revision, setRevision] = useState(0);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError("");
void fetchAuthenticated(
`/api/asset-assistant/gaps?scope=${all ? "all" : "mine"}`,
{ signal: controller.signal },
)
.then(async (r) => {
const data = await r.json();
if (!r.ok) throw new Error(data.error);
return data;
})
.then((data) => {
setItems(data.items);
setCanManage(data.canManage);
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message || "读取失败。");
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [all, revision]);
const update = async (id: number, status: GapStatus) => {
setError("");
try {
const r = await fetchAuthenticated(`/api/asset-assistant/gaps/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
if (!r.ok) throw new Error("状态保存失败。");
setRevision((x) => x + 1);
} catch {
setError("状态保存失败,请重试。");
}
};
return (
<section className="aq-gap-panel" aria-label="待完善需求">
<div className="aq-card-top">
<div>
<h3>待完善需求</h3>
<p>保留90天 · 记录未完成的问题与反馈,用于完善分析能力</p>
</div>
<button onClick={close}>返回问数</button>
</div>
<div className="aq-result-toolbar">
{canManage && (
<label>
<input
type="checkbox"
checked={all}
onChange={(e) => setAll(e.target.checked)}
/>{" "}
查看全部用户需求
</label>
)}
<button onClick={() => setRevision((x) => x + 1)} disabled={loading}>
刷新
</button>
</div>
{error && (
<p role="alert" className="aq-error">
{error}
</p>
)}
{loading ? (
<p role="status">正在读取需求记录…</p>
) : (
<>
<p>
{items.length}项需求 · 累计
{items.reduce((n, v) => n + v.occurrences, 0)}次(最近200项)
</p>
{items.length === 0 && !error && (
<p className="aq-no-data">暂无未满足需求</p>
)}
{items.map((item) => (
<article key={item.id} className="aq-gap-item">
<strong>{item.demand}</strong>
<p>
{GAP_REASONS[item.reason]} · {item.occurrences}次 · 最近{" "}
{new Date(item.lastSeen).toLocaleString("zh-CN")}
</p>
{canManage ? (
<label>
处理状态{" "}
<select
value={item.status}
onChange={(e) =>
void update(item.id, e.target.value as GapStatus)
}
>
{Object.entries(GAP_STATUSES).map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</select>
</label>
) : (
<span>{GAP_STATUSES[item.status]}</span>
)}
</article>
))}
</>
)}
</section>
);
}
@@ -1,51 +0,0 @@
import { useEffect, useRef } from "react";
import * as echarts from "echarts/core";
import { BarChart, LineChart, PieChart } from "echarts/charts";
import {
GridComponent,
TooltipComponent,
LegendComponent,
AriaComponent,
DataZoomComponent,
} from "echarts/components";
import { SVGRenderer } from "echarts/renderers";
import type { EChartsCoreOption } from "echarts/core";
echarts.use([
BarChart,
LineChart,
PieChart,
GridComponent,
TooltipComponent,
LegendComponent,
AriaComponent,
DataZoomComponent,
SVGRenderer,
]);
export default function Chart({
option,
label,
}: {
option: EChartsCoreOption;
label: string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const chart = echarts.init(ref.current, undefined, { renderer: "svg" });
chart.setOption({ ...option, aria: { enabled: true } });
const observer = new ResizeObserver(() => chart.resize());
observer.observe(ref.current);
return () => {
observer.disconnect();
chart.dispose();
};
}, [option]);
return (
<div
ref={ref}
role="img"
aria-label={label}
className="h-64 min-w-0 w-full"
/>
);
}
@@ -1,712 +0,0 @@
import { memo, useState } from "react";
import {
ArrowDownToLine,
ArrowUpRight,
ChevronLeft,
ChevronRight,
Copy,
} from "lucide-react";
import {
ResponsiveContainer,
BarChart,
Bar,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
Legend,
PieChart,
Pie,
Cell,
LineChart,
Line,
} from "recharts";
import type {
AssistantResult,
PresentationBlock,
PresentationInteraction,
} from "../../../shared/assets/assistant";
import { fetchAuthenticated } from "../../../auth/api-client";
import type { ResultViewProps } from "./ResultView";
import {
compactAnalysisPath,
copyResultText,
displayTime,
queryPath,
resultScope,
} from "./result-presentation";
import {
presentationAction,
presentationData,
presentationFields,
presentationMetrics,
presentationSeries,
presentationValue,
type PresentationDatum,
} from "./generative-presentation";
import { useMobileLayout } from '../useMobileLayout';
import { mobileBlockGroups, mobileResultSummary } from './mobile-presentation';
import { ResultActions } from './ResultActions';
const COLORS = [
"#4e806a",
"#527da5",
"#b18439",
"#8870a4",
"#39948e",
"#ae6970",
];
const datumName = (row: PresentationDatum) =>
String(row.name ?? row.date ?? row.label ?? row.plate ?? "");
type DataBlockProps = {
block: PresentationBlock;
result: AssistantResult;
interaction?: PresentationInteraction;
operate: ResultViewProps["operate"];
disabled: boolean;
};
function DataTable({
block,
result,
interaction,
operate,
disabled,
}: DataBlockProps) {
const mobile = useMobileLayout();
const [page, setPage] = useState(0);
const data = presentationData(result, block.dataset);
const columns = presentationFields(block);
const currentPage = Math.min(
page,
Math.max(0, Math.ceil(data.length / 8) - 1),
);
return (
<>
{mobile ? <div className="aq-mobile-data-list" aria-label={block.title || '分析数据'}>{data.slice(currentPage * 8, currentPage * 8 + 8).map((row, index) => {
const action = presentationAction(interaction, row);
const vehicle = block.dataset === 'rows' && columns.some(column => column.field === 'plate');
const primary = vehicle ? columns.filter(column => ['plate', 'status', 'event', 'vehicleClass', 'type', 'city'].includes(column.field)) : columns.slice(0, 3);
const extra = columns.filter(column => !primary.includes(column));
return <article className="aq-mobile-data-row" key={String(row.id ?? row.name ?? row.date ?? row.label ?? index)}>
{vehicle ? <><div className="aq-mobile-record-heading"><strong>{presentationValue(row.plate, 'plate')}</strong><span>{primary.filter(column => ['status', 'event'].includes(column.field)).map(column => presentationValue(row[column.field], column.field)).join(' · ')}</span></div><p>{primary.filter(column => !['plate', 'status', 'event'].includes(column.field)).map(column => presentationValue(row[column.field], column.field)).join(' · ')}</p></> : <div className="aq-mobile-data-values">{primary.map(column => <div key={column.field}><span>{column.label}</span><strong>{presentationValue(row[column.field], column.field)}</strong></div>)}</div>}
{(extra.length > 0 || action) && <div className="aq-mobile-data-footer">{extra.length > 0 && <details><summary>更多字段</summary><dl>{extra.map(column => <div key={column.field}><dt>{column.label}</dt><dd>{presentationValue(row[column.field], column.field)}</dd></div>)}</dl></details>}{action && <button disabled={disabled} aria-label={`${datumName(row)} · ${interaction!.label}`} onClick={() => operate(action, `${datumName(row)} · ${interaction!.label}`)}>继续分析<ArrowUpRight size={15} /></button>}</div>}
</article>;
})}</div> : <div className="aq-table-scroll">
<table>
<caption className="aq-sr-only">{block.title || "分析数据"}</caption>
<thead>
<tr>
{columns.map((column) => (
<th key={column.field} scope="col">
{column.label}
</th>
))}
{interaction && <th scope="col">继续分析</th>}
</tr>
</thead>
<tbody>
{data
.slice(currentPage * 8, currentPage * 8 + 8)
.map((row, index) => {
const action = presentationAction(interaction, row);
return (
<tr
key={String(
row.id ?? row.name ?? row.date ?? row.label ?? index,
)}
>
{columns.map((column) => (
<td key={column.field}>
{presentationValue(row[column.field], column.field)}
</td>
))}
{interaction && (
<td>
{action ? (
<button
disabled={disabled}
onClick={() =>
operate(
action,
`${datumName(row)} · ${interaction.label}`,
)
}
>
{interaction.label}
<ArrowUpRight size={12} />
</button>
) : (
"—"
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>}
{data.length > 8 && (
<div className="aq-generated-pagination">
<span>
第 {currentPage * 8 + 1}–
{Math.min(data.length, currentPage * 8 + 8)} 条,共 {data.length} 条
</span>
<button
aria-label="上一页"
disabled={currentPage === 0}
onClick={() => setPage(currentPage - 1)}
>
<ChevronLeft size={15} />
</button>
<button
aria-label="下一页"
disabled={(currentPage + 1) * 8 >= data.length}
onClick={() => setPage(currentPage + 1)}
>
<ChevronRight size={15} />
</button>
</div>
)}
</>
);
}
function DataChart(props: DataBlockProps) {
const mobile = useMobileLayout();
const { block, result, interaction, operate, disabled } = props;
const [expanded, setExpanded] = useState(false);
const allData = presentationData(result, block.dataset);
const data =
expanded || block.chart === "line" || block.chart === "donut"
? allData
: allData.slice(0, 5);
const series = presentationSeries(block);
const labelField =
block.dataset === "daily"
? "date"
: block.dataset === "comparison"
? "label"
: "name";
const explore = (datum: PresentationDatum) => {
const action = presentationAction(interaction, datum);
if (action && !disabled)
operate(action, `${datumName(datum)} · ${interaction!.label}`);
};
const clickChart = (state: {
activeTooltipIndex?: string | number | null;
}) => {
const index = Number(state.activeTooltipIndex);
if (state.activeTooltipIndex != null && data[index]) explore(data[index]);
};
if (!series.length) return <DataTable {...props} />;
const common = { data, margin: { top: 8, right: 16, bottom: 0, left: 0 } };
if (mobile && (block.chart === 'bar' || (block.chart === 'donut' && block.dataset === 'groups')) && series.length === 1 && allData.every(row => typeof row[series[0].field] === 'number' && Number(row[series[0].field]) >= 0)) {
const field = series[0].field;
const max = Math.max(1, ...allData.map(row => typeof row[field] === 'number' ? Math.abs(row[field] as number) : 0));
return <div className="aq-mobile-ranking" aria-label={block.title || '分组图表'}>{(expanded ? allData : allData.slice(0, 5)).map((row, index) => {
const action = presentationAction(interaction, row);
const content = <><span className="aq-mobile-ranking-line"><span>{datumName(row)}</span><strong>{presentationValue(row[field], field)}</strong>{action && <ArrowUpRight size={15} />}</span><span className="aq-mobile-ranking-track"><span style={{ width: `${Math.abs(Number(row[field]) || 0) / max * 100}%` }} /></span></>;
return action ? <button key={index} disabled={disabled} aria-label={`${datumName(row)} · ${interaction!.label}`} onClick={() => explore(row)}>{content}</button> : <div className="aq-mobile-ranking-item" key={index}>{content}</div>;
})}{allData.length > 5 && <button className="aq-generated-expand" onClick={() => setExpanded(!expanded)}>{expanded ? '收起分组' : `查看全部 ${allData.length} 项`}</button>}</div>;
}
return (
<>
<div
className="aq-generated-chart"
aria-label={`${block.title || "数据图表"},${allData.length}项数据`}
>
<ResponsiveContainer width="100%" height="100%">
{block.chart === "donut" ? (
<PieChart>
<Pie
data={data}
dataKey={series[0].field}
nameKey={labelField}
innerRadius="48%"
outerRadius="76%"
paddingAngle={2}
isAnimationActive={false}
onClick={(entry) => explore(entry.payload as PresentationDatum)}
>
{data.map((_, index) => (
<Cell key={index} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
formatter={(value) => presentationValue(value, series[0].field)}
/>
<Legend wrapperStyle={{ maxHeight: 54, overflowY: "auto" }} />
</PieChart>
) : block.chart === "line" ? (
<LineChart {...common} onClick={clickChart}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey={labelField}
tick={{ fontSize: 11 }}
minTickGap={25}
/>
<YAxis width={42} tick={{ fontSize: 11 }} allowDecimals={series.some(item => ["rate", "share"].includes(item.field))} />
<Tooltip />
<Legend />
{series.map((item, index) => (
<Line
key={item.field}
type="monotone"
dataKey={item.field}
name={item.label}
stroke={COLORS[index % COLORS.length]}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
isAnimationActive={false}
/>
))}
</LineChart>
) : (
<BarChart {...common} onClick={clickChart}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey={labelField}
tick={{ fontSize: 11 }}
interval={0}
/>
<YAxis width={42} tick={{ fontSize: 11 }} allowDecimals={series.some(item => ["rate", "share"].includes(item.field))} />
<Tooltip />
<Legend />
{series.map((item, index) => (
<Bar
key={item.field}
dataKey={item.field}
name={item.label}
fill={COLORS[index % COLORS.length]}
radius={[3, 3, 0, 0]}
maxBarSize={42}
isAnimationActive={false}
/>
))}
</BarChart>
)}
</ResponsiveContainer>
</div>
{interaction && (
<div
className="aq-generated-drill-targets"
aria-label={interaction.label}
>
{data.map((row, index) => (
<button
key={index}
disabled={disabled || !presentationAction(interaction, row)}
onClick={() => explore(row)}
>
{datumName(row)}
<ArrowUpRight size={12} />
</button>
))}
</div>
)}
{allData.length > 5 &&
block.chart !== "line" &&
block.chart !== "donut" && (
<button
className="aq-generated-expand"
onClick={() => setExpanded(!expanded)}
>
{expanded
? "收起分组"
: `当前展示前5项 · 查看全部${allData.length}项`}
</button>
)}
{!mobile && <details className="aq-generated-data">
<summary>查看图表数据</summary>
<DataTable
{...props}
block={{
...block,
columns: block.columns || [
{
field: labelField,
label: block.dataset === "daily" ? "日期" : "项目",
},
...series,
],
}}
/>
</details>}
</>
);
}
function Block({
block,
result,
operate,
disabled,
}: Omit<DataBlockProps, "interaction">) {
const mobile = useMobileLayout();
const [expandedMetrics, setExpandedMetrics] = useState(false);
const metrics = presentationMetrics(result, block.metrics);
const interaction = result.presentation?.interactions.find(
(item) => item.id === block.interactionId,
);
const data = presentationData(result, block.dataset);
return (
<section
className={`aq-generated-block aq-generated-block-${block.kind}`}
data-block-id={block.id}
>
{block.title && <h4>{block.title}</h4>}
{block.kind === "summary" ? (
<p
className="aq-answer"
id={
result.resultId &&
result.presentation?.blocks.find((item) => item.kind === "summary")
?.id === block.id
? `aq-answer-${result.resultId}`
: undefined
}
>
{block.text || result.summary}
</p>
) : block.kind === "narrative" ? (
<p className="aq-generated-narrative">{block.text}</p>
) : block.kind === "metrics" ? (
<><div className="aq-generated-metrics">
{(mobile && !expandedMetrics ? metrics.slice(0, 4) : metrics).map((metric) => (
<div key={metric.label}>
<span>{metric.label}</span>
<strong>
{metric.value.toLocaleString()}
<small>{metric.unit}</small>
</strong>
</div>
))}
</div>{mobile && metrics.length > 4 && <button className="aq-generated-expand" onClick={() => setExpandedMetrics(!expandedMetrics)}>{expandedMetrics ? '收起指标' : `其余 ${metrics.length - 4} 项指标`}</button>}</>
) : (
<>
{interaction && !mobile && (
<p className="aq-generated-interaction">
点击{block.dataset === "daily" ? "日期" : "数据项"} →{" "}
{interaction.label}
</p>
)}
{data.length ? (
block.kind === "chart" ? (
<DataChart
block={block}
result={result}
interaction={interaction}
operate={operate}
disabled={disabled}
/>
) : (
<DataTable
block={block}
result={result}
interaction={interaction}
operate={operate}
disabled={disabled}
/>
)
) : (
<p className="aq-generated-empty">当前范围暂无匹配数据。</p>
)}
</>
)}
</section>
);
}
function MobileDataBlock({ block, table, ...props }: Omit<DataBlockProps, 'interaction'> & { table?: PresentationBlock }) {
const [view, setView] = useState<'chart' | 'table'>('chart');
const tableBlock: PresentationBlock = table || { ...block, kind: 'table', columns: block.columns || [{ field: block.dataset === 'daily' ? 'date' : block.dataset === 'comparison' ? 'label' : 'name', label: block.dataset === 'daily' ? '日期' : '分组' }, ...presentationSeries(block)] };
return <section className="aq-mobile-visual"><div className="aq-mobile-visual-header"><h4>{block.title || '数据分析'}</h4><div aria-label="图表与明细"><button aria-pressed={view === 'chart'} onClick={() => setView('chart')}>图表</button><button aria-pressed={view === 'table'} onClick={() => setView('table')}>明细</button></div></div><Block {...props} block={{ ...(view === 'chart' ? block : tableBlock), title: undefined }} /></section>;
}
function GenerativeResultViewContent({
result,
operate,
restore,
active,
activate,
subject,
disabled,
correctionMessage,
correctionResultId,
showCorrection,
}: ResultViewProps) {
const mobile = useMobileLayout();
const [noticeByResult, setNoticeByResult] = useState<{
key: string;
text: string;
}>();
const [saving, setSaving] = useState(false);
const [flaggedResult, setFlaggedResult] = useState<string>();
const resultKey = result.resultId || result.fetchedAt;
const notice = noticeByResult?.key === resultKey ? noticeByResult.text : "";
const flagged = flaggedResult === resultKey;
const setNotice = (text: string) =>
setNoticeByResult({ key: resultKey, text });
const scope = resultScope(result, subject);
const path = queryPath(result.plan);
const breadcrumbs = compactAnalysisPath(result.breadcrumbs);
const currentLabel = result.breadcrumbs?.find(
(item) => item.resultId === result.resultId,
)?.label;
const parent = breadcrumbs
.filter(
(item) =>
item.resultId !== result.resultId && item.label !== currentLabel,
)
.at(-1);
const presentation = result.presentation!;
const followups = presentation.interactions.filter(
(item) => item.kind === "followup",
);
const save = async (kind: "feedback" | "export") => {
setSaving(true);
setNotice("");
try {
const response = await fetchAuthenticated(
`/api/asset-assistant/${kind}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: result.sessionId,
resultId: result.resultId,
subject,
}),
},
);
if (!response.ok) throw new Error();
if (kind === "feedback") {
setFlaggedResult(resultKey);
setNotice("已记录,我们会据此完善问答能力。");
} else {
const url = URL.createObjectURL(await response.blob());
const link = document.createElement("a");
link.href = url;
link.download = "资产分析明细.csv";
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
setNotice("已按当前范围导出明细。");
}
} catch {
setNotice(
kind === "feedback"
? "需求未保存,请稍后重试。"
: "导出失败,请重新查询后重试。",
);
} finally {
setSaving(false);
}
};
return (
<div className="aq-result aq-generative-result">
<header
className="aq-query-heading"
id={result.resultId ? `aq-query-${result.resultId}` : undefined}
>
<div className="aq-current-path" aria-label="当前查询范围">
{path.map((item, index) => (
<span
key={index}
className={
index === path.length - 1 ? "aq-path-current" : undefined
}
>
{item}
</span>
))}
</div>
<div className="aq-query-navigation">
<span>
{scope.company}
{result.plan.kind === "flow" ? ` · ${scope.period}` : ""}
</span>
{parent && (
<button
disabled={disabled}
onClick={() => restore(parent.resultId)}
>
<ChevronLeft size={13} />
返回上层
</button>
)}
{breadcrumbs.length > 1 && (
<details className="aq-path-history">
<summary>分析路径</summary>
<nav>
{breadcrumbs.map((item) => (
<button
key={item.resultId}
disabled={disabled || item.resultId === result.resultId}
onClick={() => restore(item.resultId)}
>
{item.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 && (
<p className="aq-generated-notice">
已纠正上一条回答:{result.correction.message}
</p>
)}
{!mobile && presentation.title && (
<h3 className="aq-generated-title">{presentation.title}</h3>
)}
{result.outcome === "empty" && (
<p className="aq-generated-notice">
当前条件下无匹配数据,可调整范围后继续查询。
</p>
)}
{result.outcome === "partial" && (
<p className="aq-generated-notice">
已返回可查结果,其余问题单独说明。
</p>
)}
{result.outcome === "unsupported" && (
<p className="aq-generated-notice">本次需求暂不能完整生成。</p>
)}
{mobile ? <>
<p className="aq-answer aq-mobile-lead" id={result.resultId ? `aq-answer-${result.resultId}` : undefined}>{mobileResultSummary(result)}</p>
{mobileBlockGroups(presentation.blocks).filter(({ block }) => block.kind !== 'summary' && block.kind !== 'narrative').map(({ block, table }) => block.kind === 'chart' ? <MobileDataBlock key={block.id} block={block} table={table} result={result} operate={operate} disabled={disabled || !!correctionMessage} /> : <Block key={block.id} block={block} result={result} operate={operate} disabled={disabled || !!correctionMessage} />)}
<details className="aq-secondary-details"><summary>分析说明</summary><p>{result.summary}</p>{presentation.blocks.filter(block => (block.kind === 'summary' || block.kind === 'narrative') && block.text && block.text !== result.summary).map(block => <p key={block.id}>{block.text}</p>)}</details>
</> : presentation.blocks.map((block) => <Block key={block.id} block={block} result={result} operate={operate} disabled={disabled || !!correctionMessage} />)}
{!!result.unanswered?.length && (
<div className="aq-compound-unanswered">
<strong>尚未回答</strong>
{result.unanswered.map((item) => (
<p key={item.question}>
{item.question}:{item.reason}
</p>
))}
</div>
)}
{result.warning && (
<p className="aq-generated-notice" role="status">
{result.warning}
</p>
)}
{result.presentationWarning && (
<p className="aq-generated-notice" role="status">
{result.presentationWarning}
</p>
)}
{result.gap && (
<p className="aq-generated-notice">
{result.gap.recorded
? "未满足的需求已记录。"
: "未满足的需求暂未保存。"}
</p>
)}
<footer className="aq-result-footer">
<details className="aq-result-definitions">
<summary>统计口径、权限与数据时间</summary>
{!presentation.blocks.some((block) => block.kind === "summary") && (
<p>{result.summary}</p>
)}
<p>
公司:{scope.company} · 权限:{scope.permission}
</p>
<p>生效条件:{scope.filters.join(" · ") || "无额外筛选"}</p>
<p>
分组:{scope.grouping} · 期间:{scope.period}
</p>
<p>
源数据更新时间:{displayTime(scope.sourceUpdatedAt)} · 查询时间:
{displayTime(result.fetchedAt)}
</p>
{scope.definitions.map((item) => (
<p key={item}>{item}</p>
))}
{result.contextNote && <p>{result.contextNote}</p>}
<p>来源:{result.source}</p>
</details>
<ResultActions primary={<button
disabled={disabled || active || !!correctionMessage}
onClick={activate}
>
{mobile ? (active ? "当前追问范围" : "继续追问") : (active ? "正在基于此结果追问" : "基于此结果追问")}
</button>}>
<button
disabled={disabled || saving}
onClick={() => void save("export")}
>
<ArrowDownToLine size={14} />
导出明细
</button>
<button
disabled={saving || flagged}
onClick={() => void save("feedback")}
>
{flagged ? "已记录" : "未解决我的问题"}
</button>
<button
onClick={() => {
void navigator.clipboard
.writeText(copyResultText(result, subject, correctionMessage))
.then(() => setNotice("已复制完整结果与统计口径。"))
.catch(() => setNotice("复制失败,请手动选择文字。"));
}}
>
<Copy size={14} />
复制完整结果
</button>
</ResultActions>
{notice && (
<p className="aq-notice" role="status">
{notice}
</p>
)}
</footer>
{!!followups.length && (
<div className="aq-followups">
<span>继续分析</span>
{followups.map((item) => (
<button
key={item.id}
disabled={disabled || !!correctionMessage}
onClick={() =>
operate({ type: "explore", interactionId: item.id }, item.label)
}
>
{item.label}
<ArrowUpRight size={13} />
</button>
))}
</div>
)}
</div>
);
}
export const GenerativeResultView = memo(
GenerativeResultViewContent,
(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,
);
@@ -1,8 +0,0 @@
import type { ReactNode } from 'react';
import { MoreHorizontal } from 'lucide-react';
import { useMobileLayout } from '../useMobileLayout';
export function ResultActions({ children, primary }: { children: ReactNode; primary?: ReactNode }) {
const mobile = useMobileLayout();
return mobile ? <div className="aq-mobile-actions">{primary}<details className="aq-more-actions"><summary><MoreHorizontal size={18} />更多</summary><div className="aq-answer-actions">{children}</div></details></div> : <div className="aq-answer-actions">{primary}{children}</div>;
}
-959
View File
@@ -1,959 +0,0 @@
import { memo, useMemo, useState } from "react";
import {
ArrowDownToLine,
ArrowUpRight,
ChevronLeft,
ChevronRight,
Copy,
} from "lucide-react";
import {
DIMENSIONS,
GROUP_LABELS,
type AssistantAction,
type AssistantResult,
type AssistantRow,
type Dimension,
type QueryPlan,
} from "../../../shared/assets/assistant";
import { fetchAuthenticated } from "../../../auth/api-client";
import { Chart } from "./Chart";
import {
copyResultText,
displayTime,
resultScope,
resultDisplay,
queryPath,
compactAnalysisPath,
} from "./result-presentation";
import { useMobileLayout } from '../useMobileLayout';
import { mobileResultSummary } from './mobile-presentation';
import { ResultActions } from './ResultActions';
function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) {
const mobile = useMobileLayout();
const [page, setPage] = useState(0),
[search, setSearch] = useState("");
const filtered = rows.filter((r) =>
[r.plate, r.city, r.type, r.model, r.department, r.abnormalType].some((v) =>
v?.includes(search),
),
);
const pages = Math.max(1, Math.ceil(filtered.length / 8)),
current = Math.min(page, pages - 1);
return (
<>
<div className="aq-record-toolbar">
<label>
<span className="sr-only">筛选明细</span>
<input
value={search}
placeholder="搜索当前明细…"
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
/>
</label>
<span>{filtered.length}条</span>
</div>
{mobile ? <div className="aq-mobile-data-list" aria-label="车辆明细">{filtered.slice(current * 8, current * 8 + 8).map(r => <article className="aq-mobile-data-row" key={r.id}><div className="aq-mobile-record-heading"><strong>{r.plate || '未登记车牌'}</strong><span>{flow ? r.event : r.status}</span></div><p>{r.type} · {r.city || '未标注城市'}</p><details><summary>更多资料</summary><dl>{[['型号', r.model], ['部门', r.department], ...(flow ? [['日期', r.date]] : [['库存子集', r.abnormal ? '异动' : r.pending ? '待交车' : '—'], ['异动类型', r.abnormalType], ['预计完成时间', r.targetCompletionTime]])].map(([label,value]) => <div key={label}><dt>{label}</dt><dd>{value || '—'}</dd></div>)}</dl></details></article>)}</div> : <div className="aq-table-scroll">
<table>
<thead>
<tr>
{[
"车牌",
"车型 / 型号",
"城市",
flow ? "业务类型" : "运营状态",
...(flow ? ["日期"] : ["库存子集", "异动类型", "预计完成时间"]),
].map((h) => (
<th key={h}>{h}</th>
))}
</tr>
</thead>
<tbody>
{filtered.slice(current * 8, current * 8 + 8).map((r) => (
<tr key={r.id}>
<td className="aq-plate">{r.plate || "未登记车牌"}</td>
<td>
{r.type}
<small>{r.model}</small>
</td>
<td>{r.city}</td>
<td>{flow ? r.event : r.status}</td>
{flow ? (
<td>{r.date}</td>
) : (
<>
<td>{r.abnormal ? "异动" : r.pending ? "待交车" : "—"}</td>
<td>{r.abnormalType || "—"}</td>
<td>{r.targetCompletionTime || "—"}</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>}
{!filtered.length && <p className="aq-no-data">没有符合条件的明细</p>}
<div className="aq-pagination">
<span>
第{current + 1}/{pages}页
</span>
<button
aria-label="上一页"
disabled={!current}
onClick={() => setPage(current - 1)}
>
<ChevronLeft size={16} />
</button>
<button
aria-label="下一页"
disabled={current >= pages - 1}
onClick={() => setPage(current + 1)}
>
<ChevronRight size={16} />
</button>
</div>
{search && (
<p className="aq-notice">
此搜索仅筛选当前明细表;继续追问仍使用上方分析条件。
</p>
)}
</>
);
}
export type ResultViewProps = {
result: AssistantResult;
ask: (text: string) => void;
operate: (action: AssistantAction, label: string) => void;
restore: (resultId: string) => void;
active: boolean;
activate: () => void;
subject: string | null;
disabled: boolean;
correctionMessage?: string;
correctionResultId?: string;
showCorrection: (resultId: string) => void;
display?: "metrics" | "chart" | "table" | "list";
};
function ResultViewContent({
result,
ask,
operate,
restore,
active,
activate,
subject,
disabled,
correctionMessage,
correctionResultId,
showCorrection,
display: preferredDisplay,
}: ResultViewProps) {
const mobile = useMobileLayout();
const [expandedGroupResult, setExpandedGroupResult] = useState<string | null>(
null,
),
[notice, setNotice] = useState(""),
[flagged, setFlagged] = useState(false),
[saving, setSaving] = useState(false);
const { plan } = result,
flow = plan.kind === "flow";
const run = (action: AssistantAction, label: string) =>
operate(action, label);
const flag = async () => {
setSaving(true);
setNotice("");
try {
const r = await fetchAuthenticated("/api/asset-assistant/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: result.sessionId,
resultId: result.resultId,
subject,
}),
});
if (!r.ok) throw new Error();
setFlagged(true);
setNotice("已记录,我们会据此完善问答能力。");
} catch {
setNotice("需求未保存,请稍后重试。");
} finally {
setSaving(false);
}
};
const download = async () => {
setSaving(true);
setNotice("");
try {
const r = await fetchAuthenticated("/api/asset-assistant/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: result.sessionId,
resultId: result.resultId,
subject,
}),
});
if (!r.ok) throw new Error();
const url = URL.createObjectURL(await r.blob());
const a = document.createElement("a");
a.href = url;
a.download = "资产分析明细.csv";
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
setNotice("已按当前权限重新查询并导出明细,数量可能随业务更新变化。");
} catch {
setNotice("导出失败,请重新查询后重试。");
} finally {
setSaving(false);
}
};
const showAll = expandedGroupResult === (result.resultId || result.fetchedAt);
const suggestions = [...new Set(result.suggestions || [])];
const currentPath = queryPath(plan);
const analysisPath = compactAnalysisPath(result.breadcrumbs);
const currentPathLabel = result.breadcrumbs?.find(
(crumb) => crumb.resultId === result.resultId,
)?.label;
const ancestors = analysisPath.filter(
(crumb) =>
crumb.resultId !== result.resultId && crumb.label !== currentPathLabel,
);
const parent = ancestors.at(-1);
const scope = resultScope(result, subject);
const briefing = plan.analysis === "briefing";
const display = preferredDisplay || resultDisplay(plan);
const viewLabels = [
!flow && plan.groupBy === "none" && !plan.secondaryGroupBy
? "指标卡"
: "图表",
"统计表",
"明细",
];
const showFourTotals =
briefing ||
(plan.kind === "assets" &&
plan.category === "all" &&
result.metrics.some((metric) => /库存/.test(metric.label)));
const metricPriority = preferredDisplay === "metrics" && plan.category === "inventory"
? ["库存车辆", "待交车占库存", "异动占库存"]
: undefined;
const orderedMetrics = metricPriority
? [...result.metrics].sort((a, b) => {
const rank = (label: string) => metricPriority.includes(label) ? metricPriority.indexOf(label) : metricPriority.length;
return rank(a.label) - rank(b.label);
})
: result.metrics;
const visibleMetrics = orderedMetrics.slice(0, showFourTotals ? 4 : 3);
const extraMetrics = orderedMetrics.slice(visibleMetrics.length);
const outcomeLabels = {
partial: "已回答可查部分",
empty: "暂无符合条件数据",
unsupported: "暂不支持该指标",
clarification: "需要补充一个条件",
help: "使用帮助",
complete: "",
};
const largest = useMemo(
() => Math.max(1, ...result.groups.map((g) => g.count)),
[result.groups],
);
const option = useMemo(
() => ({
color: ["#668874", "#b5bdc4", "#a58b65"],
tooltip: { trigger: "axis", renderMode: "richText" },
legend: { bottom: 0 },
grid: { left: 36, right: 10, top: 12, bottom: 45 },
xAxis: {
type: "category",
data: result.daily?.map((d) => d.date.slice(5)),
},
yAxis: { type: "value", minInterval: 1 },
series: (["delivered", "returned", "replaced"] as const).map(
(key, i) => ({
name: ["交车(含替换)", "还车", "其中替换"][i],
type: "line",
data: result.daily?.map((d) => d[key]),
}),
),
animation: false,
}),
[result.daily],
);
return (
<div className={`aq-result ${active ? "aq-result-active" : ""}`}>
{plan.kind !== "clarify" && !result.conciseCount && (
<header
className="aq-query-heading"
id={result.resultId ? `aq-query-${result.resultId}` : undefined}
>
<div className="aq-current-path" aria-label="当前查询维度">
{currentPath.map((step, index) => (
<span
className={
index === currentPath.length - 1 ? "aq-path-current" : ""
}
key={`${index}-${step}`}
>
{index > 0 && index < currentPath.length - 1 && (
<ChevronRight size={12} aria-hidden="true" />
)}
{step}
</span>
))}
</div>
<div className="aq-query-navigation">
<span>
{scope.company}
{flow ? ` · ${scope.period}` : ""}
</span>
{parent && (
<button
disabled={disabled}
onClick={() => restore(parent.resultId)}
>
<ChevronLeft size={13} />
返回上层
</button>
)}
{analysisPath.length > 1 && (
<details className="aq-path-history">
<summary>完整分析路径</summary>
<nav aria-label="完整分析路径">
{analysisPath.map((crumb) => (
<button
key={crumb.resultId}
disabled={disabled || crumb.resultId === result.resultId}
onClick={() => restore(crumb.resultId)}
>
{crumb.label}
</button>
))}
</nav>
</details>
)}
</div>
</header>
)}
{correctionMessage && (
<div className="aq-correction" role="status">
<strong>此回答已撤回</strong>
<p>{correctionMessage}</p>
{correctionResultId && (
<button onClick={() => showCorrection(correctionResultId)}>
查看纠正后的结果 <ArrowUpRight size={13} />
</button>
)}
</div>
)}
{result.correction && (
<div className="aq-correction aq-correction-current" role="status">
<strong>已纠正上一条回答</strong>
<p>{result.correction.message}</p>
</div>
)}
{result.outcome && outcomeLabels[result.outcome] && (
<p className={`aq-outcome aq-outcome-${result.outcome}`}>
{outcomeLabels[result.outcome]}
</p>
)}
<p
className="aq-answer"
id={result.resultId ? `aq-answer-${result.resultId}` : undefined}
>
{mobile ? mobileResultSummary(result) : result.summary}
</p>
{!result.conciseCount && !!visibleMetrics.length && (
<div className="aq-metrics aq-primary-metrics">
{visibleMetrics.map((metric) => (
<div key={metric.label}>
<span>{metric.label}</span>
<strong>
{metric.value.toLocaleString()}
<small>{metric.unit}</small>
</strong>
</div>
))}
</div>
)}
{!result.conciseCount && !!extraMetrics.length && (
<details className="aq-extra-metrics">
<summary>查看其余 {extraMetrics.length} 项指标</summary>
<div>
{extraMetrics.map((metric) => (
<span key={metric.label}>
{metric.label}:
<strong>
{metric.value.toLocaleString()}
{metric.unit}
</strong>
</span>
))}
</div>
</details>
)}
{!!result.unanswered?.length && (
<section className="aq-unanswered" aria-label="尚未回答的部分">
<strong>尚未回答的部分</strong>
{result.unanswered.map((item) => (
<p key={item.question}>
<span>{item.question}</span>
{item.reason}
</p>
))}
</section>
)}
{result.gap && (
<p className="aq-gap-note">
{result.gap.recorded
? "这个需求已记下。"
: "可以点击下方「未解决我的问题」记录需求。"}
</p>
)}
{plan.kind !== "clarify" && !result.conciseCount && (
<>
<div className="aq-result-card">
{result.inventoryBreakdown && display !== "metrics" && (
<section className="aq-briefing-inventory">
<h4>{result.inventoryBreakdown.title}</h4>
<p>{result.inventoryBreakdown.summary}</p>
{briefing && !!result.insights?.length && (
<details className="aq-secondary-details">
<summary>分析建议 · {result.insights.length}项</summary>
<ul className="aq-insights">
{result.insights.map((text) => (
<li key={text}>{text}</li>
))}
</ul>
</details>
)}
<div className="aq-briefing-groups">
{result.inventoryBreakdown.groups.slice(0, 5).map((group) => (
<div key={group.name}>
<span>{group.name}</span>
<strong>{group.count}台</strong>
<span>
{group.share === undefined ? "" : `${group.share}%`}
</span>
</div>
))}
</div>
<button
disabled={disabled}
onClick={() =>
run(
{ type: "category", category: "inventory" },
"查看库存详细分析",
)
}
>
查看库存详细分析 <ArrowUpRight size={13} />
</button>
</section>
)}
{!briefing && (
<>
<details className="aq-advanced-controls">
<summary>维度与分析</summary>
<div className="aq-analysis-controls">
{!flow && (
<>
<label>
主维度{" "}
<select
aria-label="主维度"
disabled={disabled}
value={plan.groupBy}
onChange={(e) =>
run(
{
type: "group",
groupBy: e.target
.value as QueryPlan["groupBy"],
},
"切换统计维度",
)
}
>
{[...DIMENSIONS, "none" as const].map((d) => (
<option value={d} key={d}>
{GROUP_LABELS[d]}
</option>
))}
</select>
</label>
<label>
交叉维度{" "}
<select
aria-label="交叉维度"
disabled={disabled || plan.groupBy === "none"}
value={plan.secondaryGroupBy || ""}
onChange={(e) =>
run(
{
type: "group",
groupBy: plan.groupBy,
secondaryGroupBy: (e.target.value ||
undefined) as Dimension | undefined,
},
"交叉统计",
)
}
>
<option value="">不交叉</option>
{DIMENSIONS.filter((d) => d !== plan.groupBy).map(
(d) => (
<option key={d} value={d}>
{GROUP_LABELS[d]}
</option>
),
)}
</select>
</label>
<label>
分析方式{" "}
<select
aria-label="分析方式"
disabled={disabled}
value={plan.analysis || "distribution"}
onChange={(e) =>
run(
{
type: "analysis",
analysis: e.target.value as NonNullable<
QueryPlan["analysis"]
>,
},
"切换分析方式",
)
}
>
<option value="distribution">分布统计</option>
<option value="compare">分组对比</option>
<option value="exceptions">异常定位</option>
</select>
</label>
</>
)}
{flow && (
<button
disabled={disabled}
onClick={() =>
run({ type: "compare-period" }, "对比上一等长周期")
}
>
对比上一等长周期
</button>
)}
</div>
</details>
{!!result.insights?.length && (
<details className="aq-secondary-details">
<summary>分析发现 · {result.insights.length}项</summary>
<ul className="aq-insights">
{result.insights.map((text) => (
<li key={text}>{text}</li>
))}
</ul>
</details>
)}
{!flow && !!result.exceptions?.some((item) => item.count) && (
<details
className="aq-secondary-details"
open={plan.analysis === "exceptions"}
>
<summary>异常与待交车</summary>
<div className="aq-exceptions">
{result.exceptions?.map((e) => (
<button
key={e.label}
disabled={disabled || !e.count}
onClick={() =>
run(
e.category
? { type: "category", category: e.category }
: { type: "exception", exception: "overdue" },
`查看${e.label}`,
)
}
>
{e.label}
<strong>{e.count}台</strong>
</button>
))}
</div>
</details>
)}
{result.groupComparison && display !== "metrics" && (
<div className="aq-table-scroll">
<p className="aq-chart-caption">
当前分组对比 · 数量最多的分组为基准:
{result.groupComparison.baseline}
</p>
<table>
<thead>
<tr>
{[
"分组",
"车辆数",
"较基准差值",
"占比",
"运营率差(百分点)",
].map((h) => (
<th key={h}>{h}</th>
))}
</tr>
</thead>
<tbody>
{result.groupComparison.rows.map((r) => (
<tr key={r.name}>
<td>{r.name}</td>
<td>{r.count}</td>
<td>{r.delta}</td>
<td>{r.share}%</td>
<td>{r.rateDelta}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{result.comparison && display !== "metrics" && (
<div className="aq-table-scroll">
<p>
上一等长周期:{result.comparisonPeriod?.start} —{" "}
{result.comparisonPeriod?.end}
</p>
<table>
<thead>
<tr>
{["指标", "本期", "上期", "差值", "变化率"].map(
(h) => (
<th key={h}>{h}</th>
),
)}
</tr>
</thead>
<tbody>
{result.comparison.map((m) => (
<tr key={m.label}>
<td>{m.label}</td>
<td>{m.current}</td>
<td>{m.previous}</td>
<td>
{m.delta > 0 ? "+" : ""}
{m.delta}
</td>
<td>
{m.rate === null
? "基期为0,不计算"
: `${m.rate}%`}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="aq-result-toolbar">
<div className="aq-view-tabs" aria-label="结果展示方式">
{(["chart", "table", "list"] as const).map((v, i) => (
<button
key={v}
aria-pressed={
display === "metrics"
? viewLabels[i] === "指标卡"
: plan.view === v
}
disabled={disabled}
onClick={() =>
run({ type: "view", view: v }, `显示${viewLabels[i]}`)
}
>
{viewLabels[i]}
</button>
))}
</div>
<span>
{result.rows.length}
{flow ? "笔业务" : "台车辆"}
</span>
</div>
{display === "metrics" ? null : plan.view === "list" ? (
<Records
key={result.resultId}
rows={result.rows}
flow={flow}
/>
) : flow ? (
<>
{plan.view === "chart" && (
<div className="aq-flow-chart">
<Chart option={option} label="交退换车按日趋势" />
</div>
)}
<details
className="aq-daily-details"
open={plan.view === "table"}
>
<summary>
每日车次与下钻明细 · {result.daily?.length || 0}天
</summary>
<div className="aq-table-scroll">
<table>
<thead>
<tr>
{[
"日期",
"交车(含替换)",
"还车",
"其中替换",
"查看",
].map((h) => (
<th key={h}>{h}</th>
))}
</tr>
</thead>
<tbody>
{result.daily?.map((d) => (
<tr key={d.date}>
<td>{d.date}</td>
<td>{d.delivered}</td>
<td>{d.returned}</td>
<td>{d.replaced}</td>
<td>
<button
disabled={disabled}
onClick={() =>
run(
{ type: "day", date: d.date },
`查看${d.date}明细`,
)
}
aria-label={`查看${d.date}明细`}
>
<ArrowUpRight size={15} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</>
) : plan.view === "chart" ? (
<div className="aq-bars">
{(showAll ? result.groups : result.groups.slice(0, 5)).map(
(g) => (
<button
key={JSON.stringify(g.keys)}
className="aq-bar-row"
disabled={
disabled || !Object.keys(g.keys || {}).length
}
onClick={() =>
run(
{ type: "drill", keys: g.keys || {} },
`下钻${g.name}`,
)
}
aria-label={`下钻${g.name},${g.count}台`}
>
<span className="aq-bar-name">{g.name}</span>
<span className="aq-bar-track">
<span
className="aq-bar-fill aq-bar-lead"
style={{ width: `${(g.count / largest) * 100}%` }}
/>
</span>
<strong>{g.count}</strong>
<span className="aq-bar-share">{g.share}%</span>
<ArrowUpRight size={13} />
</button>
),
)}
{result.groups.length > 5 && (
<button
className="aq-more"
onClick={() =>
setExpandedGroupResult(
showAll
? null
: result.resultId || result.fetchedAt,
)
}
>
{showAll ? "收起" : `展开全部${result.groups.length}组`}
</button>
)}
<p className="aq-chart-caption">
点击分组继续下钻 · 筛选条件自动用于下一次追问
</p>
</div>
) : mobile ? (
<div className="aq-mobile-data-list" aria-label="分组明细">{result.groups.map(g => <article className="aq-mobile-data-row" key={JSON.stringify(g.keys)}><div className="aq-mobile-data-values"><div><span>分组</span><strong>{g.name}</strong></div><div><span>车辆数</span><strong>{g.count}</strong></div><div><span>占比</span><strong>{g.share == null ? '—' : `${g.share}%`}</strong></div></div><div className="aq-mobile-data-footer">{plan.category !== 'operating' && <details><summary>更多指标</summary><p>运营率 {g.rate}% · 异动 {g.abnormal ?? '—'} · 待交车 {g.pending ?? '—'}</p></details>}<button disabled={disabled || !Object.keys(g.keys || {}).length} aria-label={`下钻${g.name}`} onClick={() => run({ type: 'drill', keys: g.keys || {} }, `下钻${g.name}`)}>继续分析<ArrowUpRight size={15} /></button></div></article>)}</div>
) : (
<div className="aq-table-scroll">
<table>
<thead>
<tr>
{[
"分组",
"车辆数",
"占比",
...(result.plan.category === "operating" ? [] : ["运营率", "异动", "待交车"]),
"下钻",
].map((h) => (
<th key={h}>{h}</th>
))}
</tr>
</thead>
<tbody>
{result.groups.map((g) => (
<tr key={JSON.stringify(g.keys)}>
<td>{g.name}</td>
<td>{g.count}</td>
<td>{g.share}%</td>
{result.plan.category !== "operating" && <>
<td>{g.rate}%</td>
<td>{g.abnormal}</td>
<td>{g.pending}</td>
</>}
<td>
<button
disabled={
disabled || !Object.keys(g.keys || {}).length
}
onClick={() =>
run(
{ type: "drill", keys: g.keys || {} },
`下钻${g.name}`,
)
}
aria-label={`下钻${g.name}`}
>
<ArrowUpRight size={15} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!result.rows.length && (
<p className="aq-no-data">
当前条件没有匹配数据,可返回上层调整。
</p>
)}
</>
)}
</div>
</>
)}
{result.conciseCount && result.metrics[0]?.value > 0 && (
<button className="aq-more" disabled={disabled} onClick={() => run({ type: "view", view: "list" }, "查看车辆明细")}>
查看车辆明细 <ArrowUpRight size={14} aria-hidden="true" />
</button>
)}
<div className="aq-result-footer">
<details className="aq-result-definitions">
<summary>
统计口径、权限与数据时间{result.warning ? " · 有补充说明" : ""}
</summary>
{mobile && <p>{result.summary}</p>}
<p>
公司:{scope.company} · 权限:{scope.permission}
</p>
<p>生效条件:{scope.filters.join(" · ") || "无额外筛选"}</p>
<p>
分组:{scope.grouping} · 期间:{scope.period}
</p>
<p>
源数据更新时间:{displayTime(scope.sourceUpdatedAt)} · 查询时间:
{displayTime(result.fetchedAt)}
</p>
{result.contextNote && <p>{result.contextNote}</p>}
{result.warning && <p role="status">{result.warning}</p>}
{scope.definitions.map((definition) => (
<p key={definition}>{definition}</p>
))}
<p>来源:{result.source}</p>
</details>
<ResultActions primary={plan.kind !== "clarify" && (
<button
disabled={disabled || active || !!correctionMessage}
onClick={activate}
>
{mobile ? (active ? "当前追问范围" : "继续追问") : (active ? "正在基于此结果追问" : "基于此结果追问")}
</button>
)}>
{plan.kind !== "clarify" && (!result.conciseCount || result.metrics[0]?.value > 0) && (
<button
disabled={disabled || saving}
onClick={() => void download()}
aria-label="导出当前结果"
>
<ArrowDownToLine size={14} />
导出明细
</button>
)}
<button disabled={saving || flagged} onClick={() => void flag()}>
{flagged ? "已记录" : "未解决我的问题"}
</button>
<button
onClick={() => {
void navigator.clipboard
.writeText(copyResultText(result, subject, correctionMessage))
.then(() => setNotice("已复制结论、范围、口径及数据时间。"))
.catch(() => setNotice("复制失败,请手动选择文字。"));
}}
>
<Copy size={14} />
复制完整结果
</button>
</ResultActions>
{notice && (
<p className="aq-notice" role="status">
{notice}
</p>
)}
</div>
{!result.conciseCount && !!suggestions.length && (
<div className="aq-followups">
<span>{plan.kind === "clarify" ? "你可以这样继续" : "继续分析"}</span>
{suggestions.slice(0, 4).map((question) => (
<button
key={question}
disabled={disabled}
onClick={() => ask(question)}
>
{question}
<ArrowUpRight size={13} />
</button>
))}
</div>
)}
</div>
);
}
export const ResultView = memo(
ResultViewContent,
// Event handlers close over this result; restore reads the latest turns from turnsRef.
(previous, next) =>
previous.result === next.result &&
previous.active === next.active &&
previous.subject === next.subject &&
previous.disabled === next.disabled &&
previous.correctionMessage === next.correctionMessage &&
previous.correctionResultId === next.correctionResultId &&
previous.display === next.display,
);
@@ -1,112 +0,0 @@
// Adapted from Beautiful UI's MIT-licensed ThinkingState (Steps).
// Uses real server progress rather than the reference's timed demo sequence.
import { useEffect, useId, useState } from "react";
import { Check, ChevronDown, CircleAlert, LoaderCircle, Pause, Sparkles } from "lucide-react";
import { useMobileLayout } from '../useMobileLayout';
export function ThinkingState({
steps,
working,
started,
failed,
stopped,
outcome,
}: {
steps: string[];
working: boolean;
started: number;
failed?: boolean;
stopped?: boolean;
outcome?: string;
}) {
const mobile = useMobileLayout();
const traceId = useId();
const [manualExpanded, setExpanded] = useState<boolean | null>(null);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!working) return;
const update = () => setElapsed((Date.now() - started) / 1000);
update();
const timer = window.setInterval(update, 1000);
return () => window.clearInterval(timer);
}, [working, started]);
const expanded = manualExpanded ?? (working && !mobile);
const StatusIcon = working ? LoaderCircle : stopped ? Pause : failed ? CircleAlert : Sparkles;
return (
<div className={`aq-thinking${working ? " aq-thinking-working" : ""}`} data-state={working ? "working" : stopped ? "stopped" : failed ? "failed" : "complete"}>
<button
type="button"
aria-expanded={expanded}
aria-controls={traceId}
onClick={() => setExpanded(!expanded)}
>
<StatusIcon size={16} className={working ? "aq-spin" : ""} aria-hidden="true" />
<span className={`aq-thinking-title${working ? " aq-shimmer" : ""}`}>
{working
? (mobile ? steps.at(-1) || "正在分析" : "正在分析")
: stopped
? "已由用户停止"
: failed
? "查询失败"
: outcome === "unsupported"
? "暂不支持该指标"
: outcome === "empty"
? "查询完成 · 暂无匹配数据"
: outcome === "partial"
? "已完成可查部分"
: outcome === "clarification"
? "等待补充条件"
: outcome === "help"
? "已回答"
: "分析完成"}
</span>
<span className="aq-thinking-meta">
{steps.length > 0 && <span>{steps.length} 个步骤</span>}
{elapsed > 0 && <span className="aq-time">{Math.floor(elapsed)} 秒</span>}
</span>
<ChevronDown
size={14}
className={expanded ? "aq-rotated" : ""}
aria-hidden="true"
/>
</button>
<div
id={traceId}
className="aq-trace-collapse"
aria-hidden={!expanded}
style={{
gridTemplateRows: expanded ? "1fr" : "0fr",
opacity: expanded ? 1 : 0,
}}
>
<div className="aq-trace-clip">
<ol className="aq-trace">
{!steps.length && <li><span>{working ? "正在连接分析服务…" : "暂无执行步骤"}</span></li>}
{steps.map((step, index) => (
<li key={`${index}-${step}`} data-current={index === steps.length - 1}>
{working && index === steps.length - 1 ? (
<LoaderCircle
className="aq-spin"
size={14}
aria-hidden="true"
/>
) : index === steps.length - 1 && (failed || stopped) ? (
<StatusIcon size={14} aria-hidden="true" />
) : (
<Check size={14} aria-hidden="true" />
)}
<span>{step}</span>
</li>
))}
</ol>
</div>
</div>
{working && (
<span className="sr-only" role="status">
{steps.at(-1) || "正在连接分析服务"}
</span>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
-64
View File
@@ -1,64 +0,0 @@
import { fetchAuthenticated } from "../../../auth/api-client";
import type {
AssistantEvent,
AssistantRequest,
} from "../../../shared/assets/assistant";
export class AssistantRequestError extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
this.name = "AssistantRequestError";
}
}
export async function askAssetAssistant(
input: AssistantRequest,
signal: AbortSignal,
onEvent: (event: AssistantEvent) => void,
) {
const response = await fetchAuthenticated("/api/asset-assistant/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
signal,
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new AssistantRequestError(
body?.error || "查询服务暂时不可用。",
response.status,
);
}
if (!response.body) throw new Error("当前浏览器无法读取查询进度。");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "",
completed = false;
try {
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const frames = buffer.split(/\r?\n\r?\n/);
buffer = frames.pop() || "";
for (const frame of frames) {
const data = frame
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim())
.join("\n");
if (!data) continue;
const event = JSON.parse(data) as AssistantEvent;
if (event.type === "result" || event.type === "error") completed = true;
onEvent(event);
}
if (done) break;
}
if (!completed && !signal.aborted)
throw new Error("连接中断,请重新提问。");
} finally {
reader.releaseLock();
}
}
@@ -1,24 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ResultView } from "./ResultView";
import type { AssistantResult } from "../../../shared/assets/assistant";
test("count answer renders prose without report cards and only offers records for positive counts", () => {
for (const count of [0, 7]) {
const result: AssistantResult = {
plan: {kind:"assets",category:"abnormal",groupBy:"none",view:"chart",filters:{}},
conciseCount:true,outcome:"complete",summary:`当前异动车辆为 ${count} 台。`,
title:"异动车辆概览",metrics:[{label:"异动车辆",value:count,unit:"台"}],
rows:[],groups:[],source:"测试台账",fetchedAt:"2026-09-23T00:00:00Z",suggestions:["查看全部车辆"],
};
const noop = () => {};
const html = renderToStaticMarkup(createElement(ResultView,{result,ask:noop,operate:noop,restore:noop,active:true,activate:noop,subject:null,disabled:false,showCorrection:noop}));
assert.match(html,new RegExp(`当前异动车辆为 ${count} 台`));
assert.doesNotMatch(html,/aq-primary-metrics|aq-result-card|aq-followups|aq-query-heading/);
assert.equal(html.includes("查看车辆明细"),count > 0);
assert.equal(html.includes("导出明细"),count > 0);
assert.match(html,/统计口径、权限与数据时间/);
}
});
@@ -1,60 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { BUSINESS_QUESTIONS, conversationTitle } from "./conversation-presentation.js";
import { localPlan } from "../../../server/routes/asset-assistant/planner.js";
import { projectQuestion } from "../../../server/routes/asset-assistant/privacy.js";
import type { AssistantResult } from "../../../shared/assets/assistant.js";
test("business starters produce supported queries matching their promises even without a language model",()=>{
const plans=BUSINESS_QUESTIONS.map(question=>localPlan(projectQuestion(question,[])).plan);
assert.equal(plans[0].analysis,"briefing");assert.equal(plans[0].category,"all");
assert.equal(plans[1].category,"inventory");
assert.deepEqual(new Set([plans[1].groupBy,plans[1].secondaryGroupBy]),new Set(["type","region"]));
assert.equal(plans[2].kind,"flow");assert.equal(Date.parse(plans[2].end!)-Date.parse(plans[2].start!),6*86400000);
assert.equal(plans[3].category,"abnormal");assert.equal(plans[3].view,"list");
});
test("history titles describe the actual result and keep long questions readable",()=>{
const base={plan:{kind:"assets",category:"inventory",groupBy:"type",filters:{},view:"chart"}} as AssistantResult;
assert.equal(conversationTitle("a long question",base),"车型库存");
assert.equal(conversationTitle("a long question",{...base,plan:{...base.plan,category:"pending",view:"list"}}),"待交车清单");
assert.ok(conversationTitle("无法识别的很长很长很长很长很长的问题内容").length<=14);
});
test("in-place drilldown keeps its question and previous results available for returning to a parent",async()=>{
const {findConversationResult,replaceAnalysisResult}=await import("./conversation-presentation.js");
const base={resultId:"root",plan:{kind:"assets",category:"inventory",groupBy:"region",filters:{},view:"chart"}} as AssistantResult;
const drilled={...base,resultId:"east",plan:{...base.plan,groupBy:"city" as const,filters:{region:["华东"]}}};
const turn={id:1,question:"库存在哪里?",result:base};
const next=replaceAnalysisResult(turn,drilled);
assert.equal(next.id,1);assert.equal(next.question,turn.question);
assert.equal(next.result,drilled);assert.equal(findConversationResult([next],"root"),base);
const restored=replaceAnalysisResult(next,{...base,resultId:"restored"});
assert.equal(findConversationResult([restored],"east"),drilled);
assert.equal(restored.resultHistory?.length,2);
assert.equal(findConversationResult([restored],"missing"),undefined);
});
test("compound drilldown replaces only its section and can return to older child scopes", async () => {
const {findConversationResult,replaceAnalysisResult}=await import("./conversation-presentation.js");
const plan={kind:"assets",category:"inventory",groupBy:"region",view:"chart",filters:{}} as const;
const stock={resultId:"stock",plan} as AssistantResult;
const pending={resultId:"pending",plan:{...plan,category:"pending"}} as AssistantResult;
const root={...stock,resultId:"bundle",sections:[
{id:"a",title:"库存",question:"库存多少",display:"chart" as const,result:stock},
{id:"b",title:"待交车",question:"待交车多少",display:"metrics" as const,result:pending},
]};
const drill={...stock,resultId:"east",plan:{...stock.plan,groupBy:"city" as const,filters:{region:["华东"]}}};
const next=replaceAnalysisResult({id:1,result:root},drill,"stock");
assert.equal(next.result?.resultId,"bundle");
assert.equal(next.result?.sections?.[0].result,drill);
assert.equal(next.result?.sections?.[1].result,pending);
assert.equal(findConversationResult([next],"stock"),stock);
const deeper={...drill,resultId:"city",plan:{...drill.plan,view:"list" as const}};
const deep=replaceAnalysisResult(next,deeper,"east");
assert.equal(deep.result?.sections?.[0].display,"list");
assert.equal(findConversationResult([deep],"east"),drill);
const returned=replaceAnalysisResult(deep,{...stock,resultId:"restored"},"stock");
assert.equal(returned.result?.sections?.[0].result?.resultId,"restored");
assert.equal(returned.result?.sections?.[1].result,pending);
assert.equal(returned.id,1);
});
@@ -1,55 +0,0 @@
import { CATEGORY_LABELS, GROUP_LABELS, type AssistantResult } from "../../../shared/assets/assistant";
export const BUSINESS_QUESTIONS = [
"现在有多少车在运营、多少车在库存?整体运营率是多少?",
"库存主要集中在哪些车型和区域?按数量从多到少统计。",
"最近7天交车、还车分别多少?其中替换交车多少?",
"查看当前异动车辆,显示异动类型和预计完成时间。",
] as const;
export function conversationTitle(question: string, result?: AssistantResult): string {
if (result?.sections?.length) return `${result.sections.length} 项关联分析`;
if (result?.presentation?.title) return result.presentation.title.slice(0, 24);
if (!result || result.plan.kind === "clarify") {
if (/历史|上个?月月底|损失|成本/.test(question)) return "历史与成本分析";
return question.replace(/[\s,。?!;:、]/g, "").slice(0, 14) || "资产分析";
}
const p = result.plan;
if (p.analysis === "briefing") return "资产总览";
if (p.kind === "flow") return p.comparePrevious ? "交还车周期对比" : "交还车统计";
if (p.view === "list") return `${CATEGORY_LABELS[p.category].replace("车辆", "")}清单`;
const category = {all:"资产",inventory:"库存",available:"可运营库存",awaiting:"待运营库存",operating:"运营",pending:"待交车",abnormal:"异动"}[p.category];
return `${p.groupBy === "none" ? "" : GROUP_LABELS[p.groupBy]}${category}`;
}
type ResultTurn = { result?: AssistantResult; resultHistory?: AssistantResult[] };
export function findConversationResult(turns: ResultTurn[], resultId: string) {
for (const turn of turns) {
if (turn.result?.resultId === resultId) return turn.result;
const current = turn.result?.sections?.find(section => section.result?.resultId === resultId)?.result;
if (current) return current;
for (const historical of [...(turn.resultHistory || [])].reverse()) {
if (historical.resultId === resultId) return historical;
const child = historical.sections?.find(section => section.result?.resultId === resultId)?.result;
if (child) return child;
}
}
}
export function replaceAnalysisResult<T extends ResultTurn>(turn: T, result: AssistantResult, baseResultId?: string): T & ResultTurn {
const history = [...(turn.resultHistory || [])];
const sectionId = baseResultId ? [turn.result, ...history.slice().reverse()]
.flatMap(item => item?.sections || [])
.find(section => section.result?.resultId === baseResultId)?.id : undefined;
if (turn.result?.resultId && (turn.result.sections || !history.some(item => item.resultId === turn.result!.resultId))) history.push(turn.result);
if (sectionId && turn.result?.sections) {
return { ...turn, result: {
...turn.result,
sections: turn.result.sections.map(section => section.id === sectionId ? {
...section, result, error: undefined,
display: result.plan.groupBy === "none" && result.plan.view === "chart" && result.plan.kind === "assets" ? "metrics" : result.plan.view,
} : section),
}, resultHistory: history };
}
return { ...turn, result, resultHistory: history };
}
@@ -1,253 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { GenerativeResultView } from "./GenerativeResultView";
import { CompoundResultView } from "./CompoundResultView";
import {
presentationAction,
presentationData,
presentationFields,
presentationMetrics,
} from "./generative-presentation";
import type {
AssistantResult,
PresentationInteraction,
} from "../../../shared/assets/assistant";
const result: AssistantResult = {
resultId: "generated-result",
sessionId: "demo",
plan: {
kind: "assets",
category: "inventory",
groupBy: "region",
view: "chart",
filters: {},
},
title: "库存分布",
summary: "华东库存12台,华南8台。",
source: "演示台账",
fetchedAt: "2026-09-23T08:00:00Z",
rows: [],
metrics: [
{ label: "总库存", value: 20, unit: "台" },
{ label: "异动", value: 2, unit: "台" },
],
groups: [
{
name: "华东",
count: 12,
operating: 0,
rate: 0,
keys: { region: "华东" },
},
{ name: "华南", count: 8, operating: 0, rate: 0, keys: { region: "华南" } },
],
daily: [
{ date: "2026-09-23", delivered: 1, returned: 2, replaced: 0, total: 3 },
],
presentation: {
title: "库存情况",
blocks: [
{ id: "conclusion", kind: "summary" },
{ id: "selected", kind: "metrics", metrics: ["异动", "总库存"] },
{ id: "narrative", kind: "narrative", text: "<script>不能执行</script>" },
{
id: "regions",
kind: "table",
dataset: "groups",
columns: [
{ field: "count", label: "库存数量" },
{ field: "name", label: "所属区域" },
],
interactionId: "cities",
},
],
interactions: [
{
id: "cities",
label: "查看城市分布",
kind: "group",
plan: {
kind: "assets",
category: "inventory",
groupBy: "city",
view: "chart",
filters: {},
},
},
],
},
};
const callbacks = {
ask: () => {},
operate: () => {},
restore: () => {},
activate: () => {},
showCorrection: () => {},
active: true,
subject: "演示公司",
disabled: false,
};
test("model-selected metrics and columns preserve their order and never invent values", () => {
assert.deepEqual(
presentationMetrics(result, ["异动", "不存在", "总库存"]).map(
(item) => item.value,
),
[2, 20],
);
assert.deepEqual(
presentationFields({
id: "table",
kind: "table",
dataset: "groups",
columns: [
{ field: "count", label: "数量" },
{ field: "secret", label: "无此字段" },
],
}),
[{ field: "count", label: "数量" }],
);
assert.deepEqual(presentationData(result, "groups")[0].keys, {
region: "华东",
});
assert.deepEqual(presentationData(result, "comparison"), []);
});
test("exploration uses stored interaction ID and actual clicked keys or date", () => {
const group = result.presentation!.interactions[0];
assert.deepEqual(
presentationAction(group, presentationData(result, "groups")[1]),
{ type: "explore", interactionId: "cities", keys: { region: "华南" } },
);
assert.equal(presentationAction(group, { name: "华东" }), undefined);
const point: PresentationInteraction = { ...group, id: "day", kind: "point" };
assert.deepEqual(
presentationAction(point, presentationData(result, "daily")[0]),
{ type: "explore", interactionId: "day", date: "2026-09-23" },
);
assert.equal(presentationAction(point, { name: "昨天" }), undefined);
assert.deepEqual(presentationAction({ ...group, kind: "followup" }), {
type: "explore",
interactionId: "cities",
});
});
test("semantic render respects block order, real table fields, plain text, scope and answer anchors", () => {
const html = renderToStaticMarkup(
createElement(GenerativeResultView, { ...callbacks, result }),
);
assert.ok(
html.indexOf('data-block-id="selected"') <
html.indexOf('data-block-id="regions"'),
);
assert.ok(html.indexOf(">异动</span>") < html.indexOf(">总库存</span>"));
assert.ok(html.indexOf(">库存数量</th>") < html.indexOf(">所属区域</th>"));
assert.match(html, /&lt;script&gt;不能执行&lt;\/script&gt;/);
assert.doesNotMatch(html, /<script>/);
for (const expected of [
"aq-query-generated-result",
"aq-answer-generated-result",
"查看城市分布",
"源数据更新时间:",
"未提供",
"演示公司",
"华东",
"复制完整结果",
])
assert.ok(html.includes(expected), expected);
});
test("stack layout renders each real section while tabs expand only the active section", () => {
const compound: AssistantResult = {
...result,
resultId: "parent",
layout: "stack",
presentation: undefined,
sections: [
{
id: "first",
title: "库存分析",
question: "库存",
display: "chart",
result,
},
{
id: "second",
title: "异动分析",
question: "异动",
display: "table",
result: { ...result, resultId: "second-child", summary: "异动2台。" },
},
{
id: "failed",
title: "成本分析",
question: "成本",
display: "metrics",
error: "缺少成本数据",
},
],
};
const stacked = renderToStaticMarkup(
createElement(CompoundResultView, {
...callbacks,
result: compound,
activeResultId: "second-child",
}),
);
assert.ok(stacked.includes('id="aq-query-generated-result"'));
assert.ok(stacked.includes('id="aq-query-second-child"'));
assert.ok(stacked.includes('data-section-id="first"'));
assert.match(stacked, /缺少成本数据/);
assert.doesNotMatch(stacked, /role="tablist"/);
const tabbed = renderToStaticMarkup(
createElement(CompoundResultView, {
...callbacks,
result: { ...compound, layout: "tabs" },
activeResultId: "second-child",
}),
);
assert.ok(!tabbed.includes('id="aq-query-generated-result"'));
assert.ok(tabbed.includes('id="aq-query-second-child"'));
assert.match(tabbed, /role="tablist"/);
});
test("AI summary text is honored and metrics-only layouts do not receive an unsolicited visible summary", () => {
const summary = renderToStaticMarkup(
createElement(GenerativeResultView, {
...callbacks,
result: {
...result,
presentation: {
blocks: [{ id: "s", kind: "summary", text: "异动2台,应先处理。" }],
interactions: [],
},
},
}),
);
assert.match(summary, /异动2台,应先处理。/);
const metricsOnly = renderToStaticMarkup(
createElement(GenerativeResultView, {
...callbacks,
result: {
...result,
presentation: {
blocks: [{ id: "m", kind: "metrics", metrics: ["总库存"] }],
interactions: [],
},
},
}),
);
assert.ok(
metricsOnly.indexOf(result.summary) >
metricsOnly.indexOf('<details class="aq-result-definitions">'),
);
assert.ok(!metricsOnly.includes('class="aq-answer"'));
assert.ok(metricsOnly.includes('id="aq-query-generated-result"'));
});
test("generated exploration does not add unrelated fixed followup suggestions", () => {
const markup=renderToStaticMarkup(createElement(GenerativeResultView,{
...callbacks,result:{...result,suggestions:["不应插入的固定城市路径"],presentation:{blocks:[{id:"m",kind:"metrics",metrics:["总库存"]}],interactions:[]}},
}));
assert.doesNotMatch(markup,/不应插入的固定城市路径/);
});
@@ -1,129 +0,0 @@
import type {
AssistantAction,
AssistantResult,
Dimension,
PresentationBlock,
PresentationInteraction,
} from "../../../shared/assets/assistant";
export type PresentationDatum = Record<string, unknown>;
const fields: Record<NonNullable<PresentationBlock["dataset"]>, string[]> = {
groups: [
"name",
"count",
"operating",
"rate",
"abnormal",
"pending",
"share",
],
daily: ["date", "delivered", "returned", "replaced", "total"],
comparison: ["label", "current", "previous", "delta", "rate"],
rows: [
"id",
"plate",
"vehicleClass",
"type",
"model",
"region",
"city",
"department",
"status",
"inventory",
"abnormal",
"pending",
"abnormalType",
"targetCompletionTime",
"actualCompletionTime",
"date",
"event",
],
};
export function presentationData(
result: AssistantResult,
dataset: PresentationBlock["dataset"],
): PresentationDatum[] {
return dataset ? (result[dataset] || []).map((row) => ({ ...row })) : [];
}
export function presentationFields(block: PresentationBlock) {
const allowed = block.dataset ? fields[block.dataset] : [];
const defaults =
block.dataset === "daily"
? [
{ field: "date", label: "日期" },
{ field: "total", label: "车次" },
]
: block.dataset === "comparison"
? [
{ field: "label", label: "指标" },
{ field: "current", label: "本期" },
{ field: "previous", label: "上期" },
]
: block.dataset === "rows"
? [
{ field: "plate", label: "车牌" },
{ field: "status", label: "状态" },
]
: [
{ field: "name", label: "分组" },
{ field: "count", label: "数量" },
];
return (block.columns?.length ? block.columns : defaults).filter((column) =>
allowed.includes(column.field),
);
}
export function presentationSeries(block: PresentationBlock) {
const allowed = block.dataset ? fields[block.dataset] : [];
const fallback =
block.dataset === "daily"
? { field: "total", label: "车次" }
: block.dataset === "comparison"
? { field: "current", label: "本期" }
: { field: "count", label: "数量" };
return (block.series?.length ? block.series : [fallback]).filter(
(series) =>
allowed.includes(series.field) &&
!["name", "date", "label"].includes(series.field),
);
}
export function presentationMetrics(
result: AssistantResult,
labels?: string[],
) {
return labels?.length
? labels.flatMap((label) => {
const metric = result.metrics.find((item) => item.label === label);
return metric ? [metric] : [];
})
: result.metrics;
}
export function presentationValue(value: unknown, field: string) {
if (value === undefined || value === null || value === "") return "—";
if (typeof value === "number")
return Number.isFinite(value)
? `${value.toLocaleString("zh-CN")}${["rate", "share"].includes(field) ? "%" : ""}`
: "—";
if (typeof value === "boolean") return value ? "是" : "否";
return typeof value === "string" ? value : "—";
}
export function presentationAction(
interaction: PresentationInteraction | undefined,
datum?: PresentationDatum,
): AssistantAction | undefined {
if (!interaction) return;
if (interaction.kind === "followup")
return { type: "explore", interactionId: interaction.id };
if (
interaction.kind === "group" &&
datum?.keys &&
typeof datum.keys === "object" &&
Object.keys(datum.keys).length
)
return {
type: "explore",
interactionId: interaction.id,
keys: datum.keys as Partial<Record<Dimension, string>>,
};
if (interaction.kind === "point" && typeof datum?.date === "string")
return { type: "explore", interactionId: interaction.id, date: datum.date };
}
@@ -1,122 +0,0 @@
/* Mobile presentation only. Keep desktop analysis density unchanged. */
.aq-context-disclosure > summary { display: none; }
@media (max-width: 767px) {
.aq-page { font-size: 14px; }
.aq-page .aq-turns { padding: 16px 16px 24px; }
.aq-page .aq-result, .aq-page .aq-compound-result { margin-left: 0; }
.aq-page .aq-question { margin-bottom: 16px; }
.aq-page .aq-question p { font-size: 15px; line-height: 1.65; }
.aq-page .aq-thinking { border: 0; border-radius: 8px; padding: 0 8px; margin-bottom: 8px; }
.aq-page .aq-thinking > button { font-size: 13px; }
.aq-page .aq-thinking-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.aq-page .aq-thinking-meta { font-size: 12px; }
.aq-page .aq-query-heading { position: static; padding: 4px 0 8px; margin-bottom: 10px; }
.aq-page .aq-current-path { flex-wrap: wrap; overflow: visible; font-size: 13px; gap: 4px 6px; }
.aq-page .aq-current-path > span { flex-shrink: 1; white-space: normal; }
.aq-page .aq-current-path > .aq-path-current { position: static; }
.aq-page .aq-query-navigation { flex-wrap: wrap; font-size: 12px; }
.aq-page .aq-query-navigation > span { font-size: 12px; }
.aq-page .aq-query-navigation > button, .aq-page .aq-path-history > summary { min-height: 44px; font-size: 13px; }
.aq-page .aq-path-history nav button { min-height: 44px; font-size: 13px; }
.aq-page .aq-result .aq-answer { font-size: 15px; line-height: 1.75; margin: 0 0 12px; }
.aq-page .aq-generated-block { margin: 12px 0; }
.aq-page .aq-generated-block h4 { font-size: 14px; }
.aq-page .aq-generated-metrics, .aq-page .aq-primary-metrics,
.aq-page .aq-primary-metrics:has(> div:nth-child(4):last-child) { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.aq-page .aq-generated-metrics > div { padding: 10px 12px; border: 0; border-radius: 8px; }
.aq-page .aq-generated-metrics span, .aq-page .aq-metrics span { font-size: 13px; line-height: 1.5; }
.aq-page .aq-generated-metrics strong, .aq-page .aq-metrics strong { font-size: 26px; margin-top: 3px; }
.aq-page .aq-generated-metrics small, .aq-page .aq-metrics small { font-size: 12px; }
.aq-page .aq-primary-metrics > div { border: 0; padding: 8px 10px !important; background: var(--aq-inset); border-radius: 8px; }
.aq-mobile-visual { margin: 12px 0; border: 1px solid var(--aq-line); border-radius: 10px; padding: 4px 12px 8px; }
.aq-mobile-visual-header { display: flex; align-items: center; gap: 8px; justify-content: space-between; }
.aq-mobile-visual-header > h4 { font-size: 14px; font-weight: 600; margin: 0; min-width: 0; }
.aq-mobile-visual-header > div { display: flex; flex-shrink: 0; gap: 2px; }
.aq-mobile-visual-header button { min-height: 44px; min-width: 44px; padding: 5px 6px; font-size: 13px; color: var(--aq-muted); }
.aq-mobile-visual-header button[aria-pressed="true"] { color: var(--aq-accent); font-weight: 600; text-decoration: underline; text-underline-offset: 7px; }
.aq-mobile-ranking { display: grid; gap: 4px; }
.aq-mobile-ranking > button:not(.aq-generated-expand), .aq-mobile-ranking-item { width: 100%; min-height: 48px; display: block; padding: 6px 0; text-align: left; }
.aq-mobile-ranking-line { display: flex; gap: 8px; align-items: center; font-size: 14px; line-height: 1.5; }
.aq-mobile-ranking-line > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.aq-mobile-ranking-line > strong { font-variant-numeric: tabular-nums; font-weight: 600; white-space: nowrap; }
.aq-mobile-ranking-line > svg { color: var(--aq-muted); }
.aq-mobile-ranking-track { display: block; height: 5px; border-radius: 3px; background: var(--aq-inset); margin-top: 6px; overflow: hidden; }
.aq-mobile-ranking-track > span { height: 100%; display: block; background: var(--aq-accent); opacity: .65; border-radius: inherit; }
.aq-page .aq-generated-expand { min-height: 44px; font-size: 13px; }
.aq-mobile-data-list { display: grid; gap: 8px; padding: 4px 0; }
.aq-mobile-data-row { min-width: 0; padding: 10px 12px; background: var(--aq-inset); border-radius: 8px; font-size: 14px; line-height: 1.6; }
.aq-mobile-data-values { display: grid; grid-template-columns: minmax(0, 1.35fr) repeat(2, minmax(0, 1fr)); gap: 8px; }
.aq-mobile-data-values span { display: block; font-size: 12px; color: var(--aq-muted); margin-bottom: 2px; }
.aq-mobile-data-values strong { display: block; font-size: 14px; font-weight: 550; overflow-wrap: anywhere; }
.aq-mobile-data-values > div:only-child { grid-column: 1 / -1; }
.aq-mobile-data-row dl { display: grid; gap: 7px; margin: 8px 0; }
.aq-mobile-data-row dl > div { display: grid; grid-template-columns: 80px minmax(0, 1fr); gap: 8px; }
.aq-mobile-data-row dt { color: var(--aq-muted); font-size: 13px; }
.aq-mobile-data-row dd { margin: 0; overflow-wrap: anywhere; }
.aq-mobile-data-footer { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 0 8px; justify-content: space-between; margin-top: 4px; }
.aq-mobile-data-footer > details[open] { width: 100%; }
.aq-mobile-data-row summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; color: var(--aq-muted); font-size: 13px; }
.aq-mobile-data-row summary::after { content: '+'; margin-left: 8px; }
.aq-mobile-data-row details[open] > summary::after { content: '−'; }
.aq-mobile-data-footer > button { min-height: 44px; color: var(--aq-accent); font-size: 13px; }
.aq-mobile-record-heading { display: flex; gap: 8px; justify-content: space-between; align-items: center; }
.aq-mobile-record-heading > strong { white-space: nowrap; font-size: 16px; }
.aq-mobile-record-heading > span { font-size: 12px; color: var(--aq-muted); }
.aq-mobile-data-row > p { margin: 5px 0 0; color: var(--aq-muted); font-size: 13px; }
.aq-page .aq-generated-pagination, .aq-page .aq-pagination { font-size: 12px; padding: 6px 0; }
.aq-page .aq-generated-pagination > button, .aq-page .aq-pagination > button { min-width: 44px; min-height: 44px; }
.aq-mobile-actions { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; margin: 4px 0 10px; }
.aq-mobile-actions > button { min-height: 44px; padding: 4px 0; color: var(--aq-accent); font-size: 13px; }
.aq-mobile-actions > button:disabled { opacity: 1; color: var(--aq-muted); cursor: default; }
.aq-more-actions > summary { display: flex; align-items: center; gap: 5px; min-height: 44px; cursor: pointer; color: var(--aq-muted); font-size: 13px; }
.aq-more-actions[open] { width: 100%; }
.aq-more-actions[open] > summary { justify-content: flex-end; }
.aq-page .aq-more-actions .aq-answer-actions { margin: 0; display: flex; flex-wrap: wrap; gap: 4px 16px; }
.aq-page .aq-more-actions .aq-answer-actions > button { font-size: 13px; min-height: 44px; }
.aq-page .aq-more-actions .aq-answer-actions > span { font-size: 12px; }
.aq-page .aq-result .aq-result-definitions > summary, .aq-page .aq-result .aq-secondary-details > summary,
.aq-page .aq-result .aq-advanced-controls > summary, .aq-page .aq-result .aq-extra-metrics > summary { min-height: 44px; font-size: 13px; line-height: 1.6; display: flex; align-items: center; }
.aq-page .aq-result-definitions, .aq-page .aq-generated-narrative { font-size: 14px; line-height: 1.75; }
.aq-page .aq-followups { gap: 8px; }
.aq-page .aq-followups > span { width: 100%; font-size: 12px; }
.aq-page .aq-followups > button { min-height: 44px; font-size: 13px; text-align: left; }
.aq-page .aq-generated-drill-targets { max-height: none; overflow: visible; gap: 8px; }
.aq-page .aq-generated-drill-targets button { min-height: 44px; font-size: 13px; }
.aq-page .aq-result .aq-table-scroll { max-height: none; overflow-y: visible; }
.aq-page .aq-table-scroll th, .aq-page .aq-table-scroll td { font-size: 13px; white-space: normal; overflow-wrap: anywhere; padding: 10px 8px; }
.aq-page .aq-table-scroll th { position: static; }
.aq-page .aq-result .aq-bar-row { min-height: 44px; grid-template-columns: 75px minmax(15px, 1fr) 36px 40px 13px; font-size: 13px !important; gap: 6px !important; }
.aq-page .aq-bar-name { white-space: normal; overflow-wrap: anywhere; }
.aq-page .aq-result .aq-view-tabs button, .aq-page .aq-result .aq-more { min-height: 44px; font-size: 13px; }
.aq-page .aq-record-toolbar input { font-size: 16px; min-height: 44px; }
.aq-page .aq-section-preview > strong { font-size: 14px; }
.aq-page .aq-section-preview > span { font-size: 13px; }
.aq-page .aq-section-overview > button { padding: 10px; }
.aq-page .aq-compound-toolbar { font-size: 12px; }
.aq-page .aq-compound-toolbar button { min-height: 44px; font-size: 13px; }
.aq-page .aq-context-disclosure { margin-bottom: 4px; min-width: 0; }
.aq-page .aq-context-disclosure > summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 44px; color: var(--aq-muted); font-size: 13px; cursor: pointer; }
.aq-context-disclosure > summary::-webkit-details-marker { display: none; }
.aq-context-disclosure > summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.aq-context-disclosure[open] > summary > span { white-space: normal; }
.aq-page .aq-context { flex-wrap: wrap; overflow: visible; padding: 4px 0; gap: 6px; max-height: 160px; overflow-y: auto; }
.aq-page .aq-context > * { flex-shrink: 1; }
.aq-page .aq-context .aq-filter-chip { max-width: 100%; font-size: 13px; overflow-wrap: anywhere; white-space: normal; }
.aq-page .aq-context button { min-height: 44px; min-width: 44px; }
.aq-page .aq-context .aq-topic-button { padding: 4px 10px; font-size: 13px; }
.aq-page .aq-context-heading { font-size: 12px; }
.aq-page .aq-composer-area { border-top: 1px solid var(--aq-line); }
.aq-page .aq-composer-foot { font-size: 11px; }
.aq-page .aq-latest-answer { min-height: 44px; font-size: 13px; }
}
@media (max-width: 767px) {
.aq-page .aq-header h1 { font-size: 16px; }
.aq-page .aq-scope, .aq-page .aq-compound-heading > div > span,
.aq-page .aq-section-number, .aq-page .aq-generated-notice,
.aq-page .aq-generated-empty, .aq-page .aq-result-unit,
.aq-page .aq-chart-caption, .aq-page .aq-bar-share,
.aq-page .aq-model, .aq-page .aq-record-toolbar,
.aq-page .aq-compound-notice, .aq-page .aq-inline-progress { font-size: 12px; }
.aq-page .aq-secondary-details[open] > p { font-size: 14px; line-height: 1.75; margin-bottom: 8px; }
.aq-page .aq-mobile-record-heading { flex-wrap: wrap; }
}
@@ -1,36 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AssistantResult, PresentationBlock } from '../../../shared/assets/assistant';
import { mobileBlockGroups, mobileResultSummary } from './mobile-presentation';
const result: AssistantResult = {
plan: { kind: 'assets', category: 'inventory', groupBy: 'region', view: 'chart', filters: { type: ['4.5T'] } },
title: '库存', summary: '完整说明和统计限制。', source: '资产台账', fetchedAt: '', rows: [],
metrics: [{ label: '库存车辆', value: 200, unit: '台' }, { label: '其中异动', value: 8, unit: '台' }],
groups: [{ name: '华东', count: 125, share: 62.5, operating: 0, rate: 0 }, { name: '华南', count: 75, share: 37.5, operating: 0, rate: 0 }],
};
test('mobile summary preserves computed total and share without adding subset metrics', () => {
assert.equal(mobileResultSummary(result), '库存车辆 200 台;华东最多,125 台(62.5%)。');
assert.equal(mobileResultSummary({ ...result, groups: [...result.groups].reverse() }), mobileResultSummary(result));
});
test('partial, exception, comparison, briefing and simple count answers retain their qualification', () => {
for (const outcome of ['partial', 'empty', 'unsupported'] as const) assert.equal(mobileResultSummary({ ...result, outcome }), result.summary);
for (const analysis of ['compare', 'exceptions', 'briefing'] as const) assert.equal(mobileResultSummary({ ...result, plan: { ...result.plan, analysis } }), result.summary);
assert.equal(mobileResultSummary({ ...result, conciseCount: true }), result.summary);
assert.equal(mobileResultSummary({ ...result, metrics: [] }), result.summary);
});
test('chart/table toggles only combine identical data and drill semantics; unrelated fields remain reachable', () => {
const blocks: PresentationBlock[] = [
{ id: 'chart', kind: 'chart', dataset: 'groups', interactionId: 'region' },
{ id: 'table-other', kind: 'table', dataset: 'groups', interactionId: 'type' },
{ id: 'table', kind: 'table', dataset: 'groups', interactionId: 'region', columns: [{ field: 'pending', label: '待交车' }] },
{ id: 'rows', kind: 'table', dataset: 'rows' },
{ id: 'second-chart', kind: 'chart', dataset: 'groups', interactionId: 'region' },
{ id: 'explanation', kind: 'narrative', text: '统计限制' },
];
const grouped = mobileBlockGroups(blocks);
assert.equal(grouped[0].table, blocks[2]);
assert.equal(grouped.find(entry => entry.block.id === 'second-chart')?.table, undefined);
assert.deepEqual(grouped.map(entry => entry.block.id), ['chart', 'table-other', 'rows', 'second-chart', 'explanation']);
assert.equal(grouped.flatMap(entry => [entry.block, ...(entry.table ? [entry.table] : [])]).length, blocks.length);
});
@@ -1,26 +0,0 @@
import type { AssistantResult, PresentationBlock } from '../../../shared/assets/assistant';
/** Summarise computed values only; never add totals across independent scopes. */
export function mobileResultSummary(result: AssistantResult): string {
if (result.plan.kind !== 'assets' || (result.outcome && result.outcome !== 'complete') || (result.plan.analysis && result.plan.analysis !== 'distribution') || result.conciseCount) return result.summary;
const total = result.metrics.find(metric => metric.unit === '台' && Number.isFinite(metric.value));
if (!total) return result.summary;
const largest = result.groups.filter(group => Number.isFinite(group.count)).reduce<(typeof result.groups)[number] | undefined>((lead, group) => !lead || group.count > lead.count ? group : lead, undefined);
const headline = `${total.label} ${total.value.toLocaleString('zh-CN')} 台`;
if (result.groups.length < 2 || !largest || !largest.count) return `${headline}。`;
const share = largest.share;
return `${headline};${largest.name}最多,${largest.count.toLocaleString('zh-CN')} 台${typeof share === 'number' && Number.isFinite(share) ? `(${share}%)` : ''}。`;
}
export type MobileBlockGroup = { block: PresentationBlock; table?: PresentationBlock };
/** Pair one chart and one table only when they share both data and drill semantics. */
export function mobileBlockGroups(blocks: PresentationBlock[]): MobileBlockGroup[] {
const paired = new Set<string>();
const pairs = new Map<string, PresentationBlock>();
for (const block of blocks) {
if (block.kind !== 'chart' || !block.dataset) continue;
const table = blocks.find(candidate => candidate.kind === 'table' && !paired.has(candidate.id) && candidate.dataset === block.dataset && candidate.interactionId === block.interactionId);
if (table) { paired.add(table.id); pairs.set(block.id, table); }
}
return blocks.filter(block => !paired.has(block.id)).map(block => ({ block, table: pairs.get(block.id) }));
}
@@ -1,225 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
copyResultText,
resultScope,
resultDisplay,
queryPath,
compactAnalysisPath,
} from "./result-presentation.js";
import type { AssistantResult } from "../../../shared/assets/assistant.js";
const result: AssistantResult = {
plan: {
kind: "assets",
category: "available",
groupBy: "region",
view: "table",
filters: { type: ["4.5T"] },
},
title: "可运营库存 · 按区域统计",
summary: "当前范围共有135台可运营库存。",
source: "资产台账",
fetchedAt: "2026-09-23T08:00:00Z",
groups: [],
rows: [],
metrics: [{ label: "可运营库存", value: 135, unit: "台" }],
scope: {
company: "演示公司",
permission: "当前账号可见车辆",
filters: ["4.5T", "可运营库存"],
grouping: "区域",
period: "当前资产台账",
definitions: ["车型按吨位汇总,4.5T包含普货与冷链"],
sourceUpdatedAt: null,
},
};
test("copy preserves facts, scope, definitions and distinguishes unknown source update from query time", () => {
const text = copyResultText(result, null);
for (const expected of [
"135台",
"公司:演示公司",
"权限范围:当前账号可见车辆",
"筛选条件:4.5T;可运营库存",
"分组:区域",
"4.5T包含普货与冷链",
"源数据更新时间:未提供",
"查询时间:",
])
assert.ok(text.includes(expected), expected);
});
test("partial briefing copies missing answers and inventory evidence without inventing a source timestamp", () => {
const partial: AssistantResult = {
...result,
outcome: "partial",
unanswered: [
{ question: "闲置损失多少钱", reason: "未接入成本数据,不估算金额。" },
],
inventoryBreakdown: {
title: "库存集中情况",
summary: "4.5T冷链库存最多。",
groups: [
{ name: "4.5T冷链", count: 155, operating: 0, rate: 0, share: 53.3 },
],
},
};
const text = copyResultText(partial, null);
assert.match(text, /4.5T冷链:155台,占库存53.3%/);
assert.match(text, /尚未回答:[\s\S]*未接入成本数据,不估算金额/);
assert.match(text, /源数据更新时间:未提供/);
});
test("copying a revoked result keeps the revocation explicit", () => {
const text = copyResultText(
result,
null,
"上一条回答的是待运营,不能用于判断待交车。",
);
assert.match(
text,
/【此回答已撤回】上一条回答的是待运营,不能用于判断待交车/,
);
});
test("fallback scope keeps exclusion and selected vehicle filters", () => {
const fallback = resultScope(
{
...result,
scope: undefined,
plan: {
...result.plan,
excludeFilters: { city: ["嘉兴市"] },
vehicleIds: ["example-1"],
exception: "overdue",
},
},
"演示公司",
);
assert.deepEqual(fallback.filters, [
"可运营库存",
"车型:4.5T",
"排除城市:嘉兴市",
"指定车辆:1台",
"已超过预计完成时间",
]);
assert.equal(fallback.sourceUpdatedAt, null);
});
test("copy includes regional facts and daily flow without duplicating groups or copying vehicle details", () => {
const regional: AssistantResult = {
...result,
groups: [
{
name: "华东",
count: 99,
operating: 0,
rate: 0,
share: 73.3,
abnormal: 1,
pending: 0,
},
],
insights: ["华东占当前可运营库存73.3%。"],
rows: [
{
id: "private-vehicle",
plate: "测试车牌不应复制",
vehicleClass: "4.5T普货",
type: "4.5T",
model: "演示型号",
region: "华东",
city: "嘉兴市",
department: "演示部门",
status: "可运营",
},
],
};
const regionalText = copyResultText(regional, null);
assert.match(regionalText, /华东:99台,占比73.3%/);
assert.match(regionalText, /异动1台,待交车0台/);
assert.match(regionalText, /分析发现:[\s\S]*华东占当前可运营库存73.3%/);
assert.ok(!regionalText.includes("测试车牌不应复制"));
const flowText = copyResultText(
{
...regional,
plan: {
...regional.plan,
kind: "flow",
start: "2026-09-23",
end: "2026-09-23",
},
daily: [
{
date: "2026-09-23",
delivered: 5,
returned: 2,
replaced: 1,
total: 7,
},
],
},
null,
);
assert.match(
flowText,
/2026-09-23:交车5车次,还车2车次,其中替换交车1车次,合计7车次/,
);
assert.match(flowText, /替换交车包含在交车中,不重复相加/);
assert.ok(!flowText.includes("华东:99台"));
assert.ok(!flowText.includes("测试车牌不应复制"));
});
test("single totals use metric cards while comparisons, trends and explicit records retain their display", () => {
const total = {
...result.plan,
groupBy: "none" as const,
view: "chart" as const,
};
assert.equal(resultDisplay(total), "metrics");
assert.equal(resultDisplay({ ...total, groupBy: "region" }), "chart");
assert.equal(resultDisplay({ ...total, secondaryGroupBy: "type" }), "chart");
assert.equal(resultDisplay({ ...total, kind: "flow" }), "chart");
assert.equal(resultDisplay({ ...total, view: "list" }), "list");
assert.equal(resultDisplay({ ...total, view: "table" }), "table");
});
test("current path retains every active slice and highlights the current grouping", () => {
const path = queryPath({
...result.plan,
filters: { region: ["华东"], city: ["嘉兴市"] },
excludeFilters: { model: ["演示型号"] },
groupBy: "vehicleClass",
exception: "overdue",
});
assert.deepEqual(path, [
"可运营库存",
"区域:华东",
"城市:嘉兴市",
"排除型号:演示型号",
"已超期",
"按车型细分统计",
]);
});
test("analysis path collapses display-only steps and preserves the latest node of each scope", () => {
const path = compactAnalysisPath([
{ resultId: "all", label: "全部库存" },
{ resultId: "east-chart", label: "库存 · 华东" },
{ resultId: "east-table", label: "库存 · 华东" },
{ resultId: "jiaxing", label: "库存 · 华东 / 嘉兴" },
]);
assert.deepEqual(
path.map((crumb) => crumb.resultId),
["all", "east-table", "jiaxing"],
);
assert.equal(path.at(-2)?.label, "库存 · 华东");
});
test("vehicle status navigation identifies a vehicle answer instead of a total", () => {
const path = queryPath({ kind: "assets", category: "all", groupBy: "none", view: "list", filters: {}, vehicleIds: ["v1"], responseMode: "vehicle-status" });
assert.ok(path.includes("车辆状态"));
assert.ok(!path.includes("总量"));
});
@@ -1,194 +0,0 @@
import {
CATEGORY_LABELS,
GROUP_LABELS,
type AssistantResult,
type Dimension,
type QueryPlan,
} from "../../../shared/assets/assistant";
export function compactAnalysisPath(
breadcrumbs: AssistantResult["breadcrumbs"] = [],
) {
const path: NonNullable<AssistantResult["breadcrumbs"]> = [];
for (const crumb of breadcrumbs) {
if (path.at(-1)?.label === crumb.label) path[path.length - 1] = crumb;
else path.push(crumb);
}
return path;
}
export function queryPath(plan: QueryPlan): string[] {
return [
plan.kind === "flow" ? "交退换车" : CATEGORY_LABELS[plan.category],
...Object.entries(plan.filters).map(
([dimension, values]) =>
`${GROUP_LABELS[dimension as Dimension]}:${values.join("、")}`,
),
...Object.entries(plan.excludeFilters || {}).map(
([dimension, values]) =>
`排除${GROUP_LABELS[dimension as Dimension]}:${values.join("、")}`,
),
...(plan.vehicleIds?.length ? [`已选${plan.vehicleIds.length}台车辆`] : []),
...(plan.exception
? [
plan.exception === "overdue"
? "已超期"
: plan.exception === "dueSoon"
? "即将到期"
: "未填写预计完成时间",
]
: []),
plan.groupBy === "none" && !plan.secondaryGroupBy
? plan.responseMode === "vehicle-status" ? "车辆状态" : "总量"
: `按${[GROUP_LABELS[plan.groupBy], plan.secondaryGroupBy && GROUP_LABELS[plan.secondaryGroupBy]].filter(Boolean).join(" × ")}统计`,
];
}
export function resultDisplay(
plan: QueryPlan,
): "metrics" | "chart" | "table" | "list" {
if (
plan.kind === "assets" &&
plan.groupBy === "none" &&
!plan.secondaryGroupBy &&
plan.view === "chart"
)
return "metrics";
return plan.view;
}
export function displayTime(value: string | null | undefined) {
if (!value) return "未提供";
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: date.toLocaleString("zh-CN", { hour12: false });
}
export function resultScope(result: AssistantResult, subject: string | null) {
if (result.scope) return result.scope;
const { plan } = result;
return {
company: subject || "全部所属公司",
permission: "当前账号可见范围",
filters: [
CATEGORY_LABELS[plan.category],
...Object.entries(plan.filters).map(
([dimension, values]) =>
`${GROUP_LABELS[dimension as Dimension]}:${values.join("、")}`,
),
...Object.entries(plan.excludeFilters || {}).map(
([dimension, values]) =>
`排除${GROUP_LABELS[dimension as Dimension]}:${values.join("、")}`,
),
...(plan.vehicleIds?.length
? [`指定车辆:${plan.vehicleIds.length}台`]
: []),
...(plan.exception
? [
plan.exception === "overdue"
? "已超过预计完成时间"
: plan.exception === "dueSoon"
? "即将到期"
: "未填写预计完成时间",
]
: []),
],
grouping: [
GROUP_LABELS[plan.groupBy],
plan.secondaryGroupBy && GROUP_LABELS[plan.secondaryGroupBy],
]
.filter(Boolean)
.join(" × "),
period: plan.start
? `${plan.start} — ${plan.end || plan.start}`
: "当前资产台账",
definitions: [],
sourceUpdatedAt: null,
};
}
export function copyResultText(
result: AssistantResult,
subject: string | null,
correctionMessage?: string,
): string {
if (result.sections?.length) return [
result.title, result.summary,
...(correctionMessage ? [`【此回答已撤回】${correctionMessage}`] : []),
...result.sections.map((section, index) => `\n${index + 1}. ${section.title}\n${section.result ? copyResultText(section.result, subject) : section.error || "暂未完成"}`),
...(result.unanswered?.length ? ["尚未回答:", ...result.unanswered.map(item => `${item.question}:${item.reason}`)] : []),
].join("\n");
const scope = resultScope(result, subject);
return [
result.presentation?.title || result.title,
...(correctionMessage ? [`【此回答已撤回】${correctionMessage}`] : []),
...(result.correction ? [`【纠正说明】${result.correction.message}`] : []),
result.summary,
...(result.presentation?.blocks.filter(block => block.kind === "narrative" && block.text).map(block => `${block.title ? `${block.title}:` : ""}${block.text}`) || []),
...result.metrics.map(
(metric) =>
`${metric.label}:${metric.value.toLocaleString()}${metric.unit}`,
),
...(result.plan.kind === "assets" &&
result.plan.analysis !== "briefing" &&
result.groups.length
? [
"分组统计:",
...result.groups.map(
(group) =>
`${group.name}:${group.count}台${group.share === undefined ? "" : `,占比${group.share}%`},运营率${group.rate}%${group.abnormal === undefined ? "" : `,异动${group.abnormal}台`}${group.pending === undefined ? "" : `,待交车${group.pending}台`}`,
),
]
: []),
...(result.plan.kind === "flow" && result.daily?.length
? [
"每日流转(车次):替换交车包含在交车中,不重复相加。",
...result.daily.map(
(day) =>
`${day.date}:交车${day.delivered}车次,还车${day.returned}车次,其中替换交车${day.replaced}车次,合计${day.total}车次`,
),
]
: []),
...(result.insights?.length ? ["分析发现:", ...result.insights] : []),
...(result.inventoryBreakdown
? [
result.inventoryBreakdown.title,
result.inventoryBreakdown.summary,
...result.inventoryBreakdown.groups.map(
(group) =>
`${group.name}:${group.count}台${group.share === undefined ? "" : `,占库存${group.share}%`}`,
),
]
: []),
...(result.comparison?.length
? [
`对比期间:${result.comparisonPeriod?.start || "未提供"} — ${result.comparisonPeriod?.end || "未提供"}`,
...result.comparison.map(
(metric) =>
`${metric.label}:本期${metric.current},上期${metric.previous},差值${metric.delta},变化率${metric.rate === null ? "基期为0,不计算" : `${metric.rate}%`}`,
),
]
: []),
...(result.unanswered?.length
? [
"尚未回答:",
...result.unanswered.map(
(item) => `${item.question}:${item.reason}`,
),
]
: []),
`公司:${scope.company}`,
`权限范围:${scope.permission}`,
`筛选条件:${scope.filters.join(";") || "无额外筛选"}`,
`分组:${scope.grouping}`,
`统计期间:${scope.period}`,
...(result.plan.topN
? [`仅展示前${result.plan.topN}组;总计与明细包含全部匹配车辆`]
: []),
...scope.definitions.map((definition) => `统计口径:${definition}`),
`来源:${result.source}`,
`源数据更新时间:${displayTime(scope.sourceUpdatedAt)}`,
`查询时间:${displayTime(result.fetchedAt)}`,
].join("\n");
}
@@ -1,60 +0,0 @@
import Blur from '../../../components/Blur';
import type { VehicleListItem } from '../types';
type DetailsProps = { vehicles: VehicleListItem[] };
function InventoryVehicleDetails({ vehicles, kind }: DetailsProps & { kind: 'abnormal' | 'pending' }) {
const pending = kind === 'pending';
const labels = pending ? ['合同编号', '客户名称(已脱敏)'] : ['异动类型', '审批通过时间', '已异动时长', '具体工作', '申请人', '预计完成时间', '实际完成时间'];
const details = (vehicle: VehicleListItem) => pending
? [vehicle.pendingDelivery?.contractNo || '未提供', vehicle.pendingDelivery?.customerName || '未提供']
: [
vehicle.abnormalMove?.type || '未提供',
vehicle.abnormalMove?.approvalPassedAt ? new Date(vehicle.abnormalMove.approvalPassedAt).toLocaleString('zh-CN', { hour12: false }) : '审批时间缺失',
vehicle.abnormalMove?.elapsedDays != null ? `${vehicle.abnormalMove.elapsedDays.toFixed(1)} 天` : '无法计算',
vehicle.abnormalMove?.work || '未提供',
vehicle.abnormalMove?.applicant || '未提供',
vehicle.abnormalMove?.targetCompletionTime || '未提供',
vehicle.abnormalMove?.actualCompletionTime || (vehicle.abnormalMove?.status === '待结束' ? '未完成' : '未提供'),
];
if (vehicles.length === 0) return <div className="py-14 text-center text-sm text-slate-500">暂无符合筛选条件的{pending ? '待交车' : '异动车辆'}</div>;
return <>
<p className="px-4 py-3 text-xs leading-5 text-slate-500">{pending ? '展示当前待交车任务的合同信息,客户名称已脱敏。' : '申请人取异动单创建人;未结束的异动显示“未完成”,缺失信息显示“未提供”。'}</p>
<div className="space-y-3 px-3 pb-3 md:hidden">
{vehicles.map(vehicle => <article key={vehicle.id} className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<h4 className="break-all font-mono text-base font-bold text-blue-700"><Blur>{vehicle.plateNumber || vehicle.vin || '—'}</Blur></h4>
<p className="mt-1 text-xs text-slate-500">{vehicle.brandLabel || '—'} · {vehicle.type} · {vehicle.location === '其他' ? '对接中' : vehicle.location}</p>
<dl className="mt-4 space-y-3 text-sm">
{details(vehicle).map((value, index) => <div key={labels[index]} className="grid grid-cols-[6rem_minmax(0,1fr)] gap-2">
<dt className="text-slate-500">{pending && index === 1 ? "客户(已脱敏)" : labels[index]}</dt>
<dd className="whitespace-pre-wrap break-words font-medium text-slate-800 [overflow-wrap:anywhere]"><Blur>{value}</Blur></dd>
</div>)}
</dl>
</article>)}
</div>
<div className="hidden overflow-x-auto md:block" tabIndex={0} role="region" aria-label={pending ? "待交车明细表" : "异动明细表,可横向滚动"}>
<table className={`w-full ${pending ? "min-w-[700px]" : "min-w-[1160px]"} border-collapse text-left text-sm`}>
<thead className="sticky top-0 z-20 bg-slate-700 text-white"><tr>
{['车牌', '品牌', '车型', '所在地', ...labels].map(label => <th key={label} scope="col" className={`whitespace-nowrap p-3 ${label === "具体工作" ? "w-64" : ""}`}>{label}</th>)}
</tr></thead>
<tbody>{vehicles.map(vehicle => <tr key={vehicle.id} className="border-b border-slate-200 bg-white align-top">
<td className="p-3 font-mono font-bold text-blue-700"><Blur>{vehicle.plateNumber || vehicle.vin || '—'}</Blur></td>
<td className="p-3 text-slate-600">{vehicle.brandLabel || '—'}</td>
<td className="p-3 text-slate-600">{vehicle.type}</td>
<td className="p-3 text-slate-600">{vehicle.location === '其他' ? '对接中' : vehicle.location}</td>
{details(vehicle).map((value, index) => <td key={labels[index]} className={`whitespace-pre-wrap break-words p-3 leading-6 text-slate-700 [overflow-wrap:anywhere] ${labels[index] === "具体工作" ? "min-w-56 max-w-72" : "min-w-28 max-w-48"}`}><Blur>{value}</Blur></td>)}
</tr>)}</tbody>
</table>
</div>
</>;
}
export function AbnormalVehicleDetails(props: DetailsProps) {
return <InventoryVehicleDetails {...props} kind="abnormal" />;
}
export function PendingVehicleDetails(props: DetailsProps) {
return <InventoryVehicleDetails {...props} kind="pending" />;
}
@@ -1,225 +0,0 @@
import React from 'react';
import { ChevronDown, Filter, Loader2, Search } from 'lucide-react';
import { motion } from 'motion/react';
import type { SubjectOption } from '../api';
export type AssetsTab = 'overview' | 'department' | 'region' | 'customer';
export type AssetsTheme = 'soft' | 'minimal' | 'vibrant';
interface AssetsHeaderProps {
activeTab: AssetsTab;
setActiveTab: React.Dispatch<React.SetStateAction<AssetsTab>>;
theme: AssetsTheme;
setTheme: React.Dispatch<React.SetStateAction<AssetsTheme>>;
lastUpdate: string;
loading: boolean;
selectedSubject: string | null;
setSelectedSubject: React.Dispatch<React.SetStateAction<string | null>>;
subjects: SubjectOption[];
subjectDropdownOpen: boolean;
setSubjectDropdownOpen: React.Dispatch<React.SetStateAction<boolean>>;
subjectSearch: string;
setSubjectSearch: React.Dispatch<React.SetStateAction<string>>;
subjectDropdownRef: React.RefObject<HTMLDivElement | null>;
}
// --- Constants ---
const TABS = [
{ id: 'overview', label: '总览' },
{ id: 'department', label: '按部门' },
{ id: 'region', label: '按区域' },
{ id: 'customer', label: '按客户' },
];
export function AssetsHeader({
activeTab,
setActiveTab,
theme,
setTheme,
lastUpdate,
loading,
selectedSubject,
setSelectedSubject,
subjects,
subjectDropdownOpen,
setSubjectDropdownOpen,
subjectSearch,
setSubjectSearch,
subjectDropdownRef,
}: AssetsHeaderProps) {
return (
<>
{/* Compact Header Bar */}
<div className="sticky top-0 z-40 -mx-3 -mt-3 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80 md:-mx-6 md:-mt-6">
{/* Title row */}
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">羚牛氢能-资产BI</h1>
{/* Right: status + theme */}
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse inline-block" />
<span>{lastUpdate}</span>
</div>
{loading && (
<div className="flex items-center gap-1 text-[10px] text-gray-400">
<Loader2 className="animate-spin" size={10} />
</div>
)}
<div className="hidden sm:flex bg-gray-100 p-0.5 rounded-lg text-[10px]">
{(['soft','minimal','vibrant'] as const).map((t) => (
<button
key={t}
onClick={() => setTheme(t)}
className={`px-2 py-0.5 rounded-md transition-all ${theme === t ? 'bg-white text-blue-600 shadow-sm font-semibold' : 'text-gray-400 hover:text-gray-600'}`}
>
{t === 'soft' ? '柔和' : t === 'minimal' ? '简约' : '经典'}
</button>
))}
</div>
</div>
</div>
{/* 归属公司作用域筛选 (Scope Chip) */}
<div className="flex items-center justify-center px-4 pt-1">
<div className="relative" ref={subjectDropdownRef}>
<button
type="button"
onClick={() => {
setSubjectDropdownOpen((o) => !o);
setSubjectSearch('');
}}
className={`group inline-flex items-center gap-1.5 h-7 pl-2.5 pr-2 rounded-full border text-[11px] font-normal transition-all cursor-pointer ${
selectedSubject
? 'bg-blue-50 border-blue-300 text-blue-700 hover:bg-blue-100'
: 'bg-white border-gray-200 text-gray-500 hover:border-gray-300 hover:text-gray-700'
}`}
title={selectedSubject || '全部公司'}
>
<Filter size={11} className={selectedSubject ? 'text-blue-500' : 'text-gray-400'} />
<span className="max-w-[180px] truncate">
所属公司:{selectedSubject || '全部公司'}
</span>
{selectedSubject ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
setSelectedSubject(null);
}}
className="ml-0.5 w-3.5 h-3.5 inline-flex items-center justify-center rounded-full text-blue-500 hover:bg-blue-200 hover:text-blue-700 cursor-pointer"
aria-label="清除归属公司筛选"
>
×
</span>
) : (
<ChevronDown size={11} className="text-gray-400" />
)}
</button>
{subjectDropdownOpen && (
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1.5 w-[320px] max-h-[380px] bg-white border border-gray-200 rounded-lg shadow-lg z-50 flex flex-col">
<div className="p-2 border-b border-gray-100">
<div className="relative">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
<input
autoFocus
value={subjectSearch}
onChange={(e) => setSubjectSearch(e.target.value)}
placeholder="搜索公司名"
className="w-full h-7 pl-6 pr-2 text-[11px] bg-gray-50 border border-gray-100 rounded focus:outline-none focus:border-blue-300 focus:bg-white"
/>
</div>
</div>
<div className="overflow-y-auto flex-1 py-1">
<button
type="button"
onClick={() => {
setSelectedSubject(null);
setSubjectDropdownOpen(false);
}}
className={`w-full flex items-center justify-between px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
!selectedSubject ? 'text-blue-600 font-medium' : 'text-gray-700'
}`}
>
<span className="flex items-center gap-1.5">
<span className={`w-1.5 h-1.5 rounded-full ${!selectedSubject ? 'bg-blue-500' : 'bg-gray-300'}`} />
全部公司
</span>
<span className="text-[10px] text-gray-400">
{subjects.reduce((s, x) => s + x.total, 0)} 台
</span>
</button>
<div className="my-1 mx-3 border-t border-gray-100" />
{subjects
.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase()))
.map((s) => {
const active = selectedSubject === s.name;
return (
<button
key={s.name}
type="button"
onClick={() => {
setSelectedSubject(s.name);
setSubjectDropdownOpen(false);
}}
className={`w-full flex items-center justify-between gap-2 px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
active ? 'text-blue-600 font-medium bg-blue-50/40' : 'text-gray-700'
}`}
title={s.name}
>
<span className="flex items-center gap-1.5 min-w-0">
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${active ? 'bg-blue-500' : 'bg-gray-300'}`} />
<span className="truncate">{s.name}</span>
</span>
<span className="text-[10px] text-gray-400 flex-shrink-0 tabular-nums">
{s.historicalOnly ? '历史主体' : <>{s.total} 台
<span className="mx-1 text-gray-200">·</span>
<span className="text-green-500">运营 {s.operating}</span></>}
</span>
</button>
);
})}
{subjects.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())).length === 0 && (
<div className="px-3 py-6 text-center text-[11px] text-gray-400">未找到匹配公司</div>
)}
</div>
</div>
)}
</div>
</div>
{/* Tab row */}
<div className="flex items-center justify-center gap-1 px-4 pb-0 overflow-x-auto no-scrollbar">
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`relative px-4 py-2 text-[13px] font-normal transition-all whitespace-nowrap ${
activeTab === tab.id
? 'text-blue-600 font-medium'
: 'text-gray-400 hover:text-gray-500'
}`}
>
{tab.label}
{activeTab === tab.id && (
<motion.div
layoutId="activeTab"
className="absolute bottom-0 left-2 right-2 h-[1.5px] bg-blue-600 rounded-full"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
)}
</button>
))}
</div>
{/* Status row */}
<div className="flex flex-wrap items-center justify-center gap-x-1 gap-y-0.5 px-3 py-1.5 text-[10px] text-gray-400">
<div className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
数据更新时间:{lastUpdate.slice(0, 16)}
</div>
<span aria-hidden="true">|</span>
<span className="whitespace-nowrap">运维台账每日统计口径:当日17:00</span>
</div>
</div>
</>
);
}
@@ -1,189 +0,0 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { ArrowLeft, X } from 'lucide-react';
import type { FlowDetailItem, FlowType } from '../api';
import type { FlowSelection } from '../model';
import { FLOW_META } from './FlowDetailModal';
import { orderDimensionGroups, dimensionRowSpans, dimensionParentTotals, dimensionChildCounts, dimensionCountUnit, visibleDimensionRowSpans } from '../dimension-layout';
import { useDialogFocus } from './useDialogFocus';
import { DimensionPicker, type DimensionPickerOption } from './analysis/DimensionPicker';
import { ProgressiveRowsStatus } from './analysis/ProgressiveRowsStatus';
import { useProgressiveRows } from './analysis/useProgressiveRows';
import { AnalysisExportButton } from './analysis/AnalysisExportButton';
import { downloadAnalysisWorkbook, statisticsExportRows } from '../analysis-export';
import './analysis/asset-analysis.css';
import './flow-analysis-modal.css';
import './analysis/mobile-analysis.css';
import { MobileAnalysisControls } from './analysis/MobileAnalysisControls';
import { MobileGroupedTable } from './analysis/MobileGroupedTable';
import { useMobileLayout } from '../useMobileLayout';
interface FlowAnalysisModalProps {
selectedFlow: FlowSelection | null;
selectedFlowDetails: FlowDetailItem[];
setSelectedFlow: Dispatch<SetStateAction<FlowSelection | null>>;
}
type Dimension = 'date' | 'department' | 'manager' | 'type';
const DIMENSIONS: { key: Dimension; label: string }[] = [
{ key: 'date', label: '日期' }, { key: 'department', label: '部门' },
{ key: 'manager', label: '业务负责人' }, { key: 'type', label: '业务类型' },
];
const DIMENSION_LABELS: Record<Dimension, string> = { date: '日期', department: '部门', manager: '业务负责人', type: '业务类型' };
const EMPTY_FLOW_ROWS: FlowDetailItem[] = [];
const countLabel = (count: number) => count.toLocaleString('zh-CN');
const dimensionValue = (row: FlowDetailItem, dimension: Dimension) => row[dimension].trim();
const dimensionLabel = (dimension: Dimension, value: string) => dimension === 'type'
? FLOW_META[value as FlowType]?.label || value
: value || `未填写${DIMENSION_LABELS[dimension]}`;
interface FlowGroup { key: string; values: string[]; rows: FlowDetailItem[] }
/** Each business event belongs to exactly one actual type; replacements also belong to the delivery scope. */
function matchesType(row: FlowDetailItem, type: FlowType): boolean {
return row.type === type || (type === 'delivered' && row.type === 'replaced');
}
function groupRows(rows: FlowDetailItem[], dimensions: Dimension[]): FlowGroup[] {
const groups = new Map<string, FlowGroup>();
for (const row of rows) {
const values = dimensions.map(dimension => dimensionValue(row, dimension));
const key = JSON.stringify(values);
const group = groups.get(key) ?? { key, values, rows: [] };
group.rows.push(row);
groups.set(key, group);
}
const dateIndex = dimensions.indexOf('date');
return Array.from(groups.values()).sort((a, b) => (dateIndex >= 0 ? b.values[dateIndex].localeCompare(a.values[dateIndex]) : 0) || b.rows.length - a.rows.length || a.key.localeCompare(b.key, 'zh-CN'));
}
export function FlowAnalysisModal(props: FlowAnalysisModalProps) {
if (!props.selectedFlow) return null;
return <FlowAnalysisContent key={JSON.stringify(props.selectedFlow)} {...props} selectedFlow={props.selectedFlow} />;
}
function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlow }: Omit<FlowAnalysisModalProps, 'selectedFlow'> & { selectedFlow: FlowSelection }) {
const mobile = useMobileLayout();
const [dimensions, setDimensions] = useState<Dimension[]>(['date']);
const [mergeParents, setMergeParents] = useState(true);
const mergeActive = mergeParents && dimensions.length > 1;
const [filters, setFilters] = useState<Partial<Record<Dimension, string[]>>>({});
const [detailKey, setDetailKey] = useState<string | null>(null);
const dialog = useRef<HTMLDivElement>(null);
const scrollRoot = useRef<HTMLDivElement>(null);
const close = useCallback(() => setSelectedFlow(null), [setSelectedFlow]);
useDialogFocus(true, dialog, close);
const rows = useMemo(() => selectedFlowDetails.filter(row => matchesType(row, selectedFlow.type)
&& ('date' in selectedFlow ? row.date === selectedFlow.date : row.date >= selectedFlow.start && row.date <= selectedFlow.end))
.sort((a, b) => b.date.localeCompare(a.date) || (b.eventTime || '').localeCompare(a.eventTime || '')), [selectedFlowDetails, selectedFlow]);
const filterOptions = useMemo(() => Object.fromEntries(DIMENSIONS.map(({ key }) => {
const eligible = rows.filter(row => DIMENSIONS.every(({ key: other }) => other === key || !filters[other]?.length || filters[other]!.includes(dimensionValue(row, other))));
const counts = new Map<string, number>();
for (const row of eligible) { const value = dimensionValue(row, key); counts.set(value, (counts.get(value) || 0) + 1); }
for (const value of filters[key] || []) if (!counts.has(value)) counts.set(value, 0);
return [key, [...counts].map(([value, count]) => ({ value, label: dimensionLabel(key, value), count })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN'))];
})) as Record<Dimension, DimensionPickerOption[]>, [rows, filters]);
const filteredRows = useMemo(() => rows.filter(row => DIMENSIONS.every(({ key }) => !filters[key]?.length || filters[key]!.includes(dimensionValue(row, key)))), [rows, filters]);
const groups = useMemo(() => {
const flat = groupRows(filteredRows, dimensions);
return mergeActive ? orderDimensionGroups(flat, dimensions.length, group => group.values, group => group.rows.length, dimensions.includes('date') ? 'label' : 'count-desc',
(a, b, index) => dimensions[index] === 'date' ? b.values[index].localeCompare(a.values[index]) : a.values[index].localeCompare(b.values[index], 'zh-CN')) : flat;
}, [filteredRows, dimensions, mergeActive]);
const detailGroup = useMemo(() => detailKey === null ? null : groups.find(group => group.key === detailKey), [groups, detailKey]);
const detailRows = detailGroup?.rows ?? EMPTY_FLOW_ROWS;
const showDetails = detailKey !== null;
const overviewScroll = useRef(0);
const openDetails = (key: string) => { overviewScroll.current = scrollRoot.current?.scrollTop || 0; setDetailKey(key); };
useLayoutEffect(() => { if (scrollRoot.current) scrollRoot.current.scrollTop = showDetails ? 0 : overviewScroll.current; }, [showDetails]);
const detailLabel = detailGroup && dimensions.length ? detailGroup.values.map((value, i) => `${DIMENSION_LABELS[dimensions[i]]}:${dimensionLabel(dimensions[i], value)}`).join(' · ') : '当前筛选范围';
const selectedRows = showDetails ? detailRows : filteredRows;
const { replacedCount, departmentCount } = useMemo(() => ({
replacedCount: selectedRows.reduce((sum, row) => sum + Number(row.type === 'replaced'), 0),
departmentCount: new Set(selectedRows.map(row => dimensionValue(row, 'department'))).size,
}), [selectedRows]);
const rangeLabel = 'date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`;
const parentTotals = useMemo(() => mergeActive ? dimensionParentTotals(groups, dimensions.length, group => group.values, group => group.rows.length) : [], [groups, dimensions.length, mergeActive]);
const childCounts = useMemo(() => mergeActive ? dimensionChildCounts(groups, dimensions.length, group => group.values) : [], [groups, dimensions.length, mergeActive]);
const groupProgress = useProgressiveRows({ total: groups.length, rootRef: scrollRoot, resetKey: groups, enabled: !showDetails, preserveOnDisable: true });
const detailProgress = useProgressiveRows({ total: detailRows.length, rootRef: scrollRoot, resetKey: detailRows, enabled: showDetails });
const shownGroups = useMemo(() => groups.slice(0, groupProgress.visibleCount), [groups, groupProgress.visibleCount]);
const completeSpans = useMemo(() => mergeActive ? dimensionRowSpans(groups, dimensions.length, group => group.values) : [], [groups, dimensions.length, mergeActive]);
const groupSpans = useMemo(() => visibleDimensionRowSpans(completeSpans, groupProgress.visibleCount), [completeSpans, groupProgress.visibleCount]);
const shownDetails = useMemo(() => detailRows.slice(0, detailProgress.visibleCount), [detailRows, detailProgress.visibleCount]);
const activeFilters = DIMENSIONS.filter(({ key }) => filters[key]?.length);
const resetResult = () => { setDetailKey(null); };
const toggleDimension = (dimension: Dimension) => {
setDimensions(current => current.includes(dimension) ? current.filter(value => value !== dimension) : [...current, dimension]);
resetResult();
};
const changeFilter = (dimension: Dimension, value: string[] | undefined) => {
setFilters(current => { const next = { ...current }; if (value === undefined) delete next[dimension]; else next[dimension] = value; return next; });
resetResult();
};
const exportWorkbook = () => {
const statistics = statisticsExportRows(dimensions.map(d => DIMENSION_LABELS[d]), (detailGroup ? [detailGroup] : groups).map(group => ({ values: group.values.map((v, i) => dimensionLabel(dimensions[i], v)), count: group.rows.length })), selectedRows.length, '车次');
const details = selectedRows.map(item => ({ 业务日期: item.date, 类型: item.typeLabel, 车牌: item.plateNumber,
运维签章时间: item.eventTime, 建单时间: item.submitTime, 部门: item.department, 业务负责人: item.manager,
客户: item.customerName, 合同: item.contractNo, 项目名称: item.projectName }));
return downloadAnalysisWorkbook(statistics, details, `${FLOW_META[selectedFlow.type].label}-${rangeLabel}-${showDetails ? detailLabel : '当前筛选范围'}`);
};
return <div className="bi-flow-modal-backdrop" onClick={event => { if (event.target === event.currentTarget) close(); }}>
<div ref={dialog} role="dialog" aria-modal="true" aria-labelledby="bi-flow-modal-title" tabIndex={-1} className="bi-flow-modal" data-testid="asset-flow-analysis-modal">
<header className="bi-flow-modal-header">
<div className="bi-flow-modal-header-row">{mobile && <button className="bi-flow-mobile-back" aria-label={showDetails ? "返回交还车统计" : "返回资产管理"} onClick={() => showDetails ? setDetailKey(null) : close()}><ArrowLeft size={20} /></button>}<div><h3 id="bi-flow-modal-title">{FLOW_META[selectedFlow.type].label}分析</h3><p>{rangeLabel} · 运维签章时间 · 车次</p></div><button type="button" aria-label="关闭交还车分析" onClick={close} className="bi-flow-modal-close"><X size={16} /></button></div>
{!mobile && <p className="bi-flow-modal-definition">交车包含替换交车;业务类型按实际记录拆分,同车多笔业务分别计次。</p>}
</header>
<div ref={scrollRoot} className="bi-flow-modal-body">
{!mobile && (!showDetails ? <div className="bi-flow-modal-controls">
<div className="bi-flow-modal-control-row"><span className="bi-flow-modal-control-label">分析维度</span><div className="adp-row" role="group" aria-label="选择交还车统计维度">
{DIMENSIONS.map(({ key, label }) => <DimensionPicker key={key} label={label} grouped={dimensions.includes(key)} order={dimensions.indexOf(key) + 1} options={filterOptions[key]} selectedValues={filters[key]} onToggleGroup={() => toggleDimension(key)} onApplyFilter={values => changeFilter(key, values)} />)}
<button type="button" className={`bi-flow-modal-chip${dimensions.length === 0 ? ' bi-flow-modal-chip-active' : ''}`} aria-pressed={dimensions.length === 0} onClick={() => { setDimensions([]); resetResult(); }}>仅筛选</button>
{activeFilters.length ? <button type="button" className="bi-flow-modal-clear" onClick={() => { setFilters({}); resetResult(); }}>清空筛选</button> : null}
</div></div>
<p className="bi-flow-modal-control-hint">点击维度名切换分组,点击右侧箭头筛选;同维度多选为“或”,不同维度为“且”。</p>
</div> : <div className="bi-flow-modal-detail-nav"><button type="button" className="bi-flow-modal-back" onClick={() => { setDetailKey(null); }}><ArrowLeft size={13} />返回统计</button><span>{detailLabel}</span></div>)}
<div className="bi-flow-modal-summary">
<div><span>{showDetails ? '当前分组' : '当前筛选'}{FLOW_META[selectedFlow.type].label}</span><strong>{countLabel(selectedRows.length)}<small>车次</small></strong></div>
<div><span>涉及部门</span><strong>{departmentCount}<small>个</small></strong></div>
{selectedFlow.type === 'delivered' ? <div><span>其中:替换交车</span><strong className="bi-flow-modal-purple">{countLabel(replacedCount)}<small>车次</small></strong></div> : <div><span>{showDetails ? '占当前筛选' : '统计分组'}</span><strong>{showDetails ? `${filteredRows.length ? (selectedRows.length / filteredRows.length * 100).toFixed(1) : '0.0'}%` : groups.length}{!showDetails ? <small>组</small> : null}</strong></div>}
</div>
{mobile && !showDetails && (filteredRows.length > 0 || activeFilters.length > 0) && <MobileAnalysisControls available={DIMENSIONS.map(d => ({ ...d, options: filterOptions[d.key] }))} dimensions={dimensions} filters={filters} defaults={['date']} apply={(nextDimensions, nextFilters) => { setDimensions(nextDimensions as Dimension[]); setFilters(nextFilters); resetResult(); }} />}
{mobile && showDetails && <p className="analysis-mobile-detail-scope">{detailLabel}</p>}
<div className="bi-flow-modal-section-heading"><div>{(!mobile || showDetails) && <h4>{showDetails ? '业务明细' : dimensions.length ? '组合统计' : '筛选汇总'}</h4>}<p>{showDetails ? `共 ${countLabel(detailRows.length)} 条业务记录` : mobile ? `共 ${countLabel(groups.length)} 组 · ${countLabel(filteredRows.length)} 车次` : `共 ${countLabel(groups.length)} 组 · 占比以当前筛选的 ${countLabel(filteredRows.length)} 车次为基数`}</p></div>
{!mobile && !showDetails && dimensions.length > 1 ? <label className="bi-flow-modal-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label> : null}
<div className="analysis-export-actions">
{(!mobile || selectedRows.length > 0) && <AnalysisExportButton disabled={!selectedRows.length} onExport={exportWorkbook} />}
</div>
</div>
{!showDetails ? groups.length ? <>
{mobile ? <MobileGroupedTable dimensions={dimensions.map(d => DIMENSION_LABELS[d])} unit="车次" merged={mergeActive} setMerged={setMergeParents} spans={groupSpans} parentTotals={parentTotals}
rows={shownGroups.map(group => ({ key: group.key, label: group.values.map((value, i) => dimensionLabel(dimensions[i], value)).join('、') || '当前筛选范围', values: group.values.map((value, i) => dimensionLabel(dimensions[i], value)), count: group.rows.length, share: group.rows.length / filteredRows.length * 100 }))}
onDetails={index => openDetails(shownGroups[index].key)} /> :
<div className={`bi-flow-modal-table-wrap${mergeActive ? ' is-merged' : ''}`}><table className="bi-flow-modal-table" data-testid="asset-flow-group-table"><thead><tr>{dimensions.length ? dimensions.map(dimension => <th key={dimension}>{DIMENSION_LABELS[dimension]}</th>) : <th>范围</th>}<th className="bi-flow-modal-number">车次</th><th className="bi-flow-modal-share">占比</th><th className="bi-flow-modal-action">操作</th></tr></thead><tbody>
{shownGroups.map((group, rowIndex) => <tr key={group.key} className={mergeActive && groupSpans[rowIndex][0] > 0 ? 'bi-flow-modal-group-start' : undefined}>
{dimensions.length ? group.values.map((value, index) => {
const span = mergeActive ? groupSpans[rowIndex][index] : 1;
return span === 0 ? null : <td key={dimensions[index]} rowSpan={span > 1 ? span : undefined} className={span > 1 ? 'bi-flow-modal-parent-cell' : undefined}>{dimensionLabel(dimensions[index], value)}{mergeActive && index < dimensions.length - 1 && childCounts[rowIndex][index] > 1 && <><small className="bi-flow-modal-parent-total" title="当前筛选范围内该分组的完整合计(全部筛选结果)">合计 <strong>{countLabel(parentTotals[rowIndex][index])}</strong> 车次</small><small className="bi-flow-modal-parent-children" title="当前筛选范围内下一级维度的去重分组数(全部筛选结果)">{DIMENSION_LABELS[dimensions[index + 1]]}:<strong>{countLabel(childCounts[rowIndex][index])}</strong> {dimensionCountUnit(dimensions[index + 1])}</small></>}</td>;
}) : <td>当前筛选范围</td>}
<td className="bi-flow-modal-number"><strong>{countLabel(group.rows.length)}</strong></td><td className="bi-flow-modal-share"><div><span className="bi-flow-modal-track"><span className={`bi-flow-modal-fill bi-flow-modal-fill-${selectedFlow.type}`} style={{ width: `${group.rows.length / filteredRows.length * 100}%` }} /></span><span>{(group.rows.length / filteredRows.length * 100).toFixed(1)}%</span></div></td>
<td className="bi-flow-modal-action"><button type="button" aria-label={`查看${group.values.map((value, i) => dimensionLabel(dimensions[i], value)).join('、') || '当前筛选范围'}明细`} onClick={() => openDetails(group.key)}>查看明细</button></td>
</tr>)}
</tbody></table></div>}
<ProgressiveRowsStatus {...groupProgress} unit="组" />
</> : <div className="bi-flow-modal-empty">{activeFilters.length ? '当前筛选范围暂无交还车数据,可调整筛选。' : '当前时段暂无交还车记录。'}</div>
: detailRows.length ? <>
{mobile ? <div className="analysis-vehicle-cards" data-testid="asset-flow-final-details">{shownDetails.map(item => <article className="analysis-vehicle-card" key={item.id}><header><h4>{item.plateNumber || '未登记车牌'}</h4><span>{FLOW_META[item.type].label}</span></header><p>{item.eventTime || item.date} · {item.department || '未填写部门'}</p><p>{item.customerName || '未填写客户'}</p><details><summary>业务资料</summary><dl>{[['运维签章时间', item.eventTime], ['建单时间', item.submitTime], ['业务负责人', item.manager], ['合同', item.contractNo], ['项目名称', item.projectName]].map(([label,value]) => <div key={label}><dt>{label}</dt><dd>{value || '未提供'}</dd></div>)}</dl></details></article>)}</div> :
<div className="bi-flow-modal-table-wrap" data-testid="asset-flow-final-details"><table className="bi-flow-modal-table bi-flow-modal-detail-table"><thead><tr><th>车牌</th><th>业务类型</th><th>运维签章时间</th><th>建单时间</th><th>部门</th><th>业务负责人</th><th>客户</th><th>合同</th><th>项目名称</th></tr></thead><tbody>{shownDetails.map(item => <tr key={item.id}><td>{item.plateNumber || '未登记车牌'}</td><td>{FLOW_META[item.type].label}</td><td>{item.eventTime || '—'}</td><td>{item.submitTime || '—'}</td><td>{dimensionLabel('department', dimensionValue(item, 'department'))}</td><td>{item.manager || '—'}</td><td>{item.customerName || '—'}</td><td>{item.contractNo || '—'}</td><td>{item.projectName || '—'}</td></tr>)}</tbody></table></div>}
<ProgressiveRowsStatus {...detailProgress} unit="条" />
</> : <div className="bi-flow-modal-empty">当前分组暂无业务明细,请返回统计调整条件。</div>}
</div>
{mobile && <footer className="analysis-footer"><details><summary>统计口径</summary><p>按运维签章时间统计。交车包含替换交车;同车多笔业务分别计次。仅展示当前可查看范围与筛选条件内的记录。</p></details></footer>}
</div>
</div>;
}
@@ -1,149 +0,0 @@
import React from 'react';
import { Download, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { FlowDetailItem, FlowType } from '../api';
import type { FlowSelection } from '../model';
export const FLOW_META: Record<FlowType, { label: string; tone: string; chip: string }> = {
delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' },
returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' },
replaced: { label: '替换交车', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' },
};
interface FlowDetailModalProps {
selectedFlow: FlowSelection | null;
selectedFlowDetails: FlowDetailItem[];
setSelectedFlow: React.Dispatch<React.SetStateAction<FlowSelection | null>>;
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
}
export function FlowDetailModal({
selectedFlow,
selectedFlowDetails,
setSelectedFlow,
exportFlowDetails,
}: FlowDetailModalProps) {
const dateLabel = selectedFlow
? ('date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`)
: '';
return (
<>
{/* Flow Detail Modal */}
<AnimatePresence>
{selectedFlow && (
<div className="fixed inset-0 z-[1000] flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-4">
<motion.div
data-testid="asset-flow-detail-modal"
initial={{ opacity: 0, y: 28, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
className="flex max-h-[88vh] w-full flex-col overflow-hidden rounded-t-3xl bg-white shadow-2xl sm:max-w-5xl sm:rounded-3xl"
>
<div className="border-b border-slate-100 bg-slate-950 px-4 py-4 text-white sm:px-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400">运维签章时间口径 · 单位:车次</div>
<h3 className="mt-1 text-lg font-black">
{dateLabel} · {FLOW_META[selectedFlow.type].label}明细
</h3>
<div className="mt-1 text-[12px] font-bold text-slate-300">
共 {selectedFlowDetails.length} 车次,包含车牌、运维签章时间、建单时间、部门、业务负责人和客户
</div>
</div>
<button
type="button"
aria-label="关闭流转明细"
onClick={() => setSelectedFlow(null)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
>
<X size={18} />
</button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => exportFlowDetails(selectedFlowDetails, `${dateLabel}-${FLOW_META[selectedFlow.type].label}明细`)}
disabled={selectedFlowDetails.length === 0}
className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
>
<Download size={13} />
导出当前明细
</button>
</div>
</div>
<div className="overflow-auto bg-slate-50 p-3 sm:p-4">
<div className="hidden overflow-hidden rounded-2xl border border-slate-100 bg-white lg:block">
<table className="w-full table-fixed text-left">
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
<tr>
<th className="w-28 px-3 py-3">车牌</th>
<th className="w-40 px-3 py-3">运维签章时间</th>
<th className="w-40 px-3 py-3">建单时间</th>
<th className="w-36 px-3 py-3">部门</th>
<th className="w-28 px-3 py-3">业务负责人</th>
<th className="px-3 py-3">客户</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-[12px] font-bold text-slate-700">
{selectedFlowDetails.map((item) => (
<tr key={item.id} className="hover:bg-blue-50/40">
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}{item.type === 'replaced' && <span className="mt-1 block text-[10px] text-violet-600">替换交车</span>}</td>
<td className="px-3 py-3 text-slate-500">{item.eventTime || '-'}</td>
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
<td className="px-3 py-3">{item.department || '-'}</td>
<td className="px-3 py-3">{item.manager || '-'}</td>
<td className="px-3 py-3">{item.customerName || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="space-y-2 lg:hidden">
{selectedFlowDetails.map((item) => (
<div key={item.id} className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-base font-black text-slate-950">{item.plateNumber}</div>
<div className={`mt-1 inline-flex rounded-full border px-2 py-0.5 text-[10px] font-black ${FLOW_META[item.type].chip}`}>
{item.typeLabel}
</div>
</div>
<div className="text-right text-[10px] font-bold text-slate-400">
<div>建单</div>
<div className="mt-0.5 text-[11px] text-blue-600">{item.submitTime?.slice(5, 16) || '-'}</div>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">运维签章时间</div>
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">业务负责人</div>
<div className="mt-1 font-bold text-slate-700">{item.manager || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">部门</div>
<div className="mt-1 font-bold text-slate-700">{item.department || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">客户</div>
<div className="mt-1 line-clamp-2 font-bold text-slate-700">{item.customerName || '-'}</div>
</div>
</div>
</div>
))}
</div>
{selectedFlowDetails.length === 0 && (
<div className="rounded-2xl bg-white px-4 py-10 text-center text-sm font-bold text-slate-400">当前日期暂无明细</div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</>
);
}
@@ -1,67 +0,0 @@
import { AssetSummarySection } from './overview/AssetSummarySection';
import { FlowStatisticsCard } from './overview/FlowStatisticsCard';
import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection';
import { SummaryMetrics } from './overview/SummaryMetrics';
import { AssetHistorySection } from '../history/AssetHistorySection';
import type { OverviewViewProps } from './overview/types';
export function OverviewView(props: OverviewViewProps) {
return (
<>
<SummaryMetrics
summary={props.summary}
operatingRate={props.operatingRate}
inventoryRate={props.inventoryRate}
pendingRate={props.pendingRate}
setVehicleSelection={props.setVehicleSelection}
/>
<AssetHistorySection subject={props.selectedSubject} onSubjects={props.onHistorySubjects} />
<FlowStatisticsCard
flowRange={props.flowRange}
setFlowRange={props.setFlowRange}
flowLoading={props.flowLoading}
flowStats={props.flowStats}
exportFlowDetails={props.exportFlowDetails}
flowDailyExpanded={props.flowDailyExpanded}
setFlowDailyExpanded={props.setFlowDailyExpanded}
setSelectedFlow={props.setSelectedFlow}
/>
<AssetSummarySection
processedData={props.processedData}
theme={props.theme}
expandedAssetTypes={props.expandedAssetTypes}
toggleAssetType={props.toggleAssetType}
expandedModels={props.expandedModels}
toggleModel={props.toggleModel}
setVehicleSelection={props.setVehicleSelection}
/>
<InventoryStatisticsSection
inventoryFilters={props.inventoryFilters}
setInventoryFilters={props.setInventoryFilters}
draftInventoryFilters={props.draftInventoryFilters}
setDraftInventoryFilters={props.setDraftInventoryFilters}
isInventoryFilterOpen={props.isInventoryFilterOpen}
setIsInventoryFilterOpen={props.setIsInventoryFilterOpen}
uniqueInventoryRegions={props.uniqueInventoryRegions}
uniqueInventoryCities={props.uniqueInventoryCities}
uniqueInventoryBrands={props.uniqueInventoryBrands}
uniqueInventoryTypes={props.uniqueInventoryTypes}
uniqueInventoryModelsForType={props.uniqueInventoryModelsForType}
filteredInventoryStats={props.filteredInventoryStats}
inventoryTab={props.inventoryTab}
setInventoryTab={props.setInventoryTab}
inventoryByRegion={props.inventoryByRegion}
expandedInventoryRegions={props.expandedInventoryRegions}
toggleInventoryRegion={props.toggleInventoryRegion}
inventoryByModel={props.inventoryByModel}
expandedInventoryTypes={props.expandedInventoryTypes}
toggleInventoryType={props.toggleInventoryType}
setVehicleSelection={props.setVehicleSelection}
/>
</>
);
}
@@ -1,292 +0,0 @@
import React, { useCallback, useRef } from 'react';
import { AbnormalVehicleDetails, PendingVehicleDetails } from './AbnormalVehicleDetails';
import { useDialogFocus } from './useDialogFocus';
import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import Blur from '../../../components/Blur';
import { SearchSelect } from '../../../components/SearchSelect';
import type { WeeklyDetailItem } from '../api';
import type { ModalVehicleFilters, VehicleModalSelection } from '../model';
import type { VehicleListItem } from '../types';
interface VehicleDetailModalProps {
selection: VehicleModalSelection | null;
setSelection: React.Dispatch<React.SetStateAction<VehicleModalSelection | null>>;
isFilterExpanded: boolean;
setIsFilterExpanded: React.Dispatch<React.SetStateAction<boolean>>;
filters: ModalVehicleFilters;
setFilters: React.Dispatch<React.SetStateAction<ModalVehicleFilters>>;
plates: string[];
models: string[];
brands: string[];
locations: string[];
loading: boolean;
weeklyDetails: WeeklyDetailItem[];
filteredWeeklyDetails: WeeklyDetailItem[];
filteredVehicles: VehicleListItem[];
}
export function VehicleDetailModal({
selection: showPlateNumbers,
setSelection: setShowPlateNumbers,
isFilterExpanded: isModalFilterExpanded,
setIsFilterExpanded: setIsModalFilterExpanded,
filters: modalFilters,
setFilters: setModalFilters,
plates: uniqueModalPlates,
models: uniqueModalModels,
brands: uniqueModalBrands,
locations: uniqueModalLocations,
loading: modalLoading,
weeklyDetails: modalWeeklyDetail,
filteredWeeklyDetails: filteredModalWeeklyDetail,
filteredVehicles: filteredModalVehicles,
}: VehicleDetailModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => setShowPlateNumbers(null), [setShowPlateNumbers]);
useDialogFocus(Boolean(showPlateNumbers), dialogRef, close);
return (
<>
{/* Vehicle Detail Modal */}
<AnimatePresence>
{showPlateNumbers && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<motion.div
ref={dialogRef} role="dialog" aria-modal="true" aria-label={showPlateNumbers.title || "车辆明细"} tabIndex={-1}
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
className={`bg-white rounded-xl shadow-2xl w-full max-w-[95vw] ${showPlateNumbers.source === 'customer' || showPlateNumbers.category === 'Abnormal' ? 'lg:max-w-6xl' : 'lg:max-w-4xl'} overflow-hidden flex flex-col max-h-[85vh] sm:max-h-[90vh] min-h-[40vh]`}
>
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-slate-800 text-white shrink-0">
<div>
<h3 className="font-bold text-base flex items-center gap-2">
<Truck size={18} className="text-blue-400" />
{showPlateNumbers.title || (
(showPlateNumbers.manager ? `${showPlateNumbers.manager} 的${showPlateNumbers.type || ''}车辆` :
showPlateNumbers.customer ? `${showPlateNumbers.customer} 的${showPlateNumbers.type || ''}车辆` :
showPlateNumbers.batch === 'All' ? '全量批次' : `${showPlateNumbers.batch} 批次`) + ' - 运营明细'
)}
</h3>
<p className="text-[10px] opacity-60 mt-0.5">
{showPlateNumbers.scopeLabel || <>{showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} |
{showPlateNumbers.category === 'Pending' ? '待交车' :
showPlateNumbers.category === 'Delivered' ? '本周已交车' :
showPlateNumbers.category === 'Returned' ? '已还车' :
showPlateNumbers.category === 'Replaced' ? '已替换' :
showPlateNumbers.category === 'Abnormal' ? '异动' :
showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` :
showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}</>}
</p>
</div>
<button aria-label="关闭车辆明细" onClick={close} className="hover:bg-white/10 p-2 rounded-full transition-colors">
<PlusCircle className="rotate-45" size={24} />
</button>
</div>
{/* Modal Filters */}
<div className="px-4 py-2 bg-slate-50 border-b border-gray-200 shrink-0">
<div
className="flex justify-between items-center cursor-pointer py-1"
onClick={() => setIsModalFilterExpanded(!isModalFilterExpanded)}
>
<div className="flex items-center gap-2 text-slate-600">
<Filter size={14} />
<span className="text-xs font-bold">筛选条件</span>
</div>
<div className="flex items-center gap-3">
{/* Quick Search always visible when collapsed */}
{!isModalFilterExpanded && (
<div className="w-40 sm:w-64" onClick={(e) => e.stopPropagation()}>
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="快速搜索车牌..." className="text-[11px] py-1 px-2" />
</div>
)}
<motion.div
animate={{ rotate: isModalFilterExpanded ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown size={16} className="text-slate-400" />
</motion.div>
</div>
</div>
<AnimatePresence>
{isModalFilterExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 py-3 border-t border-gray-100 mt-1">
<div className="space-y-1">
<label className="block text-[10px] text-gray-500 font-medium">车牌号码</label>
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="全部车牌" className="text-[11px] py-1.5 px-2" />
</div>
<div className="space-y-1">
<label className="block text-[10px] text-gray-500 font-medium">车型型号</label>
<select value={modalFilters.model} onChange={(e) => setModalFilters({...modalFilters, model: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部车型</option>
{uniqueModalModels.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
<div className="space-y-1">
<label className="block text-[10px] text-gray-500 font-medium">品牌名称</label>
<select value={modalFilters.brand} onChange={(e) => setModalFilters({...modalFilters, brand: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部品牌</option>
{uniqueModalBrands.map(b => <option key={b} value={b}>{b}</option>)}
</select>
</div>
<div className="space-y-1">
<label className="block text-[10px] text-gray-500 font-medium">运营所在地</label>
<select value={modalFilters.location} onChange={(e) => setModalFilters({...modalFilters, location: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部地区</option>
{uniqueModalLocations.map(l => <option key={l} value={l}>{l}</option>)}
</select>
</div>
</div>
<div className="flex justify-end pb-2">
<button
onClick={() => setModalFilters({ plateNumber: '', model: '', brand: '', location: '' })}
className="text-[10px] text-blue-500 hover:text-blue-600 font-medium"
>
重置筛选
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="flex-1 overflow-auto p-0 sm:p-4 bg-gray-50 min-h-0 overscroll-contain">
{modalLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="animate-spin text-blue-500" size={32} />
</div>
) : showPlateNumbers.category === 'Abnormal' ? (
<AbnormalVehicleDetails vehicles={filteredModalVehicles} />
) : showPlateNumbers.category === 'Pending' ? (
<PendingVehicleDetails vehicles={filteredModalVehicles} />
) : modalWeeklyDetail.length > 0 ? (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden w-full">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-700 text-white text-[10px] uppercase tracking-wider sticky top-0 z-20 shadow-sm">
<th className="p-2 font-semibold border-r border-slate-600 text-center">车牌</th>
<th className="p-2 font-semibold border-r border-slate-600 text-center">客户名称</th>
<th className="p-2 font-semibold text-center">日期</th>
</tr>
</thead>
<tbody className="text-[11px]">
{filteredModalWeeklyDetail.map((v, i) => (
<tr key={`${v.truck_id}-${i}`} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors ${i % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center"><Blur>{v.plate_number}</Blur></td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{v.customer_name || '—'}</Blur></td>
<td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm w-full">
<table className="min-w-full w-max text-left border-collapse">
<thead className="sticky top-0 z-20 shadow-sm">
<tr className="bg-slate-700 text-white text-[10px] uppercase tracking-wider whitespace-nowrap">
{showPlateNumbers.source === 'customer' ? (
<>
<th className="p-2 font-semibold border-r border-slate-600 w-24">业务部门</th>
<th className="p-2 font-semibold border-r border-slate-600 w-24">业务负责人</th>
<th className="p-2 font-semibold border-r border-slate-600 w-16">品牌</th>
<th className="p-2 font-semibold border-r border-slate-600 w-16">车型</th>
<th className="p-2 font-semibold border-r border-slate-600 w-48">资产归属</th>
<th className="p-2 font-semibold border-r border-slate-600 w-48">客户名称</th>
<th className="p-2 font-semibold border-r border-slate-600 w-24">车牌</th>
<th className="p-2 font-semibold border-r border-slate-600 w-16 text-center">状态</th>
<th className="p-2 font-semibold border-r border-slate-600 w-24 text-center">提车时间</th>
<th className="p-2 font-semibold border-r border-slate-600 w-24 text-center">合同到期时间</th>
<th className="p-2 font-semibold border-r border-slate-600 w-24">运营区域</th>
<th className="p-2 font-semibold border-r border-slate-600 w-20 text-center">离到期</th>
<th className="p-2 font-semibold w-48">签约公司</th>
</>
) : (
<>
<th className="p-2 font-semibold border-r border-slate-600 text-center">车牌</th>
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
<th className="p-2 font-semibold border-r border-slate-600 text-center">客户名称</th>
)}
<th className="p-2 font-semibold border-r border-slate-600 text-center">品牌</th>
<th className="p-2 font-semibold border-r border-slate-600 text-center">车型</th>
<th className="p-2 font-semibold text-center">所在地</th>
</>
)}
</tr>
</thead>
<tbody className="text-[11px]">
{filteredModalVehicles.map((v, idx) => (
<tr key={v.id} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors whitespace-nowrap ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
{showPlateNumbers.source === 'customer' ? (
<>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.departmentName || '—'}</td>
<td className="p-2 border-r border-gray-100 font-medium text-gray-700"><Blur>{v.customerManager || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.brandLabel || '—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.type}</td>
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]"><Blur>{v.subjectOrg || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 font-bold text-gray-800"><Blur>{v.customerName || '—'}</Blur></td>
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 text-center">
<span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${
v.status === 'Operating' ? 'bg-green-100 text-green-700' :
v.status === 'Inventory' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'
}`}>
{v.status === 'Operating' ? '在租' : v.status === 'Inventory' ? '库存' : '异常'}
</span>
</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.location === '其他' ? '对接中' : v.location}</td>
<td className="p-2 border-r border-gray-100 text-center font-bold text-orange-600">{'—'}</td>
<td className="p-2 text-gray-500 text-[10px]"><Blur>{v.orgName || '—'}</Blur></td>
</>
) : (
<>
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center"><Blur>{v.customerName || '—'}</Blur></td>
)}
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td>
<td className="p-2 text-gray-600 text-center">{v.location === '其他' ? '对接中' : v.location}</td>
</>
)}
</tr>
))}
{filteredModalVehicles.length === 0 && (
<tr>
<td colSpan={showPlateNumbers.source === 'customer' ? 13 : ((showPlateNumbers.source === 'asset' || showPlateNumbers.category === 'Inventory') ? 4 : 5)} className="p-8 text-center text-gray-400 italic">
暂无符合条件的车辆数据
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
<div className="p-4 bg-white border-t border-gray-100 flex justify-between items-center shrink-0">
<div className="text-xs text-gray-500">
共计 <span className="font-bold text-blue-600">{filteredModalWeeklyDetail.length > 0 ? filteredModalWeeklyDetail.length : filteredModalVehicles.length}</span> 台车辆
</div>
<button
onClick={() => setShowPlateNumbers(null)}
className="px-6 py-2 bg-slate-800 text-white rounded-lg text-xs font-bold hover:bg-slate-700 transition-colors shadow-sm"
>
确认并关闭
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More