Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a237df8b75 | ||
|
|
d8871964a4 | ||
|
|
1e57586a35 | ||
|
|
8afe13c9a3 | ||
|
|
187a72d6b0 | ||
|
|
3657661fa7 | ||
|
|
dbe0421bfe | ||
|
|
1674f052d0 | ||
|
|
f107e96d3c | ||
|
|
4dec824285 | ||
|
|
39ecde55d4 | ||
|
|
00c09d7452 | ||
|
|
a065e9fa1d | ||
|
|
5751d1af15 | ||
|
|
24984fe033 | ||
|
|
80f8d2d92e | ||
|
|
9f89e9f61c | ||
|
|
7dd7e67602 | ||
|
|
40ef05d49b | ||
|
|
807453b5fd | ||
|
|
48cf429468 | ||
|
|
f1b3f8d3da | ||
|
|
6de77adb47 | ||
|
|
5be6240bbf | ||
|
|
fd514177c6 | ||
|
|
d3fcec7dce | ||
|
|
d3a56b2cc1 | ||
|
|
dc0923b566 | ||
|
|
b5a48b6417 | ||
|
|
d20bc71efe | ||
|
|
7e9760f5db | ||
|
|
0a32392215 | ||
|
|
3d4749f28f | ||
|
|
b33e965ec3 | ||
|
|
2e5d60a239 | ||
|
|
0c4a9bcd05 | ||
|
|
822aabf2ca | ||
|
|
b0cba26fbb | ||
|
|
9b0980e349 | ||
|
|
96b64a7872 | ||
|
|
61406497cc | ||
|
|
b15951f08b | ||
|
|
8fb574599b | ||
|
|
746edeb72c | ||
|
|
9c13ab6d3b | ||
|
|
1e4afacac7 | ||
|
|
0c26b973ef | ||
|
|
6ea1b3d5dd | ||
|
|
c49a02e3d9 | ||
|
|
d7538ed6eb | ||
|
|
6822eafd09 | ||
|
|
7c8e886663 | ||
|
|
c4e2c03348 | ||
|
|
f82867b159 | ||
|
|
25e53b8f66 | ||
|
|
9750d35ba4 | ||
|
|
7802e5b0eb | ||
|
|
e4c8195ede | ||
|
|
5196db5df7 | ||
|
|
523f7b312d | ||
|
|
c199f031c5 | ||
|
|
795fb207cb | ||
|
|
954d626afc | ||
|
|
507bc90ed2 | ||
|
|
5d14a28b0f | ||
|
|
b28ca49491 | ||
|
|
9a9c9d08e1 | ||
|
|
680c7e0662 | ||
|
|
7556dbcebc | ||
|
|
3daa9808ce | ||
|
|
89dca5c5a4 | ||
|
|
7d792933dd | ||
|
|
0dc7e89c1d | ||
|
|
4430fb75f1 | ||
|
|
df259fc12b | ||
|
|
df6b8201e8 | ||
|
|
a177781fe7 | ||
|
|
637a72c1e9 | ||
|
|
98efde3f75 | ||
|
|
6c91a6694a | ||
|
|
5eb38a4b05 | ||
|
|
2518d9ee54 | ||
|
|
62efef0ab9 | ||
|
|
37a9f303ec | ||
|
|
06fe75b4c7 | ||
|
|
d610e4b841 | ||
|
|
7fce6b8474 | ||
|
|
bd0627d00f |
@@ -1,9 +1,15 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.git
|
||||
.codex
|
||||
outputs
|
||||
reports
|
||||
data/geo
|
||||
data/vehicle-heatmap/source.csv
|
||||
|
||||
# Local machine metadata and runtime logs
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# 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
|
||||
@@ -1,4 +1,13 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.worktrees
|
||||
|
||||
# Local machine metadata and runtime logs
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
# Permission-preview audit trail (operator/target identifiers)
|
||||
logs/permission-preview.jsonl
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# 故障管理 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:后续如提供统一品牌资产,可单独统一应用级导航标识;当前保持现有产品导航。
|
||||
+31
-32
@@ -2,43 +2,42 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
ln-bi:
|
||||
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
|
||||
image: harbor.lnh2e.com/lingniu-v1/ln-bi:feature-asset-statistics-1.1.18
|
||||
network_mode: host
|
||||
environment:
|
||||
DB_HOST: "${DB_HOST:?Set DB_HOST in the deployment environment}"
|
||||
DB_PORT: "${DB_PORT:-3306}"
|
||||
DB_USER: "${DB_USER:?Set DB_USER in the deployment environment}"
|
||||
DB_PASSWORD: "${DB_PASSWORD:?Set DB_PASSWORD in the deployment environment}"
|
||||
DB_NAME: "${DB_NAME:?Set DB_NAME in the deployment environment}"
|
||||
HYDROGEN_DB_HOST: "${HYDROGEN_DB_HOST:?Set HYDROGEN_DB_HOST in the deployment environment}"
|
||||
HYDROGEN_DB_PORT: "${HYDROGEN_DB_PORT:-3306}"
|
||||
HYDROGEN_DB_USER: "${HYDROGEN_DB_USER:?Set HYDROGEN_DB_USER in the deployment environment}"
|
||||
HYDROGEN_DB_PASSWORD: "${HYDROGEN_DB_PASSWORD:?Set HYDROGEN_DB_PASSWORD in the deployment environment}"
|
||||
HYDROGEN_DB_NAME: "${HYDROGEN_DB_NAME:?Set HYDROGEN_DB_NAME in the deployment environment}"
|
||||
MILEAGE_DB_HOST: "${MILEAGE_DB_HOST:?Set MILEAGE_DB_HOST in the deployment environment}"
|
||||
MILEAGE_DB_PORT: "${MILEAGE_DB_PORT:-3306}"
|
||||
MILEAGE_DB_USER: "${MILEAGE_DB_USER:?Set MILEAGE_DB_USER in the deployment environment}"
|
||||
MILEAGE_DB_PASSWORD: "${MILEAGE_DB_PASSWORD:?Set MILEAGE_DB_PASSWORD in the deployment environment}"
|
||||
MILEAGE_DB_NAME: "${MILEAGE_DB_NAME:?Set MILEAGE_DB_NAME in the deployment environment}"
|
||||
# Runtime only: set this variable in Portainer Stack environment variables.
|
||||
DEEPSEEK_API_KEY: "${DEEPSEEK_API_KEY:-}"
|
||||
# Enabled when a Key is configured; set 0 to use the local planner only.
|
||||
ASSET_AI_EXTERNAL_ENABLED: "${ASSET_AI_EXTERNAL_ENABLED:-1}"
|
||||
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
|
||||
DB_PORT: "3306"
|
||||
DB_USER: "oneos_db_prod"
|
||||
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
|
||||
DB_NAME: "ln_asset_management"
|
||||
HYDROGEN_DB_HOST: "47.99.185.173"
|
||||
HYDROGEN_DB_PORT: "3306"
|
||||
HYDROGEN_DB_USER: "root"
|
||||
HYDROGEN_DB_PASSWORD: "lnMysql."
|
||||
HYDROGEN_DB_NAME: "ln_asset_management"
|
||||
MILEAGE_DB_HOST: "101.133.130.65"
|
||||
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_KEY: "${ONEOS_MILEAGE_API_KEY:?Set ONEOS_MILEAGE_API_KEY in the deployment environment}"
|
||||
ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY}"
|
||||
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
|
||||
SERVER_PORT: "${SERVER_PORT:-8111}"
|
||||
EXTERNAL_API_BASE: "${EXTERNAL_API_BASE:-https://lnh2e.com}"
|
||||
JWT_SECRET: "${JWT_SECRET:?Set JWT_SECRET in the deployment environment}"
|
||||
AMAP_WEB_KEY: "${AMAP_WEB_KEY:?Set AMAP_WEB_KEY in the deployment environment}"
|
||||
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE:?Set AMAP_SECURITY_JS_CODE in the deployment environment}"
|
||||
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}"
|
||||
SERVER_PORT: "8111"
|
||||
EXTERNAL_API_BASE: "https://lnh2e.com"
|
||||
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
||||
AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
|
||||
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
|
||||
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
|
||||
HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}"
|
||||
HEATMAP_DB_USER: "${HEATMAP_DB_USER:?Set HEATMAP_DB_USER in the deployment environment}"
|
||||
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD:?Set HEATMAP_DB_PASSWORD in the deployment environment}"
|
||||
HEATMAP_DB_USER: "${HEATMAP_DB_USER}"
|
||||
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
|
||||
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
|
||||
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
|
||||
deploy:
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,67 @@
|
||||
# 老板视角问数验收
|
||||
|
||||
## 来源与判定原则
|
||||
|
||||
根据用户提供的《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 项均通过。
|
||||
|
||||
本次改动与验证属于该项目的业务语义和展示优化,未形成跨项目知识候选,未修改全局开发规则。
|
||||
@@ -0,0 +1,30 @@
|
||||
# 资产 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 接口使用合成车辆做单次复合规划验证,未使用生产车辆数据。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 资产 AI 问数 Demo
|
||||
|
||||
入口:本地运行 `npm run dev` 后打开 `http://localhost:3000/asset#assets`,点击「AI 问数」。
|
||||
|
||||
## 已实现
|
||||
|
||||
- 采用 Beautiful UI Thinking / Prompt Bar / Chat 的视觉语言;Thinking 步骤改为后端 SSE 实际查询进度,保留 MIT 许可于 `docs/licenses/beautiful-ui.txt`。
|
||||
- DeepSeek Flash 将自然语言和上一轮查询计划转成受校验的结构化查询;后端复用资产仓储、权限过滤和业务分类;第二次模型调用只解释汇总数据。
|
||||
- 查询当前资产数量、库存、可运营/待运营、运营率、异动和待交车;按区域、城市、车型、型号、部门和运营状态分组。
|
||||
- 查询最多 93 天的交车、还车、替换趋势。交车包含替换;流转按业务车次计数,不以车辆去重。
|
||||
- 图表/统计表/明细切换、分组下钻、明细搜索及分页、CSV 导出、连续追问、取消、错误重试、新对话、深浅主题及移动端。
|
||||
- 浏览器 SpeechRecognition 语音输入:用户点击麦克风启动,转写后可编辑再发送。不支持或拒绝授权时提示文字输入。
|
||||
|
||||
## 本机配置
|
||||
|
||||
`DEEPSEEK_API_KEY` 仅位于 Git 忽略的 `.env`,后端读取。客户端构建不包含密钥。无需配置 `VITE_` 密钥。更换临时密钥后重启后端。
|
||||
|
||||
现有开发环境本身已开启本地免登录;本次未改变该开关。问答路由挂在应用的认证中间件之后,未认证直接调用被拒绝。部署环境必须沿用正常认证配置。
|
||||
|
||||
模型接收问题、维度目录(区域/城市/车型/型号)、已校验的上一轮计划和汇总统计;不会主动发送车牌、VIN、客户或车辆明细。用户在提问中自行输入的文字会发送给模型。业务数据没有伪造或演示替换。
|
||||
|
||||
Portainer / Docker 部署见 [DeepSeek 环境变量注入](./portainer-deepseek.md)。
|
||||
|
||||
## 口径和边界
|
||||
|
||||
- 当前公司从资产页面继承,变更公司请关闭面板后选择公司;服务端执行账号权限与公司范围过滤。
|
||||
- 资产来源为当前台账与现有服务端缓存,结果标记查询时间,不声称是历史库存快照。
|
||||
- 分组与明细使用同一次查询返回的数据;区域按现有省市映射。
|
||||
- 运营率 = 当前筛选车辆中的运营车辆 / 当前筛选车辆数。
|
||||
- 历史库存、金额、成本、预测等暂不支持,助手应说明能力范围或追问,不执行模型生成的 SQL。
|
||||
- 会话只保存在打开的面板内,刷新/关闭不会保存。语音依赖浏览器和其在线识别服务,尚未接专用 ASR。
|
||||
|
||||
## 验证
|
||||
|
||||
- TypeScript 检查、Vite 生产构建;构建仍有现有 ECharts/AG Grid 大块提示。
|
||||
- 自动验证个人权限与所属公司范围、库存子集计数、无认证拒绝、非法查询/日期拒绝,以及模型请求不包含车辆明细。
|
||||
- 桌面及手机浏览器实际查询:库存 291 台 → 追问可运营库存 181 台;华东库存 198 条明细与分页对应。
|
||||
- 2026-09-01 至 2026-09-23 流转:交车 63 车次、还车 69 车次,其中替换 19 车次;2026-09-22 下钻及 CSV 均为 17 条记录。
|
||||
- 深浅模式、无横向页面溢出、空明细搜索和结果展示切换已验证。真实麦克风识别未自动录制测试。
|
||||
|
||||
开发大脑回执:DB-C-003,知识索引版本 1 校验通过,未发现冲突;本次是产品 Demo 实现,未形成新的跨任务复盘模式,无知识写入或 GitHub 同步。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 资产 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 台并保留类型和预计完成时间;帮助后可继续提问,旧回答可折叠。手机无页面横向溢出,控制台无错误和警告。
|
||||
- 未进行生产部署、生产数据库需求表建表验证或长期真实用户效果评估。本轮保留工作区修改,未提交、推送或发布。
|
||||
|
||||
开发复盘:问题根因是封闭词表先行拦截、模型只负责展示、会话没有传递实际问答及结果上下文。改为模型完整规划并用真实中文多轮样本验证。本轮证据属于项目改造,不新增全局开发知识候选。
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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 识别长期未完成,不能将其算作成功。
|
||||
|
||||
模拟用户提问同样记录,但不新增需求缺口反馈,也不改变原有只读业务权限。没有新增公开查询接口;数据库查看权限沿用现有管理制度。新表不能补回此前仅在内存中保存的历史问题。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 资产 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)。浏览器与真实模型探针均使用合成车辆,不向业务库写测试记录。
|
||||
@@ -0,0 +1,60 @@
|
||||
# 单车状态问答优化与验收计划
|
||||
|
||||
## 问题与目标
|
||||
|
||||
用户询问“沪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;本轮证据为项目内修复与本地测试,尚不足以形成跨任务可复用知识记录,无可沉淀候选,未触发知识仓写入。
|
||||
@@ -0,0 +1,101 @@
|
||||
# 故障管理
|
||||
|
||||
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 工作表:管理总览、区域分布、故障程度、客户影响、车型分布、车型与部位。导出已加载统计快照,包含日期、业务筛选、数据时间、冻结表头、交替底色和数字格式。趋势、周报的既有导出保留,并增加业务筛选说明。
|
||||
@@ -0,0 +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
|
||||
|
@@ -0,0 +1,169 @@
|
||||
## 当前展示口径(2026-10-24 更新)
|
||||
|
||||
按用户最新要求,停止展示交还记录推算的历史资产数据。正式路由只提供当前实时数据和真实归档快照,已取消 `ASSET_HISTORY_RECONSTRUCTION_FILE` 的接入;即使保留旧环境变量,也不能通过趋势或明细接口读取回溯文件。旧核对表和私有回溯文件保留用于离线核查,不删除、不写入正式快照。
|
||||
|
||||
2026年10月前的缺失期间返回空值,不补零、不提供估算明细。页面仅显示一句提示:“历史库存状态快照缺失,核对中,暂不展示。” 当前10月继续读取实时数据;今后只有采集成功的真实快照才能归档展示。服务端仍返回存储和采集状态供运维检查,页面不再重复展示部署及回溯说明。
|
||||
|
||||
本地验收服务同步停用回溯文件。生产迁移、部署和自动采集仍未执行;该展示调整不等于采集已经启用。下文的回溯回填章节保留为历史实施记录,其环境变量接入和页面展示说明已被本节取代。
|
||||
|
||||
# 资产历史趋势
|
||||
|
||||
资产概览顶部指标之后新增历史趋势。月度选择年份,年度每年一个年末点;运营和库存为堆叠柱,总数为折线。下方期间按钮支持键盘和触摸选点,月度明细可分类、搜索车牌/VIN及分页。
|
||||
|
||||
## 口径及时间
|
||||
|
||||
- 复用资产仓储 `MAIN_SQL`、`transformRow`、`isOperatingVehicle`、`isInventoryVehicle`。
|
||||
- 总数为未删除、未退出运营的车辆;运营为租赁/自营,库存为可运营/待运营;异动、待交车不另加。
|
||||
- 月末最后一天 `Asia/Shanghai` 17:00 为计划时点;只允许在 17:00:00(含)到 17:01:00(不含)内开始读取。
|
||||
- `captured_at` 是程序记录的实际开始读取时间,`scheduled_for` 是计划时点;两者不宣称完全相同。
|
||||
- 一致性读取可在窗口内开始后继续执行;持久化失败最多重试三次,始终使用同一份已取得数据,不重新读晚到的台账。
|
||||
- 当月在截止前显示实时点;年末前当年年度点显示实时点。截止后只能显示归档、归档中、失败或缺失。12 月快照直接用作年度年末点。
|
||||
- 实时点每五秒走 `/live`,不重读全部历史;切换年份/公司/模式、手动刷新、跨月或归档边界时更新历史。
|
||||
- 实时数据沿用现有资产仓储的五秒缓存,实时 `asOf` 为本次服务端查询时间,不代表源台账所有记录的最后修改时间;固定快照绕过该缓存。
|
||||
- 缺失及未来数量为 `null`,合法零数量为 `0`,不插值、不跨缺失点连线。比较只取相邻期间;一月使用上年十二月作为基期。
|
||||
- 默认年度显示最近五年;更早有归档或导入时自动扩展年份。月度允许查询 2000 年至当前年份。
|
||||
|
||||
## 权限及历史字段
|
||||
|
||||
本功能是资产存量统计。实时和归档都复用 `filterAssetVehiclesByPermission`:库存对账号公共可见,其余车辆沿用全量、部门或个人权限。公共库存按所查询时点的运营状态判断;历史公司、部门和负责人取快照,角色及个人身份别名取当前认证系统。公司筛选在权限过滤范围内取 `subjectOrg`。
|
||||
|
||||
不连接今日车辆列表补字段,不因车辆退出、删除或转部门丢失历史。当前来源的部门权限使用部门名称,快照如实保存该名称,不虚构来源没有的部门 ID。未来源系统若增加稳定部门 ID,需版本化升级口径。
|
||||
|
||||
归档汇总从授权后的同一批逐车数据计算,不返回批次全量计数。明细客户名称复用既有脱敏。历史公司仅出现在有权限的相关历史数据中;顶部公司列表中的历史独有公司标注“历史主体”,不改变现有实时公司接口含义。权限视角切换会由现有认证容器重建页面并取消旧请求。
|
||||
|
||||
## 存储
|
||||
|
||||
`bi_asset_history_settings` 保存首次实际启用时间。重复启用保留首次时间。
|
||||
|
||||
`bi_asset_history_batches` 保存年月、口径版本、计划及实际时间、来源、状态、失败码、全量校验计数及导入追溯信息。年月与口径版本唯一。正式归档为 `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`。新建空表不会伪造以前历史。
|
||||
|
||||
授权启用后,将服务进程环境变量设为 `ASSET_HISTORY_CAPTURE_ENABLED=1` 并按现有流程重启服务。调度器首次成功连接时记录 `enabled_at`,启用前的时点不会追补。如果在月末 17:00 之后才首次启用,即使仍处于该分钟,也从下一个月末开始采集。
|
||||
|
||||
按本需求确认日期 2026-10-19,如果在 2026-10-31 17:00 前成功启用,首个计划时点为北京时间 **2026-10-31 17:00**;实际计划以数据库首次启用时间和服务时间计算,不硬编码。
|
||||
|
||||
保持服务在计划时点在线。停止期间错过的 eligible 月份会在重启时记为 MISSED;已 FAILED 的记录保留诊断标记。记录到启用前没有归档,不能用重启时的实时数回填。日志前缀为 `[asset-history]`,包含年月及 captured/already-complete/locked/failed/missed 结果,不记录客户明细或连接凭据。
|
||||
|
||||
建议仅让受控服务账号写快照表;源表仍只需读权限。多实例可启用同一数据库下的同一采集配置,命名锁和数据库唯一约束共同保证幂等。启用开关为进程级配置,`enabled_at` 是历史启用记录,不能仅根据该记录判断某个进程现在是否运行调度器。
|
||||
|
||||
管理员可用以下只读 SQL 检查,不通过普通用户接口暴露全量计数:
|
||||
|
||||
```sql
|
||||
SELECT enabled_at FROM bi_asset_history_settings WHERE id=1;
|
||||
SELECT period_month, 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_month DESC LIMIT 12;
|
||||
SELECT b.period_month, 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_month, 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,采集及后台任务均关闭。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 资产管理组件库迁移(历史记录)
|
||||
|
||||
> 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 条车辆仍分页;区域筛选后折叠摘要和库存计数一致。
|
||||
@@ -1,216 +0,0 @@
|
||||
# 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 供应商未配置、未执行首次同步;记录与账单下钻代码已完成,但真实数据仍不可验收。
|
||||
- 氢能连接信息已从当前版本迁出,但历史凭据仍必须在数据库侧轮换;文档不记录任何连接值。
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Shane Levine
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,198 @@
|
||||
# OneOS 日里程与累计里程差额排查报告
|
||||
|
||||
- 排查日期:2026-09-16(Asia/Shanghai)
|
||||
- 涉及数据日期:2026-09-11 至 2026-09-15
|
||||
- 接收方:OneOS 开放平台及里程计算服务维护人员
|
||||
- 状态:已复现;待上游核查计算基准、跨日缺报及协议选源逻辑
|
||||
|
||||
## 1. 问题概述
|
||||
|
||||
BI 用户发现:昨日累计总里程减去前日累计总里程,不等于昨日的区间里程;继续向前检查,多日均存在差额。
|
||||
|
||||
本次在相同车辆范围、相同协议优先级下复现,并绕过 BI 直接请求 OneOS 单日接口核对异常车辆。上游返回本身就存在相邻日期累计差与日里程不一致的情况,不能仅用 BI 显示取整解释。
|
||||
|
||||
已确认的现象:
|
||||
|
||||
1. 某查询日返回的累计里程对应更早日期的报文;后续更新后的累计差跨越多天,与当日日里程不一致。
|
||||
2. 同一车辆相邻日期切换 MQTT / GB32960,累计读数发生跳变,且切换后可能选中很早的读数。
|
||||
|
||||
尚未确认:日里程的内部计算公式、有效数据判定、跨日补算规则及异常过滤规则。本文不将差额直接认定为漏算里程,也不将累计差直接认定为真实日行驶里程。
|
||||
|
||||
## 2. 查询范围与 BI 计算口径
|
||||
|
||||
本次 BI 查询未附加客户、部门、车牌或里程筛选条件,每天返回 916 台车辆。
|
||||
|
||||
```text
|
||||
GET /api/mileage/monitoring
|
||||
?startDate=2026-09-15
|
||||
&endDate=2026-09-15
|
||||
&sourcePriority=instrument
|
||||
&limit=2000
|
||||
```
|
||||
|
||||
`instrument` 对应上游协议优先级:
|
||||
|
||||
```json
|
||||
["GB32960", "MQTT"]
|
||||
```
|
||||
|
||||
BI 当前计算方式:
|
||||
|
||||
| 指标 | 计算方式 |
|
||||
| --- | --- |
|
||||
| 单车累计里程 | OneOS `totalMileageKm`,不以日里程反推 |
|
||||
| 单车单日里程 | OneOS `dailyMileageKm` |
|
||||
| 单车区间里程 | 区间内每日 `dailyMileageKm` 求和 |
|
||||
| 累计总里程 | 查询范围内车辆的累计里程求和;空值按 0 参与汇总 |
|
||||
| 当日总里程 | 查询范围内车辆的日里程求和;汇总接口对结果取整 |
|
||||
|
||||
以下对账表使用逐车日里程原始数值求和,保留三位小数,避免整数显示带来的误差。
|
||||
|
||||
## 3. 汇总对账结果
|
||||
|
||||
单位:km。定义 `差额 = 当天累计总里程 - 前一天累计总里程 - 当天日里程合计`。
|
||||
|
||||
| 日期 | 累计总里程 | 相邻日累计差 | 当日日里程合计 | 差额 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 2026-09-11 | 48,448,486.800 | — | 98,182.208 | — |
|
||||
| 2026-09-12 | 48,546,128.300 | 97,641.500 | 97,321.774 | +319.726 |
|
||||
| 2026-09-13 | 48,619,784.800 | 73,656.500 | 82,322.847 | −8,666.347 |
|
||||
| 2026-09-14 | 48,721,190.600 | 101,405.800 | 94,242.687 | +7,163.113 |
|
||||
| 2026-09-15 | 48,824,917.800 | 103,727.200 | 103,554.604 | +172.596 |
|
||||
|
||||
按车牌匹配相邻日期,差额来源分解如下:
|
||||
|
||||
| 日期 | 两日累计非空且协议相同的车辆数 / 净差额 | 两日累计非空且协议变化的车辆数 / 净差额 | 至少一日累计为空的车辆数 / 净差额 |
|
||||
| --- | --- | --- | --- |
|
||||
| 09-12 | 759 / +319.726 | 0 / 0 | 157 / 0 |
|
||||
| 09-13 | 758 / +674.653 | 1 / −9,341.000 | 157 / 0 |
|
||||
| 09-14 | 759 / +7,163.113 | 0 / 0 | 157 / 0 |
|
||||
| 09-15 | 759 / +172.596 | 0 / 0 | 157 / 0 |
|
||||
|
||||
本次样本中,空累计值组对差额净贡献为 0;同协议数据也存在明显不一致,不能只修复协议切换问题。
|
||||
|
||||
## 4. 异常样本一:累计读数跨日,日里程未与累计差对齐
|
||||
|
||||
以下记录已通过 OneOS 单日接口直接复核,响应均为 HTTP 200、`code=SUCCESS`,记录状态均为 `NORMAL`。
|
||||
|
||||
### 4.1 粤AGQ8377:9 月 15 日差额 +70.2 km
|
||||
|
||||
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
| 2026-09-13 | GB32960 | 14,366.0 | 6.1 | 2026-09-13 07:17:27 |
|
||||
| 2026-09-14 | GB32960 | 14,366.0 | 0 | 2026-09-13 07:17:27 |
|
||||
| 2026-09-15 | GB32960 | 14,467.4 | 31.2 | 2026-09-15 20:57:04 |
|
||||
|
||||
```text
|
||||
累计差 = 14,467.4 - 14,366.0 = 101.4
|
||||
差额 = 101.4 - 31.2 = 70.2 km
|
||||
```
|
||||
|
||||
查询 9 月 14 日时,累计读数仍来自 9 月 13 日。请核查 9 月 15 日日里程的起点读数及其时间,并解释 70.2 km 在日统计中的归属或过滤原因。
|
||||
|
||||
### 4.2 粤AGR6839:9 月 15 日差额 +38.0 km
|
||||
|
||||
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
| 2026-09-14 | GB32960 | 45,122.2 | 0 | 2026-09-11 11:10:53 |
|
||||
| 2026-09-15 | GB32960 | 45,162.8 | 2.6 | 2026-09-15 10:34:02 |
|
||||
|
||||
累计差为 40.6 km,日里程为 2.6 km,差额为 38.0 km。请核查跨多日无新读数后的统计基准及补算策略。
|
||||
|
||||
### 4.3 粤AGP3609:9 月 14 日差额 +808.7 km
|
||||
|
||||
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
| 2026-09-12 | GB32960 | 48,102.9 | 1,293.6 | 2026-09-12 15:08:47 |
|
||||
| 2026-09-13 | GB32960 | 48,102.9 | 0 | 2026-09-12 15:08:47 |
|
||||
| 2026-09-14 | GB32960 | 48,981.7 | 70.1 | 2026-09-14 23:59:51 |
|
||||
| 2026-09-15 | GB32960 | 50,349.1 | 1,367.4 | 2026-09-15 23:59:50 |
|
||||
|
||||
9 月 14 日累计差为 878.8 km,日里程为 70.1 km,差额为 808.7 km;9 月 15 日累计差与日里程则同为 1,367.4 km。该车适合对比正常日期与跨日缺报日期的计算路径。
|
||||
|
||||
## 5. 异常样本二:协议切换导致累计值倒退
|
||||
|
||||
车辆:沪A60591F。以下记录也已直接请求上游复核,均返回 `NORMAL`。
|
||||
|
||||
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
| 2026-09-12 | MQTT | 51,416 | 0 | 2026-09-12 04:53:49 |
|
||||
| 2026-09-13 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
|
||||
| 2026-09-14 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
|
||||
| 2026-09-15 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
|
||||
|
||||
```text
|
||||
9 月 13 日累计差 = 42,075 - 51,416 = -9,341 km
|
||||
9 月 13 日日里程 = 0 km
|
||||
```
|
||||
|
||||
该车单独贡献 −9,341 km,叠加其他同协议车辆 +674.653 km 的差额后,形成当日全量净差额 −8,666.347 km。
|
||||
|
||||
请重点核查:
|
||||
|
||||
1. 同一请求优先级下,9 月 12 日采用 MQTT,9 月 13 日为何改用 7 月 16 日的 GB32960 读数?
|
||||
2. 协议“有效数据”的定义是否包含数据时间、新鲜度和查询日期边界?
|
||||
3. `NORMAL` 是否仅表示读数数值有效,而不表示查询当日存在有效报文?
|
||||
4. 两协议累计读数是否具有同一基准?切换时是否应提供不连续标识?
|
||||
5. 请核查该车协议记录对应的 VIN、设备及绑定历史,排除历史绑定变化造成的读数混用。
|
||||
|
||||
## 6. 上游直接复现方法
|
||||
|
||||
接口:`POST https://open.d.lnoneos.com/api/v1/vehicles/mileage/query`。
|
||||
|
||||
使用已授权的 API Key,通过环境变量注入,不将真实凭证写入报告或日志。
|
||||
|
||||
```bash
|
||||
curl --fail-with-body --silent --show-error \
|
||||
'https://open.d.lnoneos.com/api/v1/vehicles/mileage/query' \
|
||||
-H "Authorization: Bearer ${ONEOS_MILEAGE_API_KEY}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"date": "2026-09-15",
|
||||
"plateNumbers": ["粤AGQ8377", "粤AGR6839", "粤AGP3609", "沪A60591F"],
|
||||
"protocolPriority": ["GB32960", "MQTT"]
|
||||
}'
|
||||
```
|
||||
|
||||
依次将 `date` 替换为 `2026-09-12`、`2026-09-13`、`2026-09-14`、`2026-09-15`。
|
||||
|
||||
逐车对比字段:`date`、`plateNumber`、`vin`、`status`、`dailyMileageKm`、`totalMileageKm`、`sourceProtocol`、`dataTime`、`calculatedAt`、`updatedAt`。
|
||||
|
||||
本次直接复核保留了报告中的关键字段,未记录响应 `traceId`;上游重放时请保存完整响应及 `traceId`。历史数据如已重算或补报,当前返回值可能与本报告不同,请同时提供重算时间和修改前后结果。
|
||||
|
||||
## 7. 请上游提供的定位结果
|
||||
|
||||
### 7.1 明确字段契约
|
||||
|
||||
- `dailyMileageKm`:是当天首尾读数差、前日末值到当日末值的差,还是逐报文有效增量之和?是否丢弃跨日增量、异常跳变或负增量?
|
||||
- `totalMileageKm`:是查询日最后读数、截至查询日最近读数,还是其他快照?无当日报文时是否延用历史值?
|
||||
- `dataTime`:具体对应累计读数、日里程终点还是其他时间?
|
||||
- 两个里程字段是否保证来自同一协议、同一设备、同一计算快照?
|
||||
- 自然日边界是否统一为 Asia/Shanghai 的 00:00:00 至次日 00:00:00?
|
||||
- 是否承诺相邻日累计差等于日里程;若不承诺,请明确哪些场景不成立及差额如何解释。
|
||||
|
||||
### 7.2 提供样本计算明细
|
||||
|
||||
对上述四辆车,请返回:
|
||||
|
||||
1. 各协议参与选源的候选记录、时间及未选中原因。
|
||||
2. 日里程计算起止报文的时间、累计读数、设备/VIN 标识。
|
||||
3. 有效增量、被过滤增量及过滤原因。
|
||||
4. 缺报、补报、跨日增量归属,以及历史重算记录。
|
||||
5. 对每个差额给出可核对的数值分解,不能只解释为“统计口径不同”。
|
||||
|
||||
## 8. 修复或口径确认后的验收建议
|
||||
|
||||
1. 使用同一车辆集合和协议配置,重放 9 月 11 至 15 日数据,逐车核对后再核对汇总。
|
||||
2. 对连续上报且同协议的正常车辆,若契约约定可对账,应满足累计差与日里程一致;容差按上游实际数值精度明确。
|
||||
3. 对跨日缺报车辆,明确跨日增量是否补算、归属哪天,并能解释全部差额。
|
||||
4. 对协议切换车辆,避免将不同基准的累计值视为连续可减的序列;提供明确的协议切换和不连续信息。
|
||||
5. 对陈旧读数,区分“历史累计值仍有效”和“当日有有效报文”,避免用单一 `NORMAL` 混淆两者。
|
||||
6. 验证单日接口与区间接口对同一车、同一天的日里程、累计值和选源结果一致。
|
||||
7. 如修复涉及历史重算,提供重算范围及完成时间;BI 历史接口缓存最长 6 小时,应在刷新后复核。
|
||||
|
||||
## 9. 本次排查边界
|
||||
|
||||
- 已检查 BI 里程映射、日/区间聚合和汇总逻辑,并对异常样本直接调用上游单日接口复核。
|
||||
- 尚未访问 OneOS 原始报文、计算服务源码或内部存储,不能确定其内部算法根因。
|
||||
- 本次未修改业务代码、未变更里程口径、未触发历史数据重算。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 权限视角预览
|
||||
|
||||
授权账号登录后,直接访问 `/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会话;本地免登录不能替代此项。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 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)。
|
||||
@@ -0,0 +1,97 @@
|
||||
# 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 的兼容处理
|
||||
- 看似可合并但必须保持差异的流程
|
||||
|
||||
命名和类型能够表达清楚的代码不额外添加注释。
|
||||
|
||||
## 已识别但不在本轮修复的问题
|
||||
|
||||
- 统计页实时车辆与历史日期车辆共用同一个缓存槽,存在状态覆盖风险。
|
||||
- 监控页不同入口的请求参数目前不完全一致。
|
||||
- 数据库连接配置与部署凭据需要独立安全治理。
|
||||
|
||||
这些问题需要单独建缺陷、明确期望行为后再修复,避免借重构改变线上结果。
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,141 @@
|
||||
# LN-BI UI 优化待办清单
|
||||
|
||||
> 生成日期:2026-09-20
|
||||
> 来源:全局外壳/导航、数据看板与图表、业务操作页面三路 UI/UX 审查汇总
|
||||
> 用法:`- [ ]` 为未完成,`- [x]` 为已完成;每条附 `文件:行号` 证据,完成后建议连同代码一起提交。
|
||||
> 关联文档:`../LN-BI-项目分析报告.md` §11.4、`../LN-BI-重构方案.md` 阶段 5/6
|
||||
|
||||
---
|
||||
|
||||
## P0 可用性与数据信任(最优先)
|
||||
|
||||
### 可访问性:下拉组件
|
||||
- [ ] SearchSelect 键盘/读屏不可用:补 `role="combobox"` + `aria-expanded` + listbox/option 语义、↑↓/Enter/Escape,或直接替换 Headless UI / Radix Combobox — `src/components/SearchSelect.tsx:47-68`
|
||||
- [ ] MultiSearchSelect 同上,且焦点环颜色与单选版不一致(emerald vs blue)— `src/components/MultiSearchSelect.tsx:59-85`
|
||||
- [ ] mileage SearchableSelect 选项为裸 div,键盘不可达 — `src/modules/mileage/monitoring/components/SearchableSelect.tsx:60-72`
|
||||
- [ ] 排序表头补 `aria-sort`;三张 div 模拟表格中期换成真 `<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) |
|
||||
@@ -0,0 +1,31 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- 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;
|
||||
Generated
+3957
-81
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"private": true,
|
||||
"version": "1.1.13",
|
||||
"version": "1.1.21",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||
@@ -9,20 +9,27 @@
|
||||
"dev:client": "vite --port=3000 --host=0.0.0.0",
|
||||
"build": "vite build",
|
||||
"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": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"ali-oss": "^6.23.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cn": "^0.4.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"echarts": "^6.1.0",
|
||||
"exceljs": "4.4.0",
|
||||
"hono": "^4.7.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"mysql2": "^3.11.0",
|
||||
"pg": "^8.22.0",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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));
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
const sql = await readFile(new URL('../migrations/20261019_asset_history.sql', 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(); }
|
||||
}
|
||||
+26
-144
@@ -1,142 +1,30 @@
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt, MapPinned } from "lucide-react";
|
||||
import { Shell, type ModuleConfig } from "./components/Shell";
|
||||
import { Shell } from "./components/Shell";
|
||||
import AuthProvider from "./auth/AuthProvider";
|
||||
import PermissionPreview, { PermissionPreviewPage } from "./auth/PermissionPreview";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
||||
import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles";
|
||||
import { canAccessEnergy } from "./shared/auth/roles";
|
||||
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 FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
|
||||
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 "";
|
||||
}
|
||||
normalizeBrowserPath();
|
||||
|
||||
function AuthGate() {
|
||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||
const [routeKey, setRouteKey] = useState(getRouteKey);
|
||||
const [pathSet, setPathSet] = useState<PathSet>(getPathSet);
|
||||
const [route, setRoute] = useState(readBrowserRoute);
|
||||
const { routeKey, pathSet } = route;
|
||||
|
||||
// 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
setRouteKey(getRouteKey());
|
||||
setPathSet(getPathSet());
|
||||
setRoute(readBrowserRoute());
|
||||
};
|
||||
window.addEventListener("hashchange", update);
|
||||
window.addEventListener("popstate", update);
|
||||
@@ -147,26 +35,13 @@ function AuthGate() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
|
||||
}, [pathSet]);
|
||||
document.title = routeKey === "who" ? "权限视角 - 羚牛氢能 BI" : pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
|
||||
}, [pathSet, routeKey]);
|
||||
|
||||
const modules = useMemo<ModuleConfig[]>(() => {
|
||||
if (pathSet === "energy") {
|
||||
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]);
|
||||
const modules = useMemo(
|
||||
() => buildModules(pathSet, user?.roles),
|
||||
[pathSet, user?.roles],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -194,6 +69,8 @@ function AuthGate() {
|
||||
return <UnauthorizedPage message={error || undefined} />;
|
||||
}
|
||||
|
||||
if (routeKey === "who") return <PermissionPreviewPage />;
|
||||
|
||||
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
||||
if (routeKey === "ele/import") {
|
||||
return (
|
||||
@@ -219,10 +96,15 @@ function AuthGate() {
|
||||
return <Shell key={pathSet} modules={modules} />;
|
||||
}
|
||||
|
||||
function AuthenticatedWorkspace() {
|
||||
const { identityVersion = 0 } = useAuth();
|
||||
return <PermissionPreview><AuthGate key={identityVersion} /></PermissionPreview>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AuthGate />
|
||||
<AuthenticatedWorkspace />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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'), '');
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
+135
-36
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { AuthContext, type AuthState } from './useAuth';
|
||||
import { setTokenGetter } from './api-client';
|
||||
import { AuthContext, type AuthState, type AuthUser } from './useAuth';
|
||||
import { setTokenGetter, setPreviewTokenGetter, invalidateAuthenticatedRequests, fetchPreviewControl } from './api-client';
|
||||
|
||||
const AUTH_API = '/api/auth';
|
||||
|
||||
@@ -13,43 +13,147 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
const tokenRef = useRef<string | null>(null);
|
||||
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(() => {
|
||||
// 设置全局 token getter
|
||||
setTokenGetter(() => tokenRef.current);
|
||||
|
||||
// 防止 StrictMode 双重调用(jumpToken 一次性使用)
|
||||
if (authStarted.current) return;
|
||||
authStarted.current = true;
|
||||
setPreviewTokenGetter(() => previewTokenRef.current);
|
||||
|
||||
// 监听 401 事件
|
||||
const onUnauthorized = () => {
|
||||
resetPreview();
|
||||
setCanPreview(false);
|
||||
tokenRef.current = null;
|
||||
sessionStorage.removeItem('bi_jwt');
|
||||
sessionStorage.removeItem('bi_user');
|
||||
actorRef.current = null;
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '会话已过期' });
|
||||
};
|
||||
window.addEventListener('auth:unauthorized', onUnauthorized);
|
||||
|
||||
authenticate();
|
||||
|
||||
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
|
||||
const onPreviewEnded = (event: Event) => {
|
||||
if ((event as CustomEvent).detail === 'PREVIEW_REVOKED') setCanPreview(false);
|
||||
resetPreview('预览权限已失效,已返回本人视角。');
|
||||
};
|
||||
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() {
|
||||
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: {
|
||||
userId: 'dev-local',
|
||||
userName: '本地开发',
|
||||
permissionLevel: 'full',
|
||||
depName: '',
|
||||
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
const devUser: AuthUser = {
|
||||
userId: 'dev-local',
|
||||
userName: '本地开发',
|
||||
permissionLevel: 'full',
|
||||
depName: '',
|
||||
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
|
||||
};
|
||||
// 预览授权仍由后端独立开发开关决定,不使用真实用户令牌。
|
||||
tokenRef.current = null;
|
||||
await loadPreviewContext(devUser);
|
||||
actorRef.current ||= devUser;
|
||||
setState({ isLoading: false, isAuthenticated: true, user: actorRef.current, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,22 +161,20 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const savedToken = sessionStorage.getItem('bi_jwt');
|
||||
if (savedToken) {
|
||||
tokenRef.current = savedToken;
|
||||
// 验证 token 是否仍然有效(尝试请求 health)
|
||||
// 从受保护的身份接口验证 JWT;不使用本地缓存用户决定权限。
|
||||
try {
|
||||
const res = await fetch('/api/health', {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${savedToken}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const savedUser = sessionStorage.getItem('bi_user');
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: savedUser ? JSON.parse(savedUser) : null,
|
||||
error: null,
|
||||
});
|
||||
const verifiedUser: AuthUser = await res.json();
|
||||
if (!verifiedUser.userId) throw new Error('无效的用户身份');
|
||||
await finishAuthentication(verifiedUser);
|
||||
return;
|
||||
}
|
||||
} catch { /* token 无效,继续流程 */ }
|
||||
tokenRef.current = null;
|
||||
resetPreview();
|
||||
sessionStorage.removeItem('bi_jwt');
|
||||
sessionStorage.removeItem('bi_user');
|
||||
}
|
||||
@@ -108,19 +210,16 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
: `${window.location.pathname}${window.location.hash}`;
|
||||
window.history.replaceState({}, '', cleanUrl);
|
||||
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: data.user,
|
||||
error: null,
|
||||
});
|
||||
await finishAuthentication(data.user);
|
||||
} catch (e) {
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '认证过程出错' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={state}>
|
||||
<AuthContext.Provider value={{ ...state, actor: actorRef.current || state.user,
|
||||
user: preview?.user || state.user, preview, canPreview, identityVersion, previewNotice,
|
||||
startPreview, stopPreview, clearPreviewNotice: () => setPreviewNotice(null) }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
.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)}}
|
||||
@@ -0,0 +1,85 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
+54
-76
@@ -1,83 +1,61 @@
|
||||
/** 全局认证 fetch 客户端 */
|
||||
|
||||
/** 登录身份和预览身份分别传递;切换视角会取消旧视角的在途请求。 */
|
||||
let tokenGetter: () => string | null = () => null;
|
||||
const inflightGetRequests = new Map<string, Promise<unknown>>();
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string | null;
|
||||
readonly retryable: boolean;
|
||||
|
||||
constructor(message: string, status: number, code: string | null, retryable: boolean) {
|
||||
super(message);
|
||||
this.name = 'ApiRequestError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.retryable = retryable;
|
||||
}
|
||||
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']);
|
||||
|
||||
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) {
|
||||
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
if (!res.ok) {
|
||||
let payload: ApiErrorPayload = {};
|
||||
try {
|
||||
const parsed: unknown = await res.json();
|
||||
if (typeof parsed === 'object' && parsed !== null) payload = parsed as ApiErrorPayload;
|
||||
} catch {
|
||||
payload = {};
|
||||
async function authenticatedRequest(url: string, options: RequestInit | undefined, preview: boolean, business: boolean): Promise<Response> {
|
||||
const version = identityVersion;
|
||||
const headers = new Headers(options?.headers);
|
||||
const token = tokenGetter();
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
headers.delete('X-BI-Preview');
|
||||
const previewToken = preview ? previewTokenGetter() : null;
|
||||
if (previewToken) headers.set('X-BI-Preview', previewToken);
|
||||
const signal = business
|
||||
? options?.signal ? AbortSignal.any([options.signal, identityAbort.signal]) : identityAbort.signal
|
||||
: options?.signal;
|
||||
const res = await fetch(url, { ...options, headers, signal });
|
||||
if (business && version !== identityVersion) throw new DOMException('权限视角已切换', 'AbortError');
|
||||
if (res.status === 401 || res.status === 403 || res.status === 410) {
|
||||
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('权限视角已失效,请重新选择用户');
|
||||
}
|
||||
if (res.status === 401) {
|
||||
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
if (body.code === 'PREVIEW_READ_ONLY') {
|
||||
window.dispatchEvent(new CustomEvent('auth:preview-readonly'));
|
||||
throw new Error('权限视角为只读,请退出预览后操作');
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
+20
-18
@@ -1,25 +1,27 @@
|
||||
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 {
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
user: {
|
||||
userId: string;
|
||||
userName: string;
|
||||
permissionLevel: string;
|
||||
depName: string;
|
||||
roles?: string[];
|
||||
} | null;
|
||||
user: AuthUser | 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); }
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+40
-356
@@ -1,31 +1,20 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
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 { fetchJson, fetchAuthenticated } from '../auth/api-client';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { canManageFeedback } from '../shared/auth/roles';
|
||||
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
|
||||
import RotatingFooterHint from './RotatingFooterHint';
|
||||
import FeedbackLauncher from './feedback/FeedbackLauncher';
|
||||
import FeedbackModal from './feedback/FeedbackModal';
|
||||
import type { FeedbackStep, FeedbackType, UploadedImg } from './feedback/types';
|
||||
|
||||
const MAX_SCREENSHOTS = 6;
|
||||
const MAX_IMG_SIZE_MB = 5;
|
||||
|
||||
interface UploadedImg {
|
||||
url: string;
|
||||
thumbDataUrl: string;
|
||||
}
|
||||
|
||||
async function uploadImage(file: File): Promise<string> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const token = sessionStorage.getItem('bi_jwt');
|
||||
const res = await fetch('/api/feedback/upload', {
|
||||
const res = await fetchAuthenticated('/api/feedback/upload', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
});
|
||||
const json = await res.json();
|
||||
@@ -42,38 +31,6 @@ 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 {
|
||||
const hash = (window.location.hash || '').slice(1);
|
||||
const path = window.location.pathname;
|
||||
@@ -94,7 +51,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1); // 1=选类型, 2=写内容, 3=成功页
|
||||
const [step, setStep] = useState<FeedbackStep>(1);
|
||||
const [type, setType] = useState<FeedbackType | null>(null);
|
||||
const [mod, setMod] = useState<string>('');
|
||||
const [content, setContent] = useState('');
|
||||
@@ -128,14 +85,12 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
// Open: detect current module
|
||||
useEffect(() => {
|
||||
if (open && step === 1) {
|
||||
setMod(moduleProp ?? detectModule());
|
||||
}
|
||||
}, [open, step, moduleProp]);
|
||||
|
||||
// Lock scroll when open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
@@ -153,7 +108,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setTimeout(reset, 300); // 等动画完
|
||||
setTimeout(reset, 300);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
@@ -193,319 +148,48 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const back = () => setStep((Math.max(1, step - 1)) as typeof step);
|
||||
const back = () => setStep((Math.max(1, step - 1)) as FeedbackStep);
|
||||
|
||||
const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true;
|
||||
const progress = step >= 3 ? 100 : (step / 2) * 100;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Action Button */}
|
||||
<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={() => 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>
|
||||
<FeedbackLauncher
|
||||
isAdmin={isAdmin}
|
||||
menuOpen={menuOpen}
|
||||
onCloseMenu={() => setMenuOpen(false)}
|
||||
onOpenFeedback={() => { setMenuOpen(false); setOpen(true); }}
|
||||
onOpenHistory={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
||||
onToggleMenu={() => setMenuOpen(current => !current)}
|
||||
/>
|
||||
|
||||
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||
|
||||
{/* Modal */}
|
||||
<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()}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-2.5 pb-1">
|
||||
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<FeedbackModal
|
||||
canNext={canNext}
|
||||
content={content}
|
||||
error={error}
|
||||
fileRef={fileRef}
|
||||
maxScreenshots={MAX_SCREENSHOTS}
|
||||
module={mod}
|
||||
open={open}
|
||||
progress={progress}
|
||||
screenshots={shots}
|
||||
step={step}
|
||||
submitting={submitting}
|
||||
textareaRef={taRef}
|
||||
type={type}
|
||||
uploading={uploading}
|
||||
onAddFiles={addFiles}
|
||||
onBack={back}
|
||||
onChangeContent={setContent}
|
||||
onChangeModule={setMod}
|
||||
onClose={close}
|
||||
onNext={next}
|
||||
onRemoveScreenshot={(index) => setShots(prev => prev.filter((_, current) => current !== index))}
|
||||
onSelectType={(selectedType) => { setType(selectedType); setStep(2); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { motion } from 'motion/react';
|
||||
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { DemoModeProvider } from './Blur';
|
||||
import FeedbackFab from './FeedbackFab';
|
||||
import { cn } from '../lib/cn';
|
||||
import { LoadingState } from './ui/surface';
|
||||
|
||||
@@ -91,7 +90,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
|
||||
return (
|
||||
<DemoModeProvider enabled={false}>
|
||||
<div className="enterprise-grid-bg flex min-h-screen">
|
||||
<div className="app-shell 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="absolute inset-0" style={{
|
||||
@@ -156,7 +155,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
</nav>
|
||||
|
||||
{/* 内容区 */}
|
||||
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
|
||||
<main className="app-shell-content 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">
|
||||
<span className="text-slate-600">羚牛氢能 BI</span>
|
||||
<span className="mx-2 text-slate-300">/</span>
|
||||
@@ -165,11 +164,11 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
|
||||
{ActiveComponent && <ActiveComponent />}
|
||||
</Suspense>
|
||||
<FeedbackFab module={activeModule} />
|
||||
|
||||
</main>
|
||||
|
||||
{/* 移动端底部导航 (md 以下) */}
|
||||
<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">
|
||||
<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">
|
||||
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}>
|
||||
{modules.map((m) => {
|
||||
const Icon = m.icon;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type FeedbackStep = 1 | 2 | 3;
|
||||
|
||||
export type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
|
||||
|
||||
export interface UploadedImg {
|
||||
url: string;
|
||||
thumbDataUrl: string;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<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 }
|
||||
@@ -0,0 +1,168 @@
|
||||
"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,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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 }
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type ComponentType, type ReactNode } from 'react';
|
||||
import { AlertCircle, Info, Loader2, RefreshCw, SearchX, X } from 'lucide-react';
|
||||
import { AlertCircle, Info, Loader2, SearchX, X } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
@@ -15,6 +15,7 @@ export function PageFrame({
|
||||
children,
|
||||
maxWidth = 'max-w-6xl',
|
||||
compactInfo = false,
|
||||
hideHeader = false,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
@@ -25,13 +26,14 @@ export function PageFrame({
|
||||
children: ReactNode;
|
||||
maxWidth?: string;
|
||||
compactInfo?: boolean;
|
||||
hideHeader?: boolean;
|
||||
}) {
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
|
||||
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={cn(maxWidth, 'mx-auto flex flex-col gap-4 pb-20 md:pb-6')}>
|
||||
<motion.header
|
||||
{!hideHeader ? <motion.header
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28, ease: 'easeOut' }}
|
||||
@@ -56,7 +58,7 @@ export function PageFrame({
|
||||
</div>
|
||||
<h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1>
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
||||
{actions ? <div className="hidden shrink-0 items-center gap-2 md:flex">{actions}</div> : null}
|
||||
{subtitle ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -106,7 +108,7 @@ export function PageFrame({
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
</motion.header>
|
||||
</motion.header> : null}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,7 +122,7 @@ export function SurfaceCard({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title?: string;
|
||||
title?: ReactNode;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
@@ -149,6 +151,8 @@ export function MetricTile({
|
||||
helper,
|
||||
icon: Icon,
|
||||
tone = 'blue',
|
||||
className,
|
||||
compact = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
@@ -156,6 +160,8 @@ export function MetricTile({
|
||||
helper?: string;
|
||||
icon?: SurfaceIcon;
|
||||
tone?: 'blue' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const toneClass = {
|
||||
blue: 'bg-blue-50 text-blue-600 ring-blue-100',
|
||||
@@ -166,22 +172,29 @@ export function MetricTile({
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md">
|
||||
<div className={cn(
|
||||
'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="min-w-0 text-[11px] font-black text-slate-400">{label}</div>
|
||||
{Icon ? (
|
||||
<span className={cn('inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1', toneClass)}>
|
||||
<Icon size={18} />
|
||||
<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)}>
|
||||
<Icon size={compact ? 16 : 18} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex min-w-0 items-end gap-1">
|
||||
<span className="min-w-0 whitespace-nowrap text-[clamp(1.65rem,6vw,2rem)] font-black leading-none tracking-tight text-slate-950 tabular-nums">
|
||||
<div className={cn('flex min-w-0 items-end gap-1', compact ? 'mt-2' : 'mt-3')}>
|
||||
<span className={cn(
|
||||
'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}
|
||||
</span>
|
||||
{unit ? <span className="shrink-0 pb-0.5 text-[11px] font-black text-slate-400">{unit}</span> : null}
|
||||
</div>
|
||||
{helper ? <div className="mt-3 text-[11px] font-bold leading-relaxed text-slate-500">{helper}</div> : null}
|
||||
{helper ? <div className={cn('font-bold leading-relaxed text-slate-500', compact ? 'mt-2 text-[10px]' : 'mt-3 text-[11px]')}>{helper}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -190,51 +203,23 @@ export function SegmentedNav<T extends string>({
|
||||
tabs,
|
||||
active,
|
||||
onChange,
|
||||
idPrefix,
|
||||
ariaLabel,
|
||||
className,
|
||||
}: {
|
||||
tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[];
|
||||
active: T;
|
||||
onChange: (id: T) => void;
|
||||
idPrefix: string;
|
||||
ariaLabel: 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 (
|
||||
<div className={cn('rounded-2xl border border-white/70 bg-white/90 p-1 shadow-sm backdrop-blur-xl', className)}>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className="grid gap-1"
|
||||
style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{tabs.map(({ id, label, icon: Icon }, index) => {
|
||||
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}>
|
||||
{tabs.map(({ id, label, icon: Icon }) => {
|
||||
const isActive = active === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
id={`${idPrefix}-tab-${id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`${idPrefix}-panel-${id}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => onChange(id)}
|
||||
onKeyDown={event => activateFromKeyboard(event, index)}
|
||||
className={cn(
|
||||
'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',
|
||||
@@ -261,20 +246,9 @@ export function SkeletonBlock({ className }: { className?: string }) {
|
||||
return <div className={cn('overflow-hidden rounded-xl bg-slate-100 shimmer', className)} />;
|
||||
}
|
||||
|
||||
type StateVariant = 'card' | 'inline';
|
||||
|
||||
export function LoadingState({
|
||||
label = '数据加载中',
|
||||
variant = 'card',
|
||||
}: {
|
||||
label?: string;
|
||||
variant?: StateVariant;
|
||||
}) {
|
||||
export function LoadingState({ label = '数据加载中' }: { label?: string }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'p-8 text-center',
|
||||
variant === 'card' && 'rounded-2xl border border-slate-100 bg-white shadow-sm',
|
||||
)}>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm">
|
||||
<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>
|
||||
@@ -284,17 +258,12 @@ export function LoadingState({
|
||||
export function EmptyState({
|
||||
title = '暂无数据',
|
||||
description = '换个筛选条件或稍后再试',
|
||||
variant = 'card',
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
variant?: StateVariant;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'p-8 text-center',
|
||||
variant === 'card' && 'rounded-2xl border border-slate-100 bg-white shadow-sm',
|
||||
)}>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm">
|
||||
<SearchX className="mx-auto mb-3 text-slate-300" size={26} />
|
||||
<div className="text-sm font-black text-slate-700">{title}</div>
|
||||
<div className="mt-1 text-xs font-bold text-slate-400">{description}</div>
|
||||
@@ -302,40 +271,14 @@ export function EmptyState({
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
message,
|
||||
title = '加载失败',
|
||||
onRetry,
|
||||
retrying = false,
|
||||
variant = 'card',
|
||||
}: {
|
||||
message: string;
|
||||
title?: string;
|
||||
onRetry?: () => void;
|
||||
retrying?: boolean;
|
||||
variant?: StateVariant;
|
||||
}) {
|
||||
export function ErrorState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'bg-rose-50 p-4 text-rose-700',
|
||||
variant === 'card' && 'rounded-2xl border border-rose-100 shadow-sm',
|
||||
)}>
|
||||
<div className="rounded-2xl border border-rose-100 bg-rose-50 p-4 text-rose-700 shadow-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle size={18} className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-black">{title}</div>
|
||||
<div>
|
||||
<div className="text-sm font-black">加载失败</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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import { fetchJson, fetchAuthenticated } from '../../auth/api-client';
|
||||
import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
interface FeedbackItem {
|
||||
@@ -49,10 +49,9 @@ function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
|
||||
}
|
||||
|
||||
async function patchItem(id: number, data: { status?: string; reply?: string }): Promise<void> {
|
||||
const token = sessionStorage.getItem('bi_jwt');
|
||||
const res = await fetch(`/api/feedback/${id}`, {
|
||||
const res = await fetchAuthenticated(`/api/feedback/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
+225
-1756
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
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].业务负责人, '业务负责人');
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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']);
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
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 };
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { fetchJson } from '../../auth/api-client';
|
||||
const BASE = '/api/vehicles';
|
||||
|
||||
export interface SubjectOption {
|
||||
historicalOnly?: boolean;
|
||||
name: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
@@ -37,6 +38,8 @@ export async function fetchByType(subject?: string | null): Promise<TypeSummary[
|
||||
}
|
||||
|
||||
export async function fetchVehicleList(params: {
|
||||
inventoryKeys?: string[];
|
||||
brand?: string;
|
||||
batch?: string;
|
||||
model?: string;
|
||||
location?: string;
|
||||
@@ -53,6 +56,7 @@ export async function fetchVehicleList(params: {
|
||||
source?: string;
|
||||
}): Promise<VehicleListItem[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.brand) query.set('brand', params.brand);
|
||||
if (params.batch) query.set('batch', params.batch);
|
||||
if (params.model) query.set('model', params.model);
|
||||
if (params.location) query.set('location', params.location);
|
||||
@@ -67,7 +71,11 @@ export async function fetchVehicleList(params: {
|
||||
if (params.attendance) query.set('attendance', params.attendance);
|
||||
if (params.subject) query.set('subject', params.subject);
|
||||
if (params.source) query.set('source', params.source);
|
||||
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
|
||||
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`, params.inventoryKeys ? {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inventoryKeys: params.inventoryKeys }),
|
||||
} : undefined);
|
||||
}
|
||||
|
||||
export interface WeeklyDetailItem {
|
||||
@@ -100,6 +108,8 @@ export interface FlowDetailItem {
|
||||
department: string;
|
||||
manager: string;
|
||||
customerName: string | null;
|
||||
contractNo: string | null;
|
||||
projectName: string | null;
|
||||
}
|
||||
|
||||
export interface FlowStatsResponse {
|
||||
@@ -153,13 +163,14 @@ export async function fetchRegionChart(
|
||||
|
||||
export async function fetchWeeklyDetail(
|
||||
type: string,
|
||||
filters?: { model?: string; batch?: string; location?: string; source?: string },
|
||||
filters?: { model?: string; batch?: string; location?: string; source?: string; subject?: string | null },
|
||||
): Promise<WeeklyDetailItem[]> {
|
||||
const params = new URLSearchParams({ type });
|
||||
if (filters?.model && filters.model !== 'All') params.set('model', filters.model);
|
||||
if (filters?.batch && filters.batch !== 'All') params.set('batch', filters.batch);
|
||||
if (filters?.location && filters.location !== 'All') params.set('location', filters.location);
|
||||
if (filters?.source) params.set('source', filters.source);
|
||||
if (filters?.subject) params.set('subject', filters.subject);
|
||||
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,951 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
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,
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,959 @@
|
||||
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,
|
||||
);
|
||||
@@ -0,0 +1,112 @@
|
||||
// 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
@@ -0,0 +1,64 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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,/统计口径、权限与数据时间/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
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, /<script>不能执行<\/script>/);
|
||||
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,/不应插入的固定城市路径/);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/* 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; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
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) }));
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
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("总量"));
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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" />;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
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>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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 (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<SummaryMetrics
|
||||
summary={props.summary}
|
||||
operatingRate={props.operatingRate}
|
||||
inventoryRate={props.inventoryRate}
|
||||
pendingRate={props.pendingRate}
|
||||
setVehicleSelection={props.setVehicleSelection}
|
||||
/>
|
||||
|
||||
<AssetHistorySection subject={props.selectedSubject} onSubjects={props.onHistorySubjects} />
|
||||
</div>
|
||||
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, ChevronDown, Filter, Plus, RotateCcw, Search, X } from 'lucide-react';
|
||||
import type { AssetAnalysisCell, AssetAnalysisDimension as Dimension } from '../../../../shared/assets/analytics';
|
||||
import { ANALYSIS_META, DIMENSION_LABELS, filterCustomCells, getDefaultDimensions, getDimensionValueLabel, type AnalysisFilterValues, type AnalysisView } from '../../analytics-model';
|
||||
import { useMobileLayout } from '../../useMobileLayout';
|
||||
import { MobileAnalysisControls } from './MobileAnalysisControls';
|
||||
|
||||
type Props = {
|
||||
view: AnalysisView;
|
||||
cells: AssetAnalysisCell[];
|
||||
available: Dimension[];
|
||||
dimensions: Dimension[];
|
||||
setDimensions: (value: Dimension[]) => void;
|
||||
filters: AnalysisFilterValues;
|
||||
setFilters: (value: AnalysisFilterValues) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export function AnalysisControls({ view, cells, available, dimensions, setDimensions, filters, setFilters, reset }: Props) {
|
||||
const mobile = useMobileLayout();
|
||||
const root = useRef<HTMLElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const [editor, setEditor] = useState<Dimension | null>(null);
|
||||
const [draft, setDraft] = useState<string[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const activeCount = Object.values(filters).filter(values => values?.length).length;
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setEditor(null); };
|
||||
document.addEventListener('pointerdown', outside);
|
||||
return () => document.removeEventListener('pointerdown', outside);
|
||||
}, [editor]);
|
||||
const metric = ANALYSIS_META[view].metric;
|
||||
const options = useMemo(() => {
|
||||
if (!editor) return [];
|
||||
const otherFilters = { ...filters };
|
||||
delete otherFilters[editor];
|
||||
const eligible = filterCustomCells(cells, otherFilters, metric);
|
||||
const counts = new Map<string, number>();
|
||||
eligible.forEach(cell => counts.set(cell[editor], (counts.get(cell[editor]) || 0) + cell.quantity));
|
||||
for (const value of filters[editor] || []) if (!counts.has(value)) counts.set(value, 0);
|
||||
return [...counts].map(([value, count]) => ({ value, count, label: getDimensionValueLabel(editor, value) }))
|
||||
.sort((a,b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN'))
|
||||
.filter(option => option.label.toLocaleLowerCase().includes(query.toLocaleLowerCase()));
|
||||
}, [cells, editor, filters, metric, query]);
|
||||
const closeEditor = () => { setEditor(null); triggerRef.current?.focus(); };
|
||||
const open = (dimension: Dimension, trigger: HTMLButtonElement) => {
|
||||
triggerRef.current = trigger;
|
||||
if (editor === dimension) { setEditor(null); return; }
|
||||
const bounds = root.current!.getBoundingClientRect();
|
||||
const button = trigger.getBoundingClientRect();
|
||||
setPosition({ top: button.bottom - bounds.top + 5, left: Math.max(8, Math.min(button.left - bounds.left, bounds.width - 338)) });
|
||||
setEditor(dimension); setDraft(filters[dimension] || []); setQuery('');
|
||||
};
|
||||
const apply = () => {
|
||||
if (!editor) return;
|
||||
const next = { ...filters };
|
||||
if (draft.length) next[editor] = draft; else delete next[editor];
|
||||
setFilters(next); closeEditor();
|
||||
};
|
||||
const mobileOptions = useMemo(() => mobile ? available.map(key => {
|
||||
const other = { ...filters }; delete other[key];
|
||||
const counts = new Map<string, number>();
|
||||
filterCustomCells(cells, other, metric).forEach(cell => counts.set(cell[key], (counts.get(cell[key]) || 0) + cell.quantity));
|
||||
for (const value of filters[key] || []) if (!counts.has(value)) counts.set(value, 0);
|
||||
return { key, label: DIMENSION_LABELS[key], options: [...counts].map(([value, count]) => ({ value, count, label: getDimensionValueLabel(key, value) })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN')) };
|
||||
}) : [], [mobile, available, cells, filters, metric]);
|
||||
if (mobile) return <MobileAnalysisControls available={mobileOptions} dimensions={dimensions} filters={filters} defaults={getDefaultDimensions(view)} apply={(nextDimensions, nextFilters) => { setDimensions(nextDimensions as Dimension[]); setFilters(nextFilters); }} />;
|
||||
return <section ref={root} className="analysis-builder" aria-label="自定义分析设置">
|
||||
<div className="analysis-builder-heading"><h3>分析维度</h3><span>点名称组合统计 · 点箭头搜索筛选</span><button className="analysis-reset" onClick={() => { reset(); setEditor(null); }}><RotateCcw size={12} />重置分析</button></div>
|
||||
<div className="analysis-dimension-options" role="group" aria-label="选择分析维度">
|
||||
<button aria-pressed={dimensions.length === 0} onClick={() => { setDimensions([]); setEditor(null); }}><Filter size={12} />仅筛选</button>
|
||||
{available.map(dimension => {
|
||||
const index = dimensions.indexOf(dimension);
|
||||
const selected = filters[dimension] || [];
|
||||
const filterLabel = selected.map(value => getDimensionValueLabel(dimension, value)).join('、');
|
||||
return <div className={`analysis-dimension-column${selected.length ? ' has-filter' : ''}`} key={dimension}>
|
||||
<button className="analysis-dimension-name" title={dimension === 'region' ? '区域划分与库存统计一致' : undefined} aria-pressed={index >= 0} onClick={() => { setDimensions(index < 0 ? [...dimensions, dimension] : dimensions.filter(item => item !== dimension)); setEditor(null); }}>
|
||||
{index >= 0 ? <span className="analysis-dimension-order">{index + 1}</span> : <Plus size={12} />}{DIMENSION_LABELS[dimension]}
|
||||
</button>
|
||||
<button className="analysis-dimension-dropdown" title={selected.length ? filterLabel : `搜索、筛选${DIMENSION_LABELS[dimension]}`} aria-label={`筛选${DIMENSION_LABELS[dimension]}${selected.length ? `,已选${selected.length}项` : ''}`} aria-expanded={editor === dimension} aria-controls={editor === dimension ? 'analysis-column-filter' : undefined} onClick={event => open(dimension, event.currentTarget)}>
|
||||
{selected.length > 0 && <><Filter size={11} /><span className="analysis-inline-filter">{selected.length === 1 ? filterLabel : `${selected.length}项`}</span></>}<ChevronDown size={12} />
|
||||
</button>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
{activeCount > 0 && <div className="analysis-active-note"><span>{activeCount} 个维度已筛选 · 同维度多选为“或”,不同维度为“且”</span><button className="analysis-text-button" onClick={() => { setFilters({}); setEditor(null); }}>清空全部筛选</button></div>}
|
||||
{editor && <div id="analysis-column-filter" className="analysis-filter-editor analysis-column-popover" style={position} role="group" aria-label={`${DIMENSION_LABELS[editor]}筛选菜单`} onKeyDown={event => { if (event.key === 'Escape') { event.stopPropagation(); closeEditor(); } }}>
|
||||
<div className="analysis-filter-editor-top"><strong>{DIMENSION_LABELS[editor]}</strong><span>搜索与筛选</span><button className="analysis-icon-button" aria-label="关闭筛选编辑" onClick={closeEditor}><X size={15} /></button></div>
|
||||
<label className="analysis-filter-search"><Search size={13} /><input autoFocus aria-label="搜索筛选值" placeholder={`搜索${DIMENSION_LABELS[editor]}`} value={query} onChange={event => setQuery(event.target.value)} /></label>
|
||||
<div className="analysis-filter-selection"><button onClick={() => setDraft([...new Set([...draft, ...options.map(option => option.value)])])}>全选{query ? '搜索结果' : ''}</button><button onClick={() => setDraft([])}>清除选择</button><span>已选 {draft.length} 项</span></div>
|
||||
<div className="analysis-filter-options" role="group" aria-label={`${DIMENSION_LABELS[editor]}选项`}>
|
||||
{options.map(option => <label key={option.value}><input type="checkbox" checked={draft.includes(option.value)} onChange={event => setDraft(event.target.checked ? [...draft,option.value] : draft.filter(value => value !== option.value))} /><span>{option.label}</span><small>{option.count} 台</small></label>)}
|
||||
{!options.length && <p>没有匹配选项,可调整搜索或其他筛选。</p>}
|
||||
</div>
|
||||
<div className="analysis-filter-editor-bottom"><span>未选择时不限制此维度</span><button className="analysis-button" onClick={closeEditor}>取消</button><button className="analysis-primary" onClick={apply}><Check size={13} />应用</button></div>
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
|
||||
export function AnalysisExportButton({ disabled, onExport }: {
|
||||
disabled?: boolean; onExport: () => void | Promise<void>;
|
||||
}) {
|
||||
const running = useRef(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const run = async () => {
|
||||
if (running.current) return;
|
||||
running.current = true; setBusy(true); setError('');
|
||||
try { await onExport(); }
|
||||
catch { setError('导出失败,请重试'); }
|
||||
finally { running.current = false; setBusy(false); }
|
||||
};
|
||||
return <span className="analysis-export-action">
|
||||
<button type="button" className="analysis-button" disabled={disabled || busy} onClick={run}>
|
||||
{busy ? <Loader2 size={13} className="animate-spin" /> : <Download size={13} />}{busy ? '正在导出…' : '导出 Excel'}
|
||||
</button>
|
||||
{error && <small role="alert">{error}</small>}
|
||||
</span>;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowLeft, BarChart3, Filter, Loader2, Search, X } from 'lucide-react';
|
||||
import { fetchJson } from '../../../../auth/api-client';
|
||||
import Blur from '../../../../components/Blur';
|
||||
import type { AssetAnalysisResponse, AssetAnalysisDimension } from '../../../../shared/assets/analytics';
|
||||
import { ANALYSIS_META, DIMENSION_LABELS, compareCustomLabels, getCustomAnalysis, getAvailableDimensions, getDefaultDimensions, getDimensionValueLabel, type AnalysisFilterValues, type CustomAnalysisRow, type AnalysisSort, type AnalysisView } from '../../analytics-model';
|
||||
import { dimensionChildCounts, dimensionCountUnit, dimensionParentTotals, dimensionRowSpans, orderDimensionGroups, visibleDimensionRowSpans } from '../../dimension-layout';
|
||||
import { AnalysisControls } from './AnalysisControls';
|
||||
import { DimensionPicker } from './DimensionPicker';
|
||||
import { ProgressiveRowsStatus } from './ProgressiveRowsStatus';
|
||||
import { useProgressiveRows } from './useProgressiveRows';
|
||||
import { buildVehicleModalRequest, formatLocalDateTime, type VehicleModalSelection } from '../../model';
|
||||
import { fetchWeeklyDetail, type WeeklyDetailItem } from '../../api';
|
||||
import type { VehicleListItem } from '../../types';
|
||||
import { AbnormalVehicleDetails, PendingVehicleDetails } from '../AbnormalVehicleDetails';
|
||||
import { useDialogFocus } from '../useDialogFocus';
|
||||
import { AnalysisExportButton } from './AnalysisExportButton';
|
||||
import { MobileAnalysisControls } from './MobileAnalysisControls';
|
||||
import { MobileGroupedTable } from './MobileGroupedTable';
|
||||
import { downloadAnalysisWorkbook, statisticsExportRows, vehicleExportRows } from '../../analysis-export';
|
||||
import { useMobileLayout } from '../../useMobileLayout';
|
||||
import './asset-analysis.css';
|
||||
import './mobile-analysis.css';
|
||||
|
||||
type Props = { selection: VehicleModalSelection | null; subject: string | null; close: () => void };
|
||||
const number = (value: number) => value.toLocaleString('zh-CN');
|
||||
function initialView(selection: VehicleModalSelection): AnalysisView {
|
||||
if (selection.category === 'Abnormal') return 'abnormal';
|
||||
if (selection.category === 'Inventory') return 'inventory';
|
||||
if (selection.category === 'Pending') return 'pending';
|
||||
if (selection.source === 'department') return 'department';
|
||||
if (selection.source === 'customer') return 'customer';
|
||||
if (selection.category === 'Operating') return 'operating';
|
||||
return 'assets';
|
||||
}
|
||||
|
||||
export function AssetAnalysisModal({ selection, subject, close }: Props) {
|
||||
if (!selection) return null;
|
||||
return <AnalysisDialog key={JSON.stringify([selection, subject])} selection={selection} subject={subject} close={close} />;
|
||||
}
|
||||
|
||||
function AnalysisDialog({ selection, subject, close }: Props & { selection: VehicleModalSelection }) {
|
||||
const mobile = useMobileLayout();
|
||||
const dialog = useRef<HTMLDivElement>(null);
|
||||
const scrollRoot = useRef<HTMLDivElement>(null);
|
||||
useDialogFocus(true, dialog, close);
|
||||
const [data, setData] = useState<AssetAnalysisResponse | null>(null);
|
||||
const [weekly, setWeekly] = useState<WeeklyDetailItem[]>([]);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [retry, setRetry] = useState(0);
|
||||
const view = initialView(selection);
|
||||
const [dimensions, setDimensions] = useState<AssetAnalysisDimension[]>(() => getDefaultDimensions(view));
|
||||
const [filterValues, setFilterValues] = useState<AnalysisFilterValues>({});
|
||||
const [sort, setSort] = useState<AnalysisSort>('count-desc');
|
||||
const [mergeParents, setMergeParents] = useState(true);
|
||||
const [detailGroup, setDetailGroup] = useState<CustomAnalysisRow | null>(null);
|
||||
const showDetails = detailGroup !== null;
|
||||
const overviewScroll = useRef(0);
|
||||
const openDetails = (row: CustomAnalysisRow) => { overviewScroll.current = scrollRoot.current?.scrollTop || 0; setDetailGroup(row); };
|
||||
useLayoutEffect(() => { if (scrollRoot.current) scrollRoot.current.scrollTop = showDetails ? 0 : overviewScroll.current; }, [showDetails]);
|
||||
const [vehicles, setVehicles] = useState<VehicleListItem[]>([]);
|
||||
const [detailBusy, setDetailBusy] = useState(false);
|
||||
const [detailError, setDetailError] = useState('');
|
||||
const [detailRetry, setDetailRetry] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const request = useMemo(() => buildVehicleModalRequest(selection, subject), [selection, subject]);
|
||||
const endpoint = `/api/vehicles/analysis${subject ? `?subject=${encodeURIComponent(subject)}` : ''}`;
|
||||
const available = getAvailableDimensions(view);
|
||||
const analysis = useMemo(() => getCustomAnalysis(data?.cells ?? [], { view, dimensions, filterValues, sort }), [data, view, dimensions, filterValues, sort]);
|
||||
const filterValuesKey = JSON.stringify(filterValues);
|
||||
const detailFiltersKey = JSON.stringify(detailGroup?.values ?? {});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setBusy(true); setError('');
|
||||
const load = request.kind === 'weekly'
|
||||
? fetchWeeklyDetail(request.type, { ...request.filters, subject }).then(value => { if (!cancelled) setWeekly(value); })
|
||||
: fetchJson<AssetAnalysisResponse>(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope: request.params }), signal: controller.signal }).then(value => { if (!cancelled) setData(value); });
|
||||
load.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : '统计加载失败'); })
|
||||
.finally(() => { if (!cancelled) setBusy(false); });
|
||||
return () => { cancelled = true; controller.abort(); };
|
||||
}, [request, subject, endpoint, retry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDetails || request.kind !== 'vehicles' || !data) return;
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
setDetailBusy(true); setDetailError(''); setVehicles([]); setSearch('');
|
||||
fetchJson<VehicleListItem[]>(endpoint.replace('/analysis', '/analysis/details'), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: controller.signal,
|
||||
body: JSON.stringify({ metric: analysis.metric, filters: JSON.parse(detailFiltersKey), filterValues: JSON.parse(filterValuesKey), generatedAt: data.generatedAt, scope: request.params }),
|
||||
}).then(value => { if (!cancelled) setVehicles(value); })
|
||||
.catch(e => { if (!cancelled) setDetailError(e instanceof Error ? e.message : '车辆明细加载失败'); })
|
||||
.finally(() => { if (!cancelled) setDetailBusy(false); });
|
||||
return () => { cancelled = true; controller.abort(); };
|
||||
}, [showDetails, analysis.metric, detailFiltersKey, filterValuesKey, request, data, endpoint, detailRetry]);
|
||||
|
||||
const visibleVehicles = useMemo(() => {
|
||||
const query = search.toLocaleLowerCase();
|
||||
return vehicles.filter(vehicle => !query || [vehicle.plateNumber, vehicle.vin, vehicle.model, vehicle.city].some(value => value?.toLocaleLowerCase().includes(query)));
|
||||
}, [vehicles, search]);
|
||||
const detailProgress = useProgressiveRows({ total: visibleVehicles.length, rootRef: scrollRoot, resetKey: visibleVehicles, enabled: showDetails && !detailBusy && !busy, batchSize: 40 });
|
||||
const shownVehicles = useMemo(() => visibleVehicles.slice(0, detailProgress.visibleCount), [visibleVehicles, detailProgress.visibleCount]);
|
||||
const updateDimensions = (next: AssetAnalysisDimension[]) => { setDimensions(next); };
|
||||
const updateFilters = (next: AnalysisFilterValues) => { setFilterValues(next); };
|
||||
const reset = () => { updateDimensions(getDefaultDimensions(view)); updateFilters({}); setMergeParents(true); };
|
||||
const narrowToGroup = (row: CustomAnalysisRow) => {
|
||||
updateFilters({ ...filterValues, ...Object.fromEntries(Object.entries(row.values).map(([dimension, value]) => [dimension, [value]])) });
|
||||
};
|
||||
const merged = mergeParents && analysis.dimensions.length > 1;
|
||||
const orderedGroups = useMemo(() => merged
|
||||
? orderDimensionGroups(analysis.rows, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || ''), row => row.count, sort, (a, b, index) => compareCustomLabels(a, b, [analysis.dimensions[index]]))
|
||||
: analysis.rows, [analysis, merged, sort]);
|
||||
const parentTotals = useMemo(() => merged ? dimensionParentTotals(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || ''), row => row.count) : [], [orderedGroups, analysis.dimensions, merged]);
|
||||
const childCounts = useMemo(() => merged ? dimensionChildCounts(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || '')) : [], [orderedGroups, analysis.dimensions, merged]);
|
||||
const groupProgress = useProgressiveRows({ total: orderedGroups.length, rootRef: scrollRoot, resetKey: orderedGroups, enabled: !showDetails && !busy, batchSize: 40, preserveOnDisable: true });
|
||||
const shownGroups = useMemo(() => orderedGroups.slice(0, groupProgress.visibleCount), [orderedGroups, groupProgress.visibleCount]);
|
||||
const completeSpans = useMemo(() => merged ? dimensionRowSpans(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || '')) : [], [merged, orderedGroups, analysis.dimensions]);
|
||||
const spans = useMemo(() => visibleDimensionRowSpans(completeSpans, groupProgress.visibleCount), [completeSpans, groupProgress.visibleCount]);
|
||||
const title = request.kind === 'weekly' ? '交还车统计分析' : ANALYSIS_META[view].title;
|
||||
const scopeLabel = selection.scopeLabel || [subject || '全部公司', selection.title].filter(Boolean).join(' · ');
|
||||
const exportWorkbook = async () => {
|
||||
const rows = showDetails ? visibleVehicles : await fetchJson<VehicleListItem[]>(endpoint.replace('/analysis', '/analysis/details'), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ metric: analysis.metric, filters: {}, filterValues, generatedAt: data?.generatedAt, scope: request.kind === 'vehicles' ? request.params : {} }),
|
||||
});
|
||||
const exportGroups = detailGroup ? [{ ...detailGroup, count: rows.length, totals: {
|
||||
pending: rows.filter(v => v.status === 'Pending').length,
|
||||
abnormal: rows.filter(v => v.status === 'Abnormal').length,
|
||||
} }] : orderedGroups;
|
||||
const statistics = statisticsExportRows(
|
||||
analysis.dimensions.map(d => DIMENSION_LABELS[d]),
|
||||
exportGroups.map(row => ({ values: analysis.dimensions.map(d => getDimensionValueLabel(d, row.values[d] || '')), count: row.count,
|
||||
extra: view === 'inventory' ? { 待交车: row.totals.pending, 异动: row.totals.abnormal } : undefined })),
|
||||
showDetails ? rows.length : analysis.total,
|
||||
);
|
||||
await downloadAnalysisWorkbook(statistics, vehicleExportRows(rows, view === 'inventory'), `${title}-${scopeLabel}${detailGroup ? `-${detailGroup.label}` : ''}`);
|
||||
};
|
||||
return <div className="asset-analysis-overlay">
|
||||
<div ref={dialog} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1} className={`asset-analysis-dialog ${view === 'abnormal' ? 'analysis-amber' : ''}`}>
|
||||
{(!mobile || request.kind !== 'weekly' || busy || error) && <header className="analysis-header">{mobile && <button type="button" aria-label={showDetails ? "返回统计分析" : "返回资产管理"} onClick={() => showDetails ? setDetailGroup(null) : close()}><ArrowLeft size={20} /></button>}<div><h2><BarChart3 size={16} />{title}</h2><p>{scopeLabel}</p></div><button type="button" aria-label="关闭统计分析" onClick={close}><X size={18} /></button></header>}
|
||||
{busy ? <div className="analysis-message" role="status"><Loader2 className="animate-spin" />正在汇总统计数据…</div> : error ? <div className="analysis-message" role="alert"><p>统计数据加载失败:{error}</p><button className="analysis-button" onClick={() => setRetry(retry + 1)}>重新加载</button></div> : request.kind === 'weekly' ? <WeeklyAnalysis rows={weekly} title={title} scopeLabel={scopeLabel} close={close} /> : <>
|
||||
{showDetails && !mobile && <div className="analysis-navigation"><button className="analysis-back" onClick={() => setDetailGroup(null)}><ArrowLeft size={14} />返回统计分析</button><span className="analysis-detail-scope">{detailGroup.label}</span></div>}
|
||||
<div ref={scrollRoot} className="analysis-scroll">
|
||||
{!showDetails && <>
|
||||
{!mobile && <AnalysisControls view={view} cells={data?.cells ?? []} available={available} dimensions={dimensions} setDimensions={updateDimensions} filters={filterValues} setFilters={updateFilters} reset={reset} />}
|
||||
<div className="analysis-result-bar" role="status" aria-live="polite"><span>当前结果 <strong>{number(analysis.total)}</strong> 台</span><span>{analysis.rows.length} 个分组</span><span className="analysis-result-dimensions">{analysis.dimensions.length ? analysis.dimensions.map(dimension => DIMENSION_LABELS[dimension]).join(' × ') : '仅汇总,不分组'}</span></div>
|
||||
{view === 'inventory' && <div className="analysis-inventory-subsets" aria-label="库存子集统计"><span className="analysis-pending-count">待交车 <strong>{number(analysis.summary.pending)}</strong> 台</span><span className="analysis-abnormal-count">异动 <strong>{number(analysis.summary.abnormal)}</strong> 台</span><small>已包含在当前库存数量中</small></div>}
|
||||
{mobile && (analysis.total > 0 || Object.keys(filterValues).length > 0) && <AnalysisControls view={view} cells={data?.cells ?? []} available={available} dimensions={dimensions} setDimensions={updateDimensions} filters={filterValues} setFilters={updateFilters} reset={reset} />}
|
||||
{!mobile && view === 'abnormal' && <p className="analysis-definition">已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}</p>}
|
||||
<div className={`analysis-table-tools analysis-overview-tools${!analysis.total ? ' is-empty' : ''}`}><p>{merged ? '按层级汇总数量排序,相同上级合并展示。' : '筛选分组可继续分析,查看明细可核对车辆。'}</p><div className="analysis-table-options"><AnalysisExportButton disabled={!analysis.total} onExport={exportWorkbook} />{analysis.dimensions.length > 1 && <label className="analysis-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label>}<label>排序<select aria-label="统计分组排序" value={sort} onChange={event => { setSort(event.target.value as AnalysisSort); }}><option value="count-desc">数量从多到少</option><option value="count-asc">数量从少到多</option><option value="label">维度顺序</option></select></label></div></div>
|
||||
{analysis.total === 0 ? <div className="analysis-message"><BarChart3 size={22} /><h3>{Object.keys(filterValues).length ? '没有符合条件的数据' : view === 'abnormal' ? '当前无异动车辆' : view === 'pending' ? '当前无待交车辆' : '当前范围暂无车辆'}</h3><p>{Object.keys(filterValues).length ? '可移除筛选条件后重新查看。' : '当前可查看范围内没有相关记录。'}</p>{Object.keys(filterValues).length > 0 && <button className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div> : <>
|
||||
{mobile ? <MobileGroupedTable dimensions={analysis.dimensions.map(d => DIMENSION_LABELS[d])} unit="台" merged={merged} setMerged={setMergeParents} spans={spans} parentTotals={parentTotals}
|
||||
rows={shownGroups.map(row => ({ key: row.key, label: row.label, values: analysis.dimensions.map(d => <Blur key={d}>{getDimensionValueLabel(d, row.values[d] || '')}</Blur>), count: row.count, share: row.share, note: view === 'inventory' && (row.totals.pending || row.totals.abnormal) ? [row.totals.pending ? `待交 ${row.totals.pending}` : '', row.totals.abnormal ? `异动 ${row.totals.abnormal}` : ''].filter(Boolean).join(' · ') : undefined }))}
|
||||
onDetails={index => openDetails(shownGroups[index])} onFilter={index => narrowToGroup(shownGroups[index])} /> : <>
|
||||
<div className={`analysis-group-table${merged ? ' is-merged' : ''}`} role="region" aria-label="组合统计表" tabIndex={0}><table>
|
||||
<thead><tr>{analysis.dimensions.length ? analysis.dimensions.map(dimension => <th key={dimension}>{DIMENSION_LABELS[dimension]}</th>) : <th>统计范围</th>}<th className="analysis-numeric">数量(台)</th>{view === 'inventory' && <><th className="analysis-numeric">待交车</th><th className="analysis-numeric">异动</th></>}<th className="analysis-numeric">占比</th><th>分析操作</th></tr></thead>
|
||||
<tbody>{shownGroups.map((row, rowIndex) => <tr key={row.key} className={merged && spans[rowIndex][0] > 0 ? 'analysis-group-start' : undefined}>
|
||||
{analysis.dimensions.length ? analysis.dimensions.map((dimension, columnIndex) => {
|
||||
const span = merged ? spans[rowIndex][columnIndex] : 1;
|
||||
return span === 0 ? null : <td key={dimension} rowSpan={span} className={span > 1 ? 'analysis-parent-cell' : undefined} data-label={DIMENSION_LABELS[dimension]}><Blur>{getDimensionValueLabel(dimension, row.values[dimension] || '')}</Blur>{merged && columnIndex < analysis.dimensions.length - 1 && childCounts[rowIndex][columnIndex] > 1 && <><small className="analysis-parent-total" title="当前筛选范围内该分组的完整合计(全部筛选结果)">合计 <strong>{number(parentTotals[rowIndex][columnIndex])}</strong> 台</small><small className="analysis-parent-children" title="当前筛选范围内下一级维度的去重分组数(全部筛选结果)">{DIMENSION_LABELS[analysis.dimensions[columnIndex + 1]]}:<strong>{number(childCounts[rowIndex][columnIndex])}</strong> {dimensionCountUnit(analysis.dimensions[columnIndex + 1])}</small></>}</td>;
|
||||
}) : <td data-label="统计范围">当前筛选结果</td>}
|
||||
<td className="analysis-numeric" data-label="数量(台)"><strong>{number(row.count)}</strong></td>
|
||||
{view === 'inventory' && <><td className={`analysis-numeric ${row.totals.pending ? 'analysis-pending-count' : 'analysis-zero-count'}`} data-label="待交车(台)">{number(row.totals.pending)}</td><td className={`analysis-numeric ${row.totals.abnormal ? 'analysis-abnormal-count' : 'analysis-zero-count'}`} data-label="异动(台)">{number(row.totals.abnormal)}</td></>}
|
||||
<td className="analysis-share-cell" data-label="占比"><span>{row.share.toFixed(1)}%</span><span className="analysis-bar-track" aria-hidden="true"><span style={{ width: `${row.share}%` }} /></span></td>
|
||||
<td className="analysis-group-actions">{analysis.dimensions.length > 0 && <button aria-label={`筛选分组 ${row.label}`} onClick={() => narrowToGroup(row)}><Filter size={12} />筛选此组</button>}<button aria-label={`查看明细 ${row.label}`} onClick={() => openDetails(row)}>查看明细</button></td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
</>}
|
||||
<div className="analysis-table-total"><span>合计 {number(analysis.total)} 台{view === 'inventory' && ` · 待交车 ${number(analysis.summary.pending)} 台 · 异动 ${number(analysis.summary.abnormal)} 台`} · 占比基于当前筛选结果</span><span>100%</span></div>
|
||||
<ProgressiveRowsStatus {...groupProgress} unit="组" />
|
||||
</>}
|
||||
</>}
|
||||
{showDetails && <div className="analysis-details">{mobile && <p className="analysis-mobile-detail-scope">{detailGroup.label} · 当前分组</p>}
|
||||
<div className="analysis-table-tools"><h3>车辆明细 <small>{number(visibleVehicles.length)} 台</small></h3><AnalysisExportButton disabled={detailBusy || !!detailError || !visibleVehicles.length} onExport={exportWorkbook} /><label className="analysis-search"><Search size={15} /><input aria-label="搜索车辆明细" placeholder="搜索车牌、VIN、型号、城市" value={search} onChange={event => { setSearch(event.target.value); }} /></label></div>
|
||||
{detailBusy ? <div className="analysis-message" role="status"><Loader2 className="animate-spin" />正在加载车辆明细…</div> : detailError ? <div className="analysis-message" role="alert"><p>{detailError}</p><button className="analysis-button" onClick={() => setDetailRetry(detailRetry + 1)}>重新加载明细</button></div> : <>
|
||||
{vehicles.length !== detailGroup.count && <p className="analysis-definition">车辆状态可能已更新:统计快照 {detailGroup.count} 台,当前明细 {vehicles.length} 台。</p>}
|
||||
{(view === 'abnormal' || detailGroup.values.inventoryState === 'abnormal' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'abnormal')) ? <AbnormalVehicleDetails vehicles={shownVehicles} /> : (view === 'pending' || detailGroup.values.inventoryState === 'pending' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'pending')) ? <PendingVehicleDetails vehicles={shownVehicles} /> : <VehicleRows vehicles={shownVehicles} inventory={view === 'inventory'} />}
|
||||
<ProgressiveRowsStatus {...detailProgress} unit="台" />
|
||||
</>}
|
||||
</div>}
|
||||
</div>
|
||||
<footer className="analysis-footer">{mobile ? <details><summary>统计口径与数据时间</summary><p>统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围</p><p>同维度筛选为“或”,不同维度为“且”。仅展示当前入口与筛选条件内的车辆。</p>{view === 'abnormal' && <p>已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}</p>}</details> : <><span>统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围</span><span>{showDetails ? '仅展示当前分组与筛选条件内的车辆' : '同维度筛选为“或”,不同维度筛选为“且”'}</span></>}</footer>
|
||||
</>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function VehicleRows({ vehicles, inventory = false }: { vehicles: VehicleListItem[]; inventory?: boolean }) {
|
||||
const mobile = useMobileLayout();
|
||||
if (!vehicles.length) return <div className="analysis-message">暂无匹配车辆</div>;
|
||||
const stateLabel = (vehicle: VehicleListItem) => {
|
||||
const operation = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' } as Record<string, string>)[vehicle.operationStatus || ''] || vehicle.operationStatus;
|
||||
const business = ({ Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' } as Record<string, string>)[vehicle.status] || vehicle.status;
|
||||
return [business, operation].filter(Boolean).join(' · ');
|
||||
};
|
||||
if (mobile) return <div className="analysis-vehicle-cards" aria-label="车辆明细列表">{vehicles.map(vehicle => <article className="analysis-vehicle-card" key={vehicle.id}>
|
||||
<header><h4><Blur>{vehicle.plateNumber || vehicle.vin || '未提供'}</Blur></h4><span>{stateLabel(vehicle)}</span></header>
|
||||
<p>{vehicle.model || `${vehicle.brandLabel || '—'} · ${vehicle.type}`} · {vehicle.city || vehicle.location || '未标注城市'}</p>
|
||||
{!inventory && vehicle.customerName && <p><Blur>{vehicle.customerName}</Blur></p>}
|
||||
<details><summary>车辆资料</summary><dl>{[['VIN', vehicle.vin], ['品牌 / 车型', `${vehicle.brandLabel || '—'} · ${vehicle.type}`], ['省份 / 城市', `${vehicle.province || '未标注'} / ${vehicle.city || vehicle.location || '未标注'}`], ...(inventory ? [['运维负责人', vehicle.opsManagers?.trim() || '-']] : [['签约公司', vehicle.orgName], ['部门', vehicle.departmentName], ['业务负责人', vehicle.customerManager]]), ['所属公司', vehicle.subjectOrg]].map(([label, value]) => <div key={label}><dt>{label}</dt><dd><Blur>{value || '未提供'}</Blur></dd></div>)}</dl></details>
|
||||
</article>)}</div>;
|
||||
return <div className="analysis-detail-table" tabIndex={0} role="region" aria-label="车辆明细表"><table><thead><tr>{['车牌 / VIN', '品牌 / 车型 / 型号', '省份 / 城市', '状态', ...(inventory ? ['运维负责人'] : ['客户 / 签约公司', '部门 / 业务负责人']), '所属公司'].map(label => <th key={label}>{label}</th>)}</tr></thead><tbody>{vehicles.map(vehicle => <tr key={vehicle.id}><td><Blur>{vehicle.plateNumber || vehicle.vin || '未提供'}</Blur></td><td>{vehicle.brandLabel || '—'} · {vehicle.type}<small>{vehicle.model}</small></td><td>{vehicle.province || '未标注'} / {vehicle.city || vehicle.location || '未标注'}</td><td>{stateLabel(vehicle)}</td>{inventory ? <td><Blur>{vehicle.opsManagers?.trim() || '-'}</Blur></td> : <><td><Blur>{vehicle.customerName || '—'}</Blur><small><Blur>{vehicle.orgName || '—'}</Blur></small></td><td><Blur>{vehicle.departmentName || '—'}</Blur><small><Blur>{vehicle.customerManager || '—'}</Blur></small></td></>}<td>{vehicle.subjectOrg || '—'}</td></tr>)}</tbody></table></div>;
|
||||
}
|
||||
|
||||
type WeeklyDimension = 'date' | 'contract_type';
|
||||
const WEEKLY_DIMENSIONS: WeeklyDimension[] = ['date', 'contract_type'];
|
||||
const EMPTY_WEEKLY_ROWS: WeeklyDetailItem[] = [];
|
||||
const weeklyDimensionValue = (row: WeeklyDetailItem, dimension: WeeklyDimension) => dimension === 'date'
|
||||
? row.handover_date?.slice(0, 10) || '' : row.contract_type?.trim() || '';
|
||||
|
||||
function WeeklyAnalysis({ rows, title, scopeLabel, close }: { rows: WeeklyDetailItem[]; title: string; scopeLabel: string; close: () => void }) {
|
||||
const mobile = useMobileLayout();
|
||||
type Dimension = WeeklyDimension;
|
||||
const dimensionsAvailable = WEEKLY_DIMENSIONS;
|
||||
const scrollRoot = useRef<HTMLDivElement>(null);
|
||||
const labels: Record<Dimension, string> = { date: '交还日期', contract_type: '合同类型' };
|
||||
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 value = weeklyDimensionValue;
|
||||
const label = (dimension: Dimension, entry: string) => entry || (dimension === 'date' ? '日期缺失' : '未标注');
|
||||
const filtered = useMemo(() => rows.filter(row => dimensionsAvailable.every(dimension => !filters[dimension]?.length || filters[dimension]!.includes(value(row, dimension)))), [rows, filters]);
|
||||
const grouped = useMemo(() => {
|
||||
const result = new Map<string, { key: string; values: string[]; rows: WeeklyDetailItem[] }>();
|
||||
for (const row of filtered) {
|
||||
const values = dimensions.map(dimension => value(row, dimension));
|
||||
const key = JSON.stringify(values);
|
||||
const group = result.get(key) ?? { key, values, rows: [] };
|
||||
group.rows.push(row);
|
||||
result.set(key, group);
|
||||
}
|
||||
return result;
|
||||
}, [filtered, dimensions]);
|
||||
const groups = useMemo(() => {
|
||||
const flatGroups = [...grouped.values()].sort((a, b) => b.rows.length - a.rows.length || a.key.localeCompare(b.key, 'zh-CN'));
|
||||
return mergeActive ? orderDimensionGroups(flatGroups, dimensions.length, group => group.values, group => group.rows.length) : flatGroups;
|
||||
}, [grouped, mergeActive, dimensions.length]);
|
||||
const detailGroup = detailKey === null ? null : grouped.get(detailKey);
|
||||
const detailRows = detailGroup?.rows ?? EMPTY_WEEKLY_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 groupLabel = (group: { values: string[] }) => dimensions.length
|
||||
? dimensions.map((dimension, index) => `${labels[dimension]}:${label(dimension, group.values[index])}`).join(' · ')
|
||||
: '当前筛选结果';
|
||||
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 updateFilters = (next: Partial<Record<Dimension, string[]>>) => { setFilters(next); setDetailKey(null); };
|
||||
const setFilter = (dimension: Dimension, selected: string[] | undefined) => {
|
||||
const next = { ...filters };
|
||||
if (selected === undefined) delete next[dimension]; else next[dimension] = selected;
|
||||
updateFilters(next);
|
||||
};
|
||||
const updateDimensions = (next: Dimension[]) => { setDimensions(next); setDetailKey(null); };
|
||||
const activeFilters = dimensionsAvailable.filter(dimension => filters[dimension]?.length);
|
||||
const filterOptions = useMemo(() => Object.fromEntries(dimensionsAvailable.map(dimension => {
|
||||
const eligible = rows.filter(row => dimensionsAvailable.every(other => other === dimension || !filters[other]?.length || filters[other]!.includes(value(row, other))));
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of eligible) { const entry = value(row, dimension); counts.set(entry, (counts.get(entry) || 0) + 1); }
|
||||
for (const entry of filters[dimension] || []) if (!counts.has(entry)) counts.set(entry, 0);
|
||||
return [dimension, [...counts].map(([entry, count]) => ({ value: entry, label: label(dimension, entry), count })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN'))];
|
||||
})), [rows, filters]);
|
||||
|
||||
const exportWorkbook = () => {
|
||||
const selectedRows = showDetails ? detailRows : filtered;
|
||||
const selectedGroups = detailGroup ? [detailGroup] : groups;
|
||||
const statistics = statisticsExportRows(dimensions.map(d => labels[d]), selectedGroups.map(group => ({ values: group.values.map((v, i) => label(dimensions[i], v)), count: group.rows.length })), selectedRows.length, '车次');
|
||||
const details = selectedRows.map(row => ({ 车牌: row.plate_number, 日期: row.handover_date, 合同类型: row.contract_type, 客户: row.customer_name }));
|
||||
return downloadAnalysisWorkbook(statistics, details, `交还车分析-${detailGroup ? groupLabel(detailGroup) : '当前筛选结果'}`);
|
||||
};
|
||||
if (mobile) return <>
|
||||
<header className="analysis-header"><button aria-label={showDetails ? '返回统计分析' : '返回资产管理'} onClick={() => showDetails ? setDetailKey(null) : close()}><ArrowLeft size={20} /></button><div><h2>{title}</h2><p>{scopeLabel}</p></div><button aria-label="关闭统计分析" onClick={close}><X size={18} /></button></header>
|
||||
<div ref={scrollRoot} className="analysis-scroll">
|
||||
<div className="analysis-result-bar" role="status"><span>当前结果 <strong>{number(showDetails ? detailRows.length : filtered.length)}</strong> 车次</span>{!showDetails && <span>{groups.length} 个分组</span>}</div>
|
||||
{!showDetails ? <>
|
||||
{(filtered.length > 0 || activeFilters.length > 0) && <MobileAnalysisControls available={dimensionsAvailable.map(key => ({ key, label: labels[key], options: filterOptions[key] }))} dimensions={dimensions} filters={filters} defaults={['date']} apply={(nextDimensions, nextFilters) => { updateDimensions(nextDimensions as Dimension[]); updateFilters(nextFilters); }} />}
|
||||
{filtered.length > 0 && <div className="analysis-table-tools"><AnalysisExportButton onExport={exportWorkbook} /></div>}
|
||||
{groups.length ? <><MobileGroupedTable dimensions={dimensions.map(d => labels[d])} unit="车次" merged={mergeActive} setMerged={setMergeParents} spans={groupSpans} parentTotals={parentTotals}
|
||||
rows={shownGroups.map(group => ({ key: group.key, label: groupLabel(group), values: group.values.map((entry, i) => label(dimensions[i], entry)), count: group.rows.length, share: group.rows.length / filtered.length * 100 }))}
|
||||
onDetails={index => openDetails(shownGroups[index].key)} onFilter={index => updateFilters({ ...filters, ...Object.fromEntries(dimensions.map((dimension, i) => [dimension, [shownGroups[index].values[i]]])) })} /><ProgressiveRowsStatus {...groupProgress} unit="组" /></> : <div className="analysis-message"><p>{activeFilters.length ? '没有符合条件的数据,可调整筛选。' : '当前范围暂无交还车记录。'}</p>{activeFilters.length > 0 && <button className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div>}
|
||||
</> : <>
|
||||
<p className="analysis-mobile-detail-scope">{detailGroup ? groupLabel(detailGroup) : '当前分组'}</p>
|
||||
<div className="analysis-table-tools"><h3>交还车业务明细</h3><AnalysisExportButton disabled={!detailRows.length} onExport={exportWorkbook} /></div>
|
||||
<div className="analysis-vehicle-cards">{shownDetails.map((row, index) => <article className="analysis-vehicle-card" key={`${row.truck_id}-${index}`}><header><h4><Blur>{row.plate_number || '未登记车牌'}</Blur></h4><span>{row.contract_type || '未标注合同类型'}</span></header><p>{row.handover_date || '日期缺失'}</p><p><Blur>{row.customer_name || '未提供客户'}</Blur></p></article>)}</div>
|
||||
<ProgressiveRowsStatus {...detailProgress} unit="条" />
|
||||
</>}
|
||||
</div>
|
||||
<footer className="analysis-footer"><details><summary>统计口径</summary><p>按当前入口的周业务记录统计车次,同一车辆多笔记录分别计次。仅展示当前可查看范围和筛选条件内的数据。</p></details></footer>
|
||||
</>;
|
||||
return <div ref={scrollRoot} className="analysis-scroll">
|
||||
{!showDetails ? <>
|
||||
<div className="analysis-builder">
|
||||
<div className="analysis-builder-heading"><h3>分析维度</h3><span>点击名称分组,点击箭头筛选</span><button type="button" className="analysis-reset" onClick={() => { updateDimensions(['date']); updateFilters({}); setMergeParents(true); }}>恢复默认</button></div>
|
||||
<div className="adp-row" role="group" aria-label="交还车统计维度">
|
||||
{dimensionsAvailable.map(dimension => {
|
||||
const options = filterOptions[dimension];
|
||||
return <DimensionPicker key={dimension} label={labels[dimension]} grouped={dimensions.includes(dimension)} order={dimensions.indexOf(dimension) + 1} options={options} selectedValues={filters[dimension]} onToggleGroup={() => updateDimensions(dimensions.includes(dimension) ? dimensions.filter(item => item !== dimension) : [...dimensions, dimension])} onApplyFilter={selected => setFilter(dimension, selected)} />;
|
||||
})}
|
||||
<button type="button" className="analysis-button" aria-pressed={dimensions.length === 0} onClick={() => updateDimensions([])}>仅筛选</button>
|
||||
{activeFilters.length > 0 && <button type="button" className="analysis-text-button" onClick={() => updateFilters({})}>清空筛选</button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="analysis-result-bar" role="status" aria-live="polite"><span>当前结果 <strong>{number(filtered.length)}</strong> 车次</span><span>{groups.length} 个分组</span><span className="analysis-result-dimensions">{dimensions.length ? dimensions.map(dimension => labels[dimension]).join(' × ') : '仅汇总,不分组'}</span></div>
|
||||
<div className="analysis-table-tools"><p>筛选分组可继续分析,查看明细可核对业务记录。</p><div className="analysis-export-actions"><AnalysisExportButton disabled={!filtered.length} onExport={exportWorkbook} /></div>{dimensions.length > 1 && <label className="analysis-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label>}</div>
|
||||
{groups.length ? <>
|
||||
<div className={`analysis-group-table${mergeActive ? ' is-merged' : ''}`} role="region" aria-label="交还车组合统计表" tabIndex={0}><table><thead><tr>{dimensions.length ? dimensions.map(dimension => <th key={dimension}>{labels[dimension]}</th>) : <th>统计范围</th>}<th className="analysis-numeric">数量(车次)</th><th className="analysis-numeric">占比</th><th>分析操作</th></tr></thead><tbody>
|
||||
{shownGroups.map((group, rowIndex) => <tr key={group.key} className={mergeActive && groupSpans[rowIndex][0] > 0 ? 'analysis-group-start' : undefined}>
|
||||
{dimensions.length ? dimensions.map((dimension, index) => {
|
||||
const span = mergeActive ? groupSpans[rowIndex][index] : 1;
|
||||
return span === 0 ? null : <td data-label={labels[dimension]} key={dimension} rowSpan={span > 1 ? span : undefined} className={span > 1 ? 'analysis-parent-cell' : undefined}>{label(dimension, group.values[index])}{mergeActive && index < dimensions.length - 1 && childCounts[rowIndex][index] > 1 && <><small className="analysis-parent-total" title="当前筛选范围内该分组的完整合计(全部筛选结果)">合计 <strong>{number(parentTotals[rowIndex][index])}</strong> 车次</small><small className="analysis-parent-children" title="当前筛选范围内下一级维度的去重分组数(全部筛选结果)">{labels[dimensions[index + 1]]}:<strong>{number(childCounts[rowIndex][index])}</strong> {dimensionCountUnit(dimensions[index + 1])}</small></>}</td>;
|
||||
}) : <td data-label="统计范围">当前筛选结果</td>}
|
||||
<td className="analysis-numeric" data-label="数量(车次)"><strong>{number(group.rows.length)}</strong></td><td className="analysis-share-cell" data-label="占比"><span>{(group.rows.length / filtered.length * 100).toFixed(1)}%</span><span className="analysis-bar-track" aria-hidden="true"><span style={{ width: `${group.rows.length / filtered.length * 100}%` }} /></span></td>
|
||||
<td className="analysis-group-actions">{dimensions.length > 0 && <button type="button" aria-label={`筛选分组 ${groupLabel(group)}`} onClick={() => updateFilters({ ...filters, ...Object.fromEntries(dimensions.map((dimension, index) => [dimension, [group.values[index]]])) })}><Filter size={12} />筛选此组</button>}<button type="button" aria-label={`查看明细 ${groupLabel(group)}`} onClick={() => { setDetailKey(group.key); }}>查看明细</button></td>
|
||||
</tr>)}
|
||||
</tbody></table></div>
|
||||
<div className="analysis-table-total"><span>合计 {number(filtered.length)} 车次 · 占比基于当前筛选结果</span><span>100%</span></div>
|
||||
<ProgressiveRowsStatus {...groupProgress} unit="组" />
|
||||
</> : <div className="analysis-message"><p>没有符合条件的数据,请调整筛选。</p>{activeFilters.length > 0 && <button type="button" className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div>}
|
||||
</> : <>
|
||||
<div className="analysis-navigation"><button type="button" className="analysis-back" onClick={() => { setDetailKey(null); }}><ArrowLeft size={14} />返回统计分析</button><span className="analysis-detail-scope">{detailGroup ? groupLabel(detailGroup) : '当前分组'}</span></div>
|
||||
<div className="analysis-table-tools"><h3>交还车业务明细 <small>{number(detailRows.length)} 车次</small></h3><AnalysisExportButton disabled={!detailRows.length} onExport={exportWorkbook} /></div>
|
||||
{detailRows.length ? <><div className="analysis-detail-table" role="region" aria-label="交还车业务明细表" tabIndex={0}><table><thead><tr><th>车牌</th><th>日期</th><th>合同类型</th><th>客户</th></tr></thead><tbody>{shownDetails.map((row, index) => <tr key={`${row.truck_id}-${index}`}><td><Blur>{row.plate_number || '—'}</Blur></td><td>{row.handover_date || '—'}</td><td>{row.contract_type || '—'}</td><td><Blur>{row.customer_name || '—'}</Blur></td></tr>)}</tbody></table></div>
|
||||
<ProgressiveRowsStatus {...detailProgress} unit="条" />
|
||||
</> : <p className="analysis-message">当前分组暂无数据,请返回统计分析。</p>}
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { ChevronDown, Filter, Search, X } from 'lucide-react';
|
||||
import './dimension-picker.css';
|
||||
|
||||
export interface DimensionPickerOption { value: string; label: string; count?: number }
|
||||
export interface DimensionPickerProps {
|
||||
label: string;
|
||||
grouped: boolean;
|
||||
order?: number;
|
||||
options: DimensionPickerOption[];
|
||||
selectedValues?: string[];
|
||||
onToggleGroup: () => void;
|
||||
onApplyFilter: (values: string[] | undefined) => void;
|
||||
}
|
||||
|
||||
/** A grouping switch and an independently applied, searchable multi-value filter. */
|
||||
export function DimensionPicker({ label, grouped, order, options, selectedValues, onToggleGroup, onApplyFilter }: DimensionPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<string[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [position, setPosition] = useState({ left: 8, top: 8, maxHeight: 420 });
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const search = useRef<HTMLInputElement>(null);
|
||||
const menuId = useId();
|
||||
const selected = selectedValues ?? [];
|
||||
const labels = new Map(options.map(option => [option.value, option.label]));
|
||||
const selectedLabel = selected.map(value => labels.get(value) || value || '未标注').join('、');
|
||||
const visible = options.filter(option => option.label.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
|
||||
const close = (restoreFocus = true) => { setOpen(false); if (restoreFocus) trigger.current?.focus(); };
|
||||
const show = () => {
|
||||
if (open) { close(); return; }
|
||||
setDraft([...selected]); setQuery(''); setOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const place = () => {
|
||||
const rect = trigger.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const width = Math.min(320, window.innerWidth - 16);
|
||||
const roomBelow = window.innerHeight - rect.bottom - 12;
|
||||
const useAbove = roomBelow < 220 && rect.top > roomBelow;
|
||||
const height = Math.min(420, Math.max(160, useAbove ? rect.top - 12 : roomBelow));
|
||||
setPosition({ left: Math.max(8, Math.min(rect.left, window.innerWidth - width - 8)), top: useAbove ? Math.max(8, rect.top - height - 5) : rect.bottom + 5, maxHeight: height });
|
||||
};
|
||||
place();
|
||||
const frame = requestAnimationFrame(() => search.current?.focus());
|
||||
const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setOpen(false); };
|
||||
document.addEventListener('pointerdown', outside);
|
||||
window.addEventListener('resize', place);
|
||||
window.addEventListener('scroll', place, true);
|
||||
return () => { cancelAnimationFrame(frame); document.removeEventListener('pointerdown', outside); window.removeEventListener('resize', place); window.removeEventListener('scroll', place, true); };
|
||||
}, [open]);
|
||||
|
||||
return <div ref={root} className={`analysis-dimension-picker${grouped ? ' adp-grouped' : ''}${selected.length ? ' adp-filtered' : ''}`}>
|
||||
<div className="adp-split">
|
||||
<button type="button" className="adp-name" aria-pressed={grouped} onClick={() => { setOpen(false); onToggleGroup(); }} title={`点击${grouped ? '取消' : '加入'}${label}分组`}>
|
||||
{grouped && order !== undefined ? <span className="adp-order">{order}</span> : null}{label}
|
||||
</button>
|
||||
<button ref={trigger} type="button" className="adp-arrow" aria-label={`筛选${label}`} aria-expanded={open} aria-controls={open ? menuId : undefined} aria-haspopup="dialog" onClick={show} title={selected.length ? `${label}已筛选 ${selected.length} 项:${selectedLabel}` : `筛选${label}`}>
|
||||
{selected.length ? <><Filter size={11} /><span>{selected.length}</span></> : null}<ChevronDown size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{selected.length ? <button type="button" className="adp-selected" onClick={show} title={selectedLabel}>{selected.length === 1 ? selectedLabel : `${selectedLabel}(${selected.length} 项)`}</button> : null}
|
||||
{open ? <div id={menuId} role="dialog" aria-label={`${label}筛选菜单`} className="adp-menu" style={{ left: position.left, top: position.top, maxHeight: position.maxHeight }} onKeyDown={event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(); } }}>
|
||||
<div className="adp-menu-heading"><strong>{label}</strong><button type="button" aria-label={`关闭${label}筛选`} onClick={() => close()}><X size={14} /></button></div>
|
||||
<label className="adp-search"><Search size={13} /><input ref={search} aria-label={`搜索${label}筛选值`} value={query} onChange={event => setQuery(event.target.value)} placeholder="搜索选项" /></label>
|
||||
<div className="adp-select-tools"><button type="button" disabled={!visible.length} onClick={() => setDraft(current => [...new Set([...current, ...visible.map(option => option.value)])])}>全选搜索结果</button><button type="button" onClick={() => setDraft([])}>清除选择</button><span>{draft.length} 项已选</span></div>
|
||||
<div className="adp-options" role="group" aria-label={`${label}可选值`}>
|
||||
{visible.map(option => <label key={option.value}><input type="checkbox" checked={draft.includes(option.value)} onChange={event => setDraft(current => event.target.checked ? [...current, option.value] : current.filter(value => value !== option.value))} /><span>{option.label}</span>{option.count !== undefined ? <small>{option.count.toLocaleString('zh-CN')}</small> : null}</label>)}
|
||||
{!visible.length ? <p>没有匹配选项</p> : null}
|
||||
</div>
|
||||
<div className="adp-menu-footer"><span>同维度多选为“或”;未选表示不限</span><div><button type="button" onClick={() => close()}>取消</button><button type="button" className="adp-apply" onClick={() => { onApplyFilter(draft.length ? draft : undefined); close(); }}>应用筛选</button></div></div>
|
||||
</div> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Check, ChevronDown, Filter, Search, X } from 'lucide-react';
|
||||
|
||||
export type MobileDimension = { key: string; label: string; options: { value: string; label: string; count?: number }[] };
|
||||
type Filters = Partial<Record<string, string[]>>;
|
||||
type Props = {
|
||||
available: MobileDimension[];
|
||||
dimensions: string[];
|
||||
filters: Filters;
|
||||
defaults: string[];
|
||||
apply: (dimensions: string[], filters: Filters) => void;
|
||||
};
|
||||
|
||||
/** Draft all mobile settings together; cancelling never changes the current result. */
|
||||
export function MobileAnalysisControls({ available, dimensions, filters, defaults, apply }: Props) {
|
||||
const [mode, setMode] = useState<'group' | 'filter' | null>(null);
|
||||
const [draftDimensions, setDraftDimensions] = useState(dimensions);
|
||||
const [draftFilters, setDraftFilters] = useState(filters);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const trigger = useRef<HTMLButtonElement | null>(null);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const id = useId();
|
||||
const selected = available.filter(d => filters[d.key]?.length);
|
||||
const editor = available.find(d => d.key === editing);
|
||||
const options = editor?.options.filter(o => o.label.toLocaleLowerCase().includes(query.toLocaleLowerCase())) || [];
|
||||
const close = () => { setMode(null); trigger.current?.focus(); };
|
||||
const open = (next: 'group' | 'filter', button: HTMLButtonElement) => {
|
||||
trigger.current = button; setDraftDimensions([...dimensions]); setDraftFilters({ ...filters });
|
||||
setEditing(null); setQuery(''); setMode(next);
|
||||
requestAnimationFrame(() => panel.current?.focus());
|
||||
};
|
||||
const toggle = (key: string, value: string) => {
|
||||
const values = draftFilters[key] || [];
|
||||
setDraftFilters({ ...draftFilters, [key]: values.includes(value) ? values.filter(v => v !== value) : [...values, value] });
|
||||
};
|
||||
return <div className="analysis-mobile-controls">
|
||||
<div className="analysis-mobile-dimension-heading"><strong>分析维度</strong><span>可多选 · 左右滑动</span><button onClick={e => open('group', e.currentTarget)} aria-expanded={mode === 'group'} aria-controls={id}>分组设置</button></div>
|
||||
<div className="analysis-mobile-toolbar">
|
||||
<div className="analysis-dimension-strip" role="group" aria-label="分析维度">
|
||||
{available.map(d => <button key={d.key} type="button" aria-pressed={dimensions.includes(d.key)} onClick={() => apply(dimensions.includes(d.key) ? dimensions.filter(key => key !== d.key) : [...dimensions, d.key], filters)}>{d.label}{dimensions.includes(d.key) && <small>{dimensions.indexOf(d.key) + 1}</small>}</button>)}
|
||||
<button type="button" aria-pressed={!dimensions.length} onClick={() => apply([], filters)}>仅汇总</button>
|
||||
</div>
|
||||
<button type="button" onClick={e => open('filter', e.currentTarget)} aria-expanded={mode === 'filter'} aria-controls={id}><Filter size={16} />筛选{selected.length ? ` ${selected.length}` : ''}</button>
|
||||
</div>
|
||||
{!!selected.length && <div className="analysis-mobile-chips" aria-label="已选筛选条件">{selected.map(d => <button key={d.key} title={(filters[d.key] || []).map(v => d.options.find(o => o.value === v)?.label || v).join('、')} aria-label={`移除${d.label}筛选`} onClick={() => { const next = { ...filters }; delete next[d.key]; apply(dimensions, next); }}><span>{d.label}:{filters[d.key]?.length === 1 ? d.options.find(o => o.value === filters[d.key]?.[0])?.label || filters[d.key]?.[0] : `${filters[d.key]?.length}项`}</span><X size={14} /></button>)}</div>}
|
||||
{mode && <div className="analysis-settings-backdrop" onClick={e => { if (e.target === e.currentTarget) close(); }}>
|
||||
<div ref={panel} id={id} role="dialog" aria-modal="true" aria-label={mode === 'group' ? '分组设置' : '筛选条件'} tabIndex={-1} className="analysis-settings-panel" onKeyDown={e => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); if (editing) { setEditing(null); setQuery(''); } else close(); }
|
||||
if (e.key === 'Tab') {
|
||||
e.stopPropagation();
|
||||
const items = Array.from(panel.current?.querySelectorAll<HTMLElement>('button:not([disabled]),input') || []).filter(el => el.getClientRects().length);
|
||||
const first = items[0], last = items.at(-1);
|
||||
if (e.shiftKey && (document.activeElement === first || document.activeElement === panel.current)) { e.preventDefault(); last?.focus(); }
|
||||
else if (!e.shiftKey && (document.activeElement === last || document.activeElement === panel.current)) { e.preventDefault(); first?.focus(); }
|
||||
}
|
||||
}}>
|
||||
<header><button aria-label={editing ? '返回筛选条件' : '取消设置'} onClick={() => editing ? (setEditing(null), setQuery('')) : close()}><ArrowLeft size={20} /></button><h3>{editor?.label || (mode === 'group' ? '分组设置' : '筛选条件')}</h3><button aria-label="关闭设置" onClick={close}><X size={20} /></button></header>
|
||||
{editor && <label className="analysis-settings-search"><Search size={17} /><input aria-label={`搜索${editor.label}`} placeholder={`搜索${editor.label}`} value={query} onChange={e => setQuery(e.target.value)} /></label>}
|
||||
<div className="analysis-settings-content">
|
||||
{mode === 'group' ? <><p>可选择多个维度,按选择顺序组合统计。</p><div className="analysis-settings-groups">{available.map(d => <button key={d.key} aria-pressed={draftDimensions.includes(d.key)} onClick={() => setDraftDimensions(draftDimensions.includes(d.key) ? draftDimensions.filter(v => v !== d.key) : [...draftDimensions, d.key])}><span>{d.label}</span>{draftDimensions.includes(d.key) && <span className="analysis-dimension-order">{draftDimensions.indexOf(d.key) + 1}</span>}</button>)}</div><button className="analysis-settings-row" aria-pressed={!draftDimensions.length} onClick={() => setDraftDimensions([])}>仅汇总,不分组{!draftDimensions.length && <Check size={18} />}</button></> : editor ? <>
|
||||
<div className="analysis-settings-select"><button onClick={() => setDraftFilters({ ...draftFilters, [editor.key]: [...new Set([...(draftFilters[editor.key] || []), ...options.map(o => o.value)])] })}>全选{query ? '搜索结果' : ''}</button><button onClick={() => setDraftFilters({ ...draftFilters, [editor.key]: [] })}>清除选择</button><span>{draftFilters[editor.key]?.length || 0} 项已选</span></div>
|
||||
{options.map(o => <label className="analysis-settings-option" key={o.value}><input type="checkbox" checked={draftFilters[editor.key]?.includes(o.value) || false} onChange={() => toggle(editor.key, o.value)} /><span>{o.label}</span>{o.count !== undefined && <small>{o.count.toLocaleString()}</small>}</label>)}
|
||||
{!options.length && <p>没有匹配选项,可调整搜索。</p>}
|
||||
</> : <><p>同一维度多选取并集,不同维度同时满足。未选择表示不限。</p>{available.map(d => <button className="analysis-settings-row" key={d.key} onClick={() => { setEditing(d.key); setQuery(''); }}><span>{d.label}</span><small>{draftFilters[d.key]?.length ? `已选 ${draftFilters[d.key]?.length} 项` : '不限'}</small><ChevronDown size={16} /></button>)}</>}
|
||||
</div>
|
||||
<footer><button onClick={() => mode === 'group' ? setDraftDimensions([...defaults]) : setDraftFilters({})}>重置{mode === 'group' ? '分组' : '筛选'}</button><button className="analysis-settings-apply" onClick={() => { const clean = Object.fromEntries(Object.entries(draftFilters).filter(([, values]) => values?.length)); apply(draftDimensions, clean); close(); }}>查看结果</button></footer>
|
||||
</div>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user