Compare commits
201
Commits
demo
...
Profile0928
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39ecde55d4 | ||
|
|
a065e9fa1d | ||
|
|
80f8d2d92e | ||
|
|
9f89e9f61c | ||
|
|
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 | ||
|
|
17c839a35e | ||
|
|
99b4efda6d | ||
|
|
6b72f0ce3c | ||
|
|
b0c8d6a5b8 | ||
|
|
d8773ff0a0 | ||
|
|
becacecc67 | ||
|
|
67f3ec7306 | ||
|
|
3f29bed5fa | ||
|
|
4d523cbce0 | ||
|
|
2b31f8efe8 | ||
|
|
ae4b5d30e7 | ||
|
|
392a36a0ec | ||
|
|
a558db5795 | ||
|
|
b0caa5afcb | ||
|
|
5377d2c225 | ||
|
|
654573ac4b | ||
|
|
43f94ed1b8 | ||
|
|
fb5789d705 | ||
|
|
94a6e0a75e | ||
|
|
67c5f9d281 | ||
|
|
91202bdf71 | ||
|
|
c13f341d5e | ||
|
|
6962c4ff1c | ||
|
|
6b7f0eedd9 | ||
|
|
5bb3ceb47a | ||
|
|
cc778f3701 | ||
|
|
74d6efe261 | ||
|
|
a124e31fab | ||
|
|
a3dfe7ab8c | ||
|
|
3f0edfaaf5 | ||
|
|
feb950dd59 | ||
|
|
5e1c12eba2 | ||
|
|
ae24bc7647 | ||
|
|
0a372e4290 | ||
|
|
1e08d1ea62 | ||
|
|
2d82918d73 | ||
|
|
482243e052 | ||
|
|
f1a69c8271 | ||
|
|
1d2c3a0cd5 | ||
|
|
e7ba5315e1 | ||
|
|
69168abdf8 | ||
|
|
7e6fd491b0 | ||
|
|
1c57eb4a58 | ||
|
|
331ad1a1da | ||
|
|
05c99fc57a | ||
|
|
1357296f28 | ||
|
|
433a75f9d1 | ||
|
|
0193e78f18 | ||
|
|
2a851fc243 | ||
|
|
6142af7617 | ||
|
|
26f7d7ab3f | ||
|
|
f06b0d21eb | ||
|
|
6ad4b5e2a4 | ||
|
|
ad8ec50038 | ||
|
|
dc6f541c8b | ||
|
|
034654265c | ||
|
|
5958bb581e | ||
|
|
4153f329b8 | ||
|
|
ee981639eb | ||
|
|
fe70ec389b | ||
|
|
c3b463d9ca | ||
|
|
f2acb73033 | ||
|
|
015ff9bc7e | ||
|
|
d24ce55a59 | ||
|
|
e0183986ee | ||
|
|
234b44ea03 | ||
|
|
8d861538af | ||
|
|
bdefb878a5 | ||
|
|
2aeff0c2f4 | ||
|
|
9bbd11cc86 | ||
|
|
1a3d48b2d1 | ||
|
|
c5541fbbf5 | ||
|
|
90b34b681e | ||
|
|
90b1266fe5 | ||
|
|
20ebb16e08 | ||
|
|
e8f1604c11 | ||
|
|
08f21b7e24 | ||
|
|
57207debfb | ||
|
|
d3fa2fd4d6 | ||
|
|
8b4fb6563f | ||
|
|
e187c0d02e | ||
|
|
3d4d862d73 | ||
|
|
23a7722583 | ||
|
|
213037c2ac | ||
|
|
3efa701395 | ||
|
|
e775acb8fe | ||
|
|
c788dd4577 | ||
|
|
3851335843 | ||
|
|
d0a644cf18 | ||
|
|
0d30ee2df5 | ||
|
|
9a20a7cb79 | ||
|
|
d1d79f1c7c | ||
|
|
5217e19b25 | ||
|
|
57fdd346cf | ||
|
|
d8189329ac | ||
|
|
e2d04db06d | ||
|
|
5493e27e49 | ||
|
|
355c45a2e4 | ||
|
|
66779a98e3 | ||
|
|
97ac92a0da | ||
|
|
7ca8ef24dc | ||
|
|
f9c6155ea7 | ||
|
|
cab86556f3 | ||
|
|
e0c609168e | ||
|
|
ebd82893bc | ||
|
|
3809e785c1 | ||
|
|
d1acdafa7e | ||
|
|
c3b43837fb | ||
|
|
c02c1aa62c | ||
|
|
9a4f1945d9 | ||
|
|
7de2d1ecd5 | ||
|
|
42ec6e1c01 | ||
|
|
313325553d | ||
|
|
d9b9ff495e | ||
|
|
bdd039a2c4 | ||
|
|
2a92d991b0 | ||
|
|
ccf76cba79 | ||
|
|
a40fd2be34 | ||
|
|
c8a1e8506e | ||
|
|
dc1f0326fc | ||
|
|
e6880cba17 | ||
|
|
09b9862f1f | ||
|
|
deb2f2d5da | ||
|
|
ccd97d3aae | ||
|
|
61db692980 | ||
|
|
cfe79cace2 | ||
|
|
9ea2f306c4 |
@@ -0,0 +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
|
||||||
+10
@@ -1,3 +1,13 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.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
|
||||||
|
|||||||
+32
-5
@@ -2,17 +2,44 @@ version: '3.8'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
ln-bi:
|
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
|
network_mode: host
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: "47.101.148.99"
|
# 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_PORT: "3306"
|
||||||
DB_USER: "root"
|
DB_USER: "oneos_db_prod"
|
||||||
DB_PASSWORD: "LN#Passw0rd@2026"
|
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
|
||||||
DB_NAME: "lingniu_prod"
|
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}"
|
||||||
|
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
|
||||||
SERVER_PORT: "8111"
|
SERVER_PORT: "8111"
|
||||||
EXTERNAL_API_BASE: "https://lnh2e.com"
|
EXTERNAL_API_BASE: "https://lnh2e.com"
|
||||||
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
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}"
|
||||||
|
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
|
||||||
|
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
|
||||||
|
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
|
||||||
deploy:
|
deploy:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
restart_policy:
|
restart_policy:
|
||||||
|
|||||||
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,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,81 @@
|
|||||||
|
# 故障管理
|
||||||
|
|
||||||
|
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 按导出操作动态加载。
|
||||||
@@ -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 条车辆仍分页;区域筛选后折叠摘要和库存计数一致。
|
||||||
@@ -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,155 @@
|
|||||||
|
# OneOS 车辆里程接口需求
|
||||||
|
|
||||||
|
当前协议选源联调阻塞项和 OneOS 修改清单见:
|
||||||
|
[OneOS 车辆里程数据源协议改造清单](./oneos-mileage-source-protocol-gaps.md)。
|
||||||
|
|
||||||
|
## 开发原则
|
||||||
|
|
||||||
|
1. 新增区间查询接口;车辆里程汇总能力在现有
|
||||||
|
`POST /api/v1/vehicles/mileage/query` 上兼容扩展。
|
||||||
|
2. 现有接口的路径、鉴权、已有请求参数、已有响应字段、字段语义和默认行为必须完全兼容。
|
||||||
|
3. 公网 `https://open.d.lnoneos.com` 与 ECS 内网 `http://172.17.111.55:20310` 使用相同路径、鉴权和数据口径。
|
||||||
|
4. 鉴权继续使用 `Authorization: Bearer APP_KEY`。
|
||||||
|
5. 日期采用 `YYYY-MM-DD`,时区统一为 `Asia/Shanghai`,里程单位统一为 km。
|
||||||
|
|
||||||
|
## 1. 车辆区间日里程批量查询
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/vehicles/mileage/range/query
|
||||||
|
```
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"startDate": "2026-07-01",
|
||||||
|
"endDate": "2026-07-23",
|
||||||
|
"plateNumbers": ["沪A00001", "沪A00002"],
|
||||||
|
"protocolPriority": ["GB32960", "MQTT", "JT808"],
|
||||||
|
"cursor": null,
|
||||||
|
"pageSize": 5000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- `startDate`、`endDate` 必填,最长支持 366 天;
|
||||||
|
- `plateNumbers` 可省略,省略时返回授权范围内全部车辆;
|
||||||
|
- `protocolPriority` 可省略;提供时只允许 `GB32960`、`MQTT`、`JT808`,
|
||||||
|
数组顺序表示逐车逐日的数据源优先级,未列出的协议视为禁用;
|
||||||
|
- BI 只会使用以下四种组合:
|
||||||
|
- 仪表数据优先:`["GB32960", "MQTT", "JT808"]`;
|
||||||
|
- GPS 数据优先:`["JT808", "GB32960", "MQTT"]`;
|
||||||
|
- 仅仪表数据:`["GB32960", "MQTT"]`;
|
||||||
|
- 仅 GPS 数据:`["JT808"]`;
|
||||||
|
- BI 页面默认只启用“仪表数据”,即默认发送
|
||||||
|
`["GB32960", "MQTT"]`;用户主动启用 GPS 后才发送包含 `JT808` 的组合;
|
||||||
|
- 仪表数据内部优先级固定为 `GB32960 > MQTT`,不提供反向切换;
|
||||||
|
- 数据量过大时使用 `cursor` 分页,同一次查询使用相同 `snapshotId`;
|
||||||
|
- 每辆车每天返回一条记录;
|
||||||
|
- 无数据返回 `status: "NO_DATA"` 且 `dailyMileageKm: null`,真实零里程返回 `status: "NORMAL"` 且 `dailyMileageKm: 0`。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"message": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"vin": "LMRK...",
|
||||||
|
"plateNumber": "沪A00001",
|
||||||
|
"date": "2026-07-01",
|
||||||
|
"dailyMileageKm": 182.437,
|
||||||
|
"sourceProtocol": "GB32960",
|
||||||
|
"dataTime": "2026-07-01T23:58:45+08:00",
|
||||||
|
"updatedAt": "2026-07-02T05:10:00+08:00",
|
||||||
|
"status": "NORMAL"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"snapshotId": "mileage-20260723-001",
|
||||||
|
"nextCursor": null,
|
||||||
|
"traceId": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 兼容扩展现有单日车辆里程接口
|
||||||
|
|
||||||
|
用于一次获取指定日期每辆车的日里程、累计里程和数据时间。
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/vehicles/mileage/query
|
||||||
|
```
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "2026-07-23",
|
||||||
|
"plateNumbers": ["沪A00001", "沪A00002"],
|
||||||
|
"protocolPriority": ["GB32960", "MQTT", "JT808"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- `date` 和 `plateNumbers` 沿用现有接口规则;
|
||||||
|
- `protocolPriority` 的枚举、顺序和禁用规则与区间接口完全一致;
|
||||||
|
- `plateNumbers` 省略时返回授权范围内全部车辆;
|
||||||
|
- 接口默认在现有车辆结果中返回 `totalMileageKm`、`dataTime` 和 `updatedAt`;
|
||||||
|
- 只增加响应字段,不删除或修改任何现有字段及其语义;
|
||||||
|
- 历史日期返回该自然日最终有效结果;
|
||||||
|
- 查询当天返回当前最新结果;
|
||||||
|
- `dataTime` 必须是本次统计实际采用的最后一条车辆源数据时间,不能使用接口请求时间或响应时间代替;
|
||||||
|
- 无数据时相关里程字段返回 `null`,不能使用 0 代替;
|
||||||
|
- 接口应支持当前应用授权范围内的全部车辆。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"message": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"vin": "LMRK...",
|
||||||
|
"plateNumber": "沪A00001",
|
||||||
|
"date": "2026-07-23",
|
||||||
|
"dailyMileageKm": 182.437,
|
||||||
|
"totalMileageKm": 12345.678,
|
||||||
|
"sourceProtocol": "GB32960",
|
||||||
|
"dataTime": "2026-07-23T10:35:42+08:00",
|
||||||
|
"updatedAt": "2026-07-23T10:35:46+08:00",
|
||||||
|
"status": "NORMAL"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"traceId": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
现有响应外层结构及已有字段保持不变,调用方无需增加任何请求参数。
|
||||||
|
|
||||||
|
## 通用要求
|
||||||
|
|
||||||
|
- `dataTime`、`updatedAt` 使用带 `+08:00` 的 ISO 8601 格式;
|
||||||
|
- `sourceProtocol` 必须返回本条结果实际采用的协议:
|
||||||
|
`GB32960`、`MQTT` 或 `JT808`;无数据时返回 `null`;
|
||||||
|
- OneOS 必须按 `protocolPriority` 选择第一份有效数据,不得返回已禁用协议的数据;
|
||||||
|
- `sourceProtocol=GB32960/MQTT` 在 BI 显示为“仪表数据”,
|
||||||
|
`sourceProtocol=JT808` 显示为“GPS数据”;
|
||||||
|
- 每个响应返回 `traceId`;
|
||||||
|
- 接口返回值保留原始精度;
|
||||||
|
- 负里程不得作为正常数据返回;
|
||||||
|
- 公网和 ECS 内网使用同一 AppKey 时,授权范围和查询结果保持一致;
|
||||||
|
- 区间接口及现有接口的汇总扩展不得影响现有调用方和默认行为。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
1. 现有 `/api/v1/vehicles/mileage/query` 请求方式保持不变,回归测试全部通过。
|
||||||
|
2. 区间接口不传车牌可完整查询全部授权车辆,分页无重复、无漏行。
|
||||||
|
3. 现有接口不传车牌时,默认返回全部授权车辆的日里程、累计里程和数据时间。
|
||||||
|
4. 正确区分无数据与真实零里程。
|
||||||
|
5. `dataTime` 能反映每辆车实际数据的新鲜度。
|
||||||
|
6. 四种 `protocolPriority` 组合均能按顺序选源,且响应中的
|
||||||
|
`sourceProtocol` 与实际采用协议一致。
|
||||||
|
7. 禁用某类协议后,响应不得回退到该协议。
|
||||||
@@ -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,183 @@
|
|||||||
|
# OneOS 车辆里程数据源协议改造清单
|
||||||
|
|
||||||
|
## 0. 发布后复测结论
|
||||||
|
|
||||||
|
复测日期:2026-07-23
|
||||||
|
|
||||||
|
开放平台发布后,BI 已重新连接 `https://open.d.lnoneos.com` 并完成复测:
|
||||||
|
|
||||||
|
- 单日接口和区间接口均接受 `protocolPriority`,不再返回
|
||||||
|
`INVALID_REQUEST`;
|
||||||
|
- 成功响应已返回实际使用的 `sourceProtocol`;
|
||||||
|
- 仪表优先、GPS 优先、仅仪表、仅 GPS 四种模式均返回 HTTP 200;
|
||||||
|
- “仅仪表数据”结果未出现 `JT808`;
|
||||||
|
- “仅 GPS 数据”结果未出现 `GB32960` 或 `MQTT`;
|
||||||
|
- 同一车辆四天区间查询能够随优先级在 `GB32960` 和 `JT808`
|
||||||
|
之间正确切换;
|
||||||
|
- BI 页面已显示真实“仪表数据”或“GPS数据”,不再显示“来源待接口”。
|
||||||
|
|
||||||
|
复测时的单日协议分布如下,数据量会随当天车辆上报变化:
|
||||||
|
|
||||||
|
| BI 模式 | GB32960 | MQTT | JT808 | 无数据 | 禁用协议混入 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | --- |
|
||||||
|
| 仪表数据优先 | 387 | 49 | 134 | 454 | 无 |
|
||||||
|
| GPS 数据优先 | 69 | 30 | 471 | 454 | 无 |
|
||||||
|
| 仅仪表数据 | 387 | 49 | 0 | 588 | 无 |
|
||||||
|
| 仅 GPS 数据 | 0 | 0 | 471 | 553 | 无 |
|
||||||
|
|
||||||
|
结论:本清单中的协议选源阻塞项已关闭,当前接口满足 BI 对接要求。
|
||||||
|
以下内容保留为接口契约和后续回归验收依据。
|
||||||
|
|
||||||
|
## 1. 历史联调结论(发布前)
|
||||||
|
|
||||||
|
联调地址:`https://open.d.lnoneos.com`
|
||||||
|
|
||||||
|
涉及接口:
|
||||||
|
|
||||||
|
- `POST /api/v1/vehicles/mileage/query`
|
||||||
|
- `POST /api/v1/vehicles/mileage/range/query`
|
||||||
|
|
||||||
|
发布前接口不满足 BI 对车辆里程数据源切换和来源标注的要求,阻塞项如下:
|
||||||
|
|
||||||
|
| 序号 | 当前表现 | 期望表现 | 影响 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | 请求增加 `protocolPriority` 后返回 HTTP 400,业务码为 `INVALID_REQUEST` | 两个里程接口均接受可选字段 `protocolPriority` | BI 无法切换仪表数据和 GPS 数据的优先级,也无法单独禁用某类数据 |
|
||||||
|
| 2 | 成功响应中没有 `sourceProtocol` | 每辆车、每天的结果返回实际采用的协议 | BI 无法准确标注“仪表数据”或“GPS数据” |
|
||||||
|
| 3 | 无法验证接口是否排除了已禁用协议 | 未列入 `protocolPriority` 的协议不得参与选源 | “仅仪表数据”和“仅 GPS 数据”无法生效 |
|
||||||
|
| 4 | 无法验证仪表数据内部的降级顺序 | 仪表数据固定按 `GB32960 > MQTT` 选源 | 32960 缺失时无法确认是否正确降级到 MQTT |
|
||||||
|
|
||||||
|
BI 保留旧请求格式的兼容回退,用于开放平台异常或版本回滚时保证现有里程查询可用。回退结果不会伪造数据来源,页面会显示“来源待接口”。正常情况下新接口已不触发该回退。
|
||||||
|
|
||||||
|
## 2. OneOS 必须修改的请求字段
|
||||||
|
|
||||||
|
两个接口都需要增加以下可选字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"protocolPriority": ["GB32960", "MQTT", "JT808"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段规则:
|
||||||
|
|
||||||
|
1. 类型为非空字符串数组。
|
||||||
|
2. 仅允许 `GB32960`、`MQTT`、`JT808`,协议名区分以此处定义为准。
|
||||||
|
3. 数组顺序表示逐车、逐自然日的数据源优先级。
|
||||||
|
4. 未出现在数组中的协议视为禁用,不得参与选源或作为兜底数据返回。
|
||||||
|
5. `protocolPriority` 省略时保持现有接口的默认行为,确保旧调用方兼容。
|
||||||
|
6. 非法枚举、空数组或重复协议应返回 HTTP 400,并在响应中给出明确的错误信息和 `traceId`。
|
||||||
|
|
||||||
|
BI 只会发送以下四种组合:
|
||||||
|
|
||||||
|
| BI 模式 | `protocolPriority` | 选源规则 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 仪表数据优先 | `["GB32960", "MQTT", "JT808"]` | 先查 32960,再查 MQTT,最后查 JT808 |
|
||||||
|
| GPS 数据优先 | `["JT808", "GB32960", "MQTT"]` | 先查 JT808,再查 32960,最后查 MQTT |
|
||||||
|
| 仅仪表数据 | `["GB32960", "MQTT"]` | 只允许 32960 和 MQTT,禁止使用 JT808 |
|
||||||
|
| 仅 GPS 数据 | `["JT808"]` | 只允许 JT808,禁止使用 32960 和 MQTT |
|
||||||
|
|
||||||
|
BI 页面默认只启用“仪表数据”,即默认使用
|
||||||
|
`["GB32960", "MQTT"]`;GPS 数据需要用户主动启用。
|
||||||
|
|
||||||
|
仪表数据内部优先级固定为 `GB32960 > MQTT`,BI 不会发送 `MQTT > GB32960`。
|
||||||
|
|
||||||
|
## 3. OneOS 必须增加的响应字段
|
||||||
|
|
||||||
|
两个接口的每条车辆日里程结果都需要增加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sourceProtocol": "GB32960"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段规则:
|
||||||
|
|
||||||
|
1. 有有效里程数据时,只能返回 `GB32960`、`MQTT` 或 `JT808`。
|
||||||
|
2. 返回值必须是本条结果实际采用的协议,不能返回车辆支持的协议列表,也不能返回请求中的第一项作为固定值。
|
||||||
|
3. 无有效数据、`status="NO_DATA"` 时返回 `null`。
|
||||||
|
4. `sourceProtocol` 对应关系:
|
||||||
|
- `GB32960`、`MQTT`:BI 显示为“仪表数据”;
|
||||||
|
- `JT808`:BI 显示为“GPS数据”。
|
||||||
|
5. 如果 `sourceProtocol` 不在本次请求的 `protocolPriority` 中,应视为接口错误。
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"message": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"vin": "LMRK...",
|
||||||
|
"plateNumber": "粤A00001F",
|
||||||
|
"date": "2026-07-23",
|
||||||
|
"dailyMileageKm": 182.437,
|
||||||
|
"totalMileageKm": 12345.678,
|
||||||
|
"sourceProtocol": "GB32960",
|
||||||
|
"dataTime": "2026-07-23T10:35:42+08:00",
|
||||||
|
"updatedAt": "2026-07-23T10:35:46+08:00",
|
||||||
|
"status": "NORMAL"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"traceId": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 逐车逐日选源规则
|
||||||
|
|
||||||
|
OneOS 需要针对每辆车、每个自然日独立执行以下逻辑:
|
||||||
|
|
||||||
|
1. 按 `protocolPriority` 从前到后检查数据源。
|
||||||
|
2. 找到第一份有效数据后立即使用,不再混用低优先级协议的数据。
|
||||||
|
3. 有效数据必须满足现有里程质量规则,包括里程非空、非负且数据状态有效。
|
||||||
|
4. 高优先级协议无有效数据时,才能降级到下一个已启用协议。
|
||||||
|
5. 所有已启用协议均无有效数据时,返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dailyMileageKm": null,
|
||||||
|
"sourceProtocol": null,
|
||||||
|
"status": "NO_DATA"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. 真实零里程必须返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dailyMileageKm": 0,
|
||||||
|
"sourceProtocol": "实际采用的协议",
|
||||||
|
"status": "NORMAL"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
区间查询必须逐日选源,不能先按一个协议汇总整个区间后再切换协议。
|
||||||
|
|
||||||
|
## 5. 验收用例
|
||||||
|
|
||||||
|
OneOS 修改完成后,至少提供一辆在同一天同时存在 32960、MQTT、JT808 数据的测试车辆,以及一辆只有 MQTT 数据的测试车辆。
|
||||||
|
|
||||||
|
| 用例 | 请求优先级 | 必须满足的结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 仪表优先 | `GB32960, MQTT, JT808` | 同时有三种数据时采用 GB32960,`sourceProtocol="GB32960"` |
|
||||||
|
| GPS 优先 | `JT808, GB32960, MQTT` | 同时有三种数据时采用 JT808,`sourceProtocol="JT808"` |
|
||||||
|
| 仅仪表 | `GB32960, MQTT` | 不得返回 JT808;32960 缺失时允许降级到 MQTT |
|
||||||
|
| 仅 GPS | `JT808` | 不得返回 GB32960 或 MQTT |
|
||||||
|
| 32960 降级 | `GB32960, MQTT` | 只有 MQTT 有效时返回 MQTT,`sourceProtocol="MQTT"` |
|
||||||
|
| 禁用验证 | `JT808` | 即使仪表数据存在,也不能回退到仪表数据 |
|
||||||
|
| 无数据 | 任一合法组合 | 返回 `dailyMileageKm=null`、`sourceProtocol=null`、`status="NO_DATA"` |
|
||||||
|
| 真实零里程 | 任一合法组合 | 返回 `dailyMileageKm=0`、实际 `sourceProtocol`、`status="NORMAL"` |
|
||||||
|
| 单日接口 | 四种组合分别测试 | 请求成功且选源、来源字段符合规则 |
|
||||||
|
| 区间接口 | 四种组合分别测试 | 每辆车每天独立选源,分页无重复、无漏行 |
|
||||||
|
| 兼容性 | 不传 `protocolPriority` | 原有调用方式和响应语义保持不变 |
|
||||||
|
|
||||||
|
## 6. 交付和联调要求
|
||||||
|
|
||||||
|
1. 公网 `https://open.d.lnoneos.com` 和 ECS 内网地址同时发布相同能力。
|
||||||
|
2. 发布后提供接口版本或发布日期、测试车辆、测试日期及对应协议数据情况。
|
||||||
|
3. 提供四种合法组合的请求和响应样例。
|
||||||
|
4. 提供一组禁用协议的验证结果,证明未启用协议不会被回退使用。
|
||||||
|
5. 每个成功和失败响应均返回 `traceId`,便于双方定位问题。
|
||||||
|
|
||||||
|
完成标准:两个接口均支持 `protocolPriority`,每条结果准确返回 `sourceProtocol`,四种 BI 模式全部通过上述验收用例。
|
||||||
@@ -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.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,264 @@
|
|||||||
|
# 能源管理模块设计
|
||||||
|
|
||||||
|
在底部导航增加「能源管理」入口,集中展示加氢/充电的成本与用量数据。当前阶段**只做前端原型,数据全部走前端 mock**,后端接入留作下一阶段。
|
||||||
|
|
||||||
|
## 业务背景
|
||||||
|
|
||||||
|
参考 FineBI 既有的三张大屏:
|
||||||
|
|
||||||
|
| BI 链接 | 标题 | 内容 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `link/0iqP` | 氢气管理(氢费统计) | 年/月/日加氢量与加氢费 KPI、加氢业务区域分布、Top5 加氢站、各区域占比 |
|
||||||
|
| `link/GBSp` | 加氢站氢量每日汇总 | 每日加氢量与环比,下钻到站点级带单价 |
|
||||||
|
| `link/TPqB` | 龙王路停车场充电站每日充电汇总 | 每日充电量(度) + 充电费用(元) |
|
||||||
|
|
||||||
|
这些大屏目前只在桌面浏览器里好看,移动端体验较差,且与现有应用风格割裂。新模块的目标是把核心信息按「移动优先 + 双端响应」重新组织进当前 BI App 的底部导航,让一线运营在手机上也能秒级抓取关键能耗指标。
|
||||||
|
|
||||||
|
## 用户故事
|
||||||
|
|
||||||
|
- 作为运营人员,进 App 底部 Tab 「能源管理」,第一屏立刻能看到本年/本月/今日的加氢量与加氢费。
|
||||||
|
- 作为运营人员,能切换查看「氢能」和「电能」两类业务。
|
||||||
|
- 作为氢能业务方,能看到每日加氢明细,并下钻到站点级别(站名 + 单价)。
|
||||||
|
- 作为电能业务方,能看到龙王路充电站每日充电量与充电费。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
### 在范围内
|
||||||
|
- 新模块 `src/modules/energy/`,遵循 mileage 模块的目录骨架
|
||||||
|
- `EnergyModule` 顶级组件 + 二级 Tab(氢能 / 电能)
|
||||||
|
- 氢能下:总览(KPI + Top5 横柱 + 区域占比环)+ 每日(明细表带下钻)
|
||||||
|
- 电能下:mini KPI 头 + 每日明细表
|
||||||
|
- 全前端 mock 数据,数据值从 BI 截图取真实样本,文件 `mock.ts`
|
||||||
|
- 双端响应(mobile + web)
|
||||||
|
- 在 `App.tsx` 的 `BASE_MODULES` 注册(登录即可见)
|
||||||
|
|
||||||
|
### 不在范围内
|
||||||
|
- 后端接入、真实数据库表设计(下一期)
|
||||||
|
- 中国地图(加氢业务区域分布)— 工作量与依赖(高德/echarts-geo)大,后续单独立项
|
||||||
|
- iframe 嵌入 FineBI(已否决)
|
||||||
|
- 角色权限门禁(暂不需要)
|
||||||
|
- 数据导出 / CSV
|
||||||
|
- 站点详情页 / 区域详情页
|
||||||
|
|
||||||
|
## 模块注册(App.tsx)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Truck, Route, Activity, Zap } from 'lucide-react';
|
||||||
|
import EnergyModule from './modules/energy/EnergyModule';
|
||||||
|
|
||||||
|
const BASE_MODULES: ModuleConfig[] = [
|
||||||
|
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||||
|
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||||
|
{ id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
并在 `Shell.tsx` 的 `PATH_MAP` 加 `'/energy': 'energy'`。`#energy` hash 由 Shell 已有逻辑兜底。
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
```
|
||||||
|
EnergyModule
|
||||||
|
├── 顶部 sticky sub-nav: [氢能] [电能] ← motion layoutId 滑块动画
|
||||||
|
│
|
||||||
|
├── 氢能 view (HydrogenView)
|
||||||
|
│ ├── 内层 sticky tab: [总览] [每日]
|
||||||
|
│ ├── HydrogenOverview
|
||||||
|
│ │ ├── 数据时间提示条:「数据自 2025-01-01 起,每 5 分钟更新」
|
||||||
|
│ │ ├── KPI 网格(移动 2×2 / 桌面 1×4)
|
||||||
|
│ │ ├── Top5 加氢站横柱图
|
||||||
|
│ │ └── 各区域加氢占比环形图
|
||||||
|
│ └── HydrogenDaily
|
||||||
|
│ ├── 日期速选 6 选 1:当天 / 本周 / 本月 / 本季度 / 最近7天 / 最近30天
|
||||||
|
│ ├── 客户类型 2 选 1:外部 / 羚牛
|
||||||
|
│ ├── 合计行(pin 在表头下)
|
||||||
|
│ └── 表格:日期 → 加氢量(Kg) → 环比%(日期行可展开为站点级)
|
||||||
|
│
|
||||||
|
└── 电能 view (ElectricView)
|
||||||
|
├── 数据时间提示条:「龙王路停车场充电站,期初 2025-01-01,手工导入每日更新」
|
||||||
|
├── 横向 mini KPI 头(3 列:累计 / 本月 / 今日)
|
||||||
|
├── 客户类型 2 选 1:外部 / 羚牛
|
||||||
|
└── 表格:月份/日期 → 充电电量(度) → 充电费用(元)(月份组可展开为日级)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/modules/energy/
|
||||||
|
├── EnergyModule.tsx # 顶级容器 + 氢能/电能 切换
|
||||||
|
├── HydrogenView.tsx # 含「总览/每日」二级 Tab
|
||||||
|
├── HydrogenOverview.tsx # KPI + Top5 + 区域占比
|
||||||
|
├── HydrogenDaily.tsx # 加氢量明细表
|
||||||
|
├── ElectricView.tsx # 充电汇总表
|
||||||
|
├── mock.ts # 所有 mock 数据
|
||||||
|
└── types.ts # 共享类型
|
||||||
|
```
|
||||||
|
|
||||||
|
参照 `src/modules/mileage/` 风格。**没有 api.ts**(后端先不接)。
|
||||||
|
|
||||||
|
## 数据形状(mock.ts / types.ts)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// types.ts
|
||||||
|
export type CustomerType = 'external' | 'lingniu'; // 外部 / 羚牛
|
||||||
|
export type DateQuickPick = 'today' | 'thisWeek' | 'thisMonth' | 'thisQuarter' | 'last7' | 'last30';
|
||||||
|
|
||||||
|
export interface HydrogenKpi {
|
||||||
|
yearKg: number; // 年加氢量 Kg
|
||||||
|
yearFee: number; // 年加氢费 元
|
||||||
|
ourYearKg: number; // 我方年加氢量
|
||||||
|
ourYearFee: number; // 我方年加氢费
|
||||||
|
customerYearKg: number; // 客户产生年加氢量
|
||||||
|
monthKg: number;
|
||||||
|
monthFee: number;
|
||||||
|
todayKg: number;
|
||||||
|
todayFee: number;
|
||||||
|
lingniuBornKg: number; // 累计羚牛承担量
|
||||||
|
lingniuBornFee: number; // 累计羚牛承担费
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydrogenStation {
|
||||||
|
name: string;
|
||||||
|
kg: number;
|
||||||
|
fee: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydrogenRegionShare {
|
||||||
|
region: string;
|
||||||
|
kg: number;
|
||||||
|
share: number; // 0-1
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydrogenStationRow {
|
||||||
|
name: string;
|
||||||
|
pricePerKg: number; // 单价 元/Kg
|
||||||
|
kg: number;
|
||||||
|
chainPct: number; // 环比 -1..+1
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydrogenDailyRow {
|
||||||
|
date: string; // 'YYYY-MM-DD'
|
||||||
|
totalKg: number;
|
||||||
|
chainPct: number;
|
||||||
|
stations: HydrogenStationRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectricKpi {
|
||||||
|
totalKwh: number;
|
||||||
|
totalFee: number;
|
||||||
|
monthKwh: number;
|
||||||
|
monthFee: number;
|
||||||
|
todayKwh: number;
|
||||||
|
todayFee: number;
|
||||||
|
todayChainPct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectricDailyRow {
|
||||||
|
date: string; // 'YYYY-MM-DD'
|
||||||
|
kwh: number;
|
||||||
|
fee: number;
|
||||||
|
chainPct: number; // 环比,用于趋势箭头
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectricMonthGroup {
|
||||||
|
month: string; // 'YYYY-MM'
|
||||||
|
kwh: number;
|
||||||
|
fee: number;
|
||||||
|
rows: ElectricDailyRow[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`mock.ts` 提供:
|
||||||
|
- `hydrogenKpi`:取自 0iqP 的 362.43T / ¥1066.46 万 / 10.03 万 等真实样本
|
||||||
|
- `hydrogenStationsTop5`:5 家站,名字取自 GBSp 截图
|
||||||
|
- `hydrogenRegionShare`:约 8-12 个区域条目
|
||||||
|
- `hydrogenDaily`:约 30 天数据,前 7 天每天 3-4 个站点;剩余天只有汇总 + 1-2 站点
|
||||||
|
- `electricKpi`:取自 TPqB 合计 817,632.24 度 / ¥151,542.92
|
||||||
|
- `electricMonthly`:以月为顶级 group,含每日明细,至少覆盖 2026-04 起若干天
|
||||||
|
|
||||||
|
数字均做轻微抖动,但保留量级与百分比,避免与 BI 大屏数字"完全一致"误导。
|
||||||
|
|
||||||
|
## 视觉规范
|
||||||
|
|
||||||
|
### 通用
|
||||||
|
- 容器:`min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6` + `max-w-6xl mx-auto`
|
||||||
|
- 横屏:保留 `landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden`
|
||||||
|
- 卡片:`bg-white rounded-2xl border border-slate-100 shadow-sm`
|
||||||
|
- sub-nav:`bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm` + sticky top 0
|
||||||
|
- Tab active 用 `motion.div layoutId="..."` 下划线动画(沿用 mileage 模式)
|
||||||
|
- 主色:`text-blue-600 / bg-blue-50`
|
||||||
|
- 正向:`text-emerald-500 / bg-emerald-50`
|
||||||
|
- 负向:`text-red-500 / bg-red-50`
|
||||||
|
- 警示:`text-amber-500 / bg-amber-50`
|
||||||
|
|
||||||
|
### 氢能总览
|
||||||
|
- KPI 卡:4 张主角卡,顺序固定
|
||||||
|
1. 年加氢量(青蓝渐变 `from-cyan-50 to-blue-50`)+ 主数 `362.43T` + 分解两行(我方 / 客户产生)
|
||||||
|
2. 年加氢费(蓝紫渐变 `from-blue-50 to-violet-50`)+ 主数 `¥1066.46万` + 分解(我方)
|
||||||
|
3. 累计羚牛承担(蜜橙渐变 `from-amber-50 to-orange-50`)+ 主数 `¥10.03万` + 分解(量 / 费)
|
||||||
|
4. 本月/今日合并卡(浅灰)+ 左半月度 + 右半今日,文字主数 `text-2xl md:text-3xl`
|
||||||
|
- 卡左上 lucide icon:`Fuel` / `Wallet` / `Coins` / `CalendarClock`
|
||||||
|
- KPI 主数移动端 `text-2xl font-bold`,桌面 `md:text-3xl`,分解行 `text-[11px] text-slate-500 font-bold`
|
||||||
|
- Top5 加氢站:横向柱状(recharts BarChart `layout="vertical"`),柱子蓝→青渐变,柱左侧带数字徽章 1-5,柱右端贴 `XX,XXX Kg · XX%`,`<ResponsiveContainer width="100%" height={240}>`
|
||||||
|
- 区域占比环形:recharts PieChart,外环切片,中心圆心放年合计 `362.43T`,下方两列图例(移动单列、桌面双列)
|
||||||
|
|
||||||
|
### 氢能每日表
|
||||||
|
- 日期速选:6 个 pill 一行,可横向滚动,激活态 `bg-blue-50 text-blue-600 border-blue-200`
|
||||||
|
- 客户类型:2 列等宽 segmented control(背景 `bg-slate-100 rounded-xl`,激活态白底+阴影)
|
||||||
|
- 合计行:`bg-blue-50/50 text-blue-600` 粗体
|
||||||
|
- 主行 = 日期:左侧 `▶` 折叠图标 + 日期;展开后子行内缩进 `pl-6`,子行内容 = 站名 - 单价 元/Kg
|
||||||
|
- 环比 pill 双端共用样式:
|
||||||
|
- 上 `↑` 绿底 `bg-emerald-50 text-emerald-600`
|
||||||
|
- 下 `↓` 红底 `bg-red-50 text-red-600`
|
||||||
|
- 持平 `–` 灰底 `bg-slate-100 text-slate-500`
|
||||||
|
- 圆角 `rounded-full px-2 py-0.5 text-[11px] font-bold`
|
||||||
|
- 移动 3 列(日期 / 量 / 环比),桌面 4 列加站价列(站点级行用网格而非缩进)
|
||||||
|
|
||||||
|
### 电能
|
||||||
|
- mini KPI 头:3 卡横排(移动也保持横排,不堆叠),每卡上排 `¥金额` 主数(`text-xl md:text-2xl`),下排 `XXX 度` 副数 + 今日卡再加 pill 环比
|
||||||
|
- 月份组:`▶` + `2026-04` + 该月合计 `度 / 元`;展开后是日级行
|
||||||
|
- 月份组 active 时背景 `bg-blue-50/30`
|
||||||
|
- 行的趋势图标 → 与氢能页用同一 pill 组件(保证视觉一致)
|
||||||
|
|
||||||
|
## 响应式行为
|
||||||
|
|
||||||
|
| 区域 | 移动 (<md) | 桌面 (≥md) |
|
||||||
|
|------|-----------|-----------|
|
||||||
|
| 顶部 sub-nav (氢/电) | sticky 满宽 | sticky 满宽,左对齐 |
|
||||||
|
| 氢能内层 sub-tab | 紧贴 sub-nav 下 | 同 |
|
||||||
|
| 氢能总览 KPI 网格 | `grid-cols-2` | `md:grid-cols-4` |
|
||||||
|
| Top5 横柱 + 区域占比 | 上下叠 `grid-cols-1` | `md:grid-cols-2 gap-4` |
|
||||||
|
| 每日氢能表 | 3 列:日期/量/环比 | 4 列:日期/站价/量/环比,站点级行同样 4 列 |
|
||||||
|
| 电能 mini KPI 头 | 横排 3 卡(gap 紧凑) | 3 卡(gap 宽松) |
|
||||||
|
| 电能表格 | 3 列 | 4-5 列(可加客户类型 / 趋势火花线,留作后期) |
|
||||||
|
| 容器宽度上限 | 100% | `max-w-6xl mx-auto` |
|
||||||
|
| recharts 图 | `<ResponsiveContainer width="100%">` | 同 |
|
||||||
|
|
||||||
|
## 组件复用
|
||||||
|
|
||||||
|
- 内层 sub-nav 抄 `MileageModule.tsx` 的 sticky tab 实现(含 motion 滑块)
|
||||||
|
- segmented control(客户类型 / 日期速选)抄 mileage 已有的实现
|
||||||
|
- 表格行 chevron 折叠抄 assets 模块里现有展开行的写法
|
||||||
|
- 环比 pill 单独抽 `<TrendBadge value={number} />` 共用组件,放在 `src/modules/energy/HydrogenDaily.tsx` 顶端 export
|
||||||
|
|
||||||
|
## 边界与开放点
|
||||||
|
|
||||||
|
1. **`氢费`字面口径**:用户原始描述「每日氢费」,但 BI GBSp 实际只有加氢量+环比,单价在站点级。本期采纳 BI 的口径——表格只展示量+环比+单价(嵌入站名),费汇总放氢能总览的 KPI 卡。
|
||||||
|
2. **数据时间**:mock 用 2026-04-28 为「今天」(`currentDate` 取自 user 自动 memory)。`今日加氢` 在 0Kg 时显示 `0` 而非 `--`,避免误判为缺失。
|
||||||
|
3. **空态**:mock 数据已写满,UI 仍要支持 `rows.length === 0` 时显示「暂无数据」灰文 + 图标。
|
||||||
|
4. **未来后端接入**:mock 文件命名 `mock.ts`,不放在 `api.ts`;后期添加 `api.ts` 时同名 export,UI 切到 `useEffect + fetch` 即可。
|
||||||
|
5. **icon 选型**:`Zap` 作为模块底栏 icon。备选 `Fuel` / `Battery`。
|
||||||
|
6. **lucide 大小**:底部 nav 沿用 `Icon size={20}`。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- [ ] 底部 nav 多出「能源管理」Tab,icon Zap,登录后可见
|
||||||
|
- [ ] 进入后默认在「氢能 → 总览」
|
||||||
|
- [ ] 氢能总览 4 张 KPI 卡数据正确、移动 2×2 / 桌面 1×4
|
||||||
|
- [ ] Top5 横柱 + 区域占比环 双端可见,柱图站名不被截断
|
||||||
|
- [ ] 切换到「氢能 → 每日」,日期速选/客户类型 toggle 工作(前端筛选 mock 即可)
|
||||||
|
- [ ] 点开任意日期行能展开站点级行,环比 pill 颜色正确
|
||||||
|
- [ ] 切到电能 Tab,3 张 mini KPI + 月份分组表,月份能展开日级行
|
||||||
|
- [ ] 横屏不出现底部 nav 遮挡内容
|
||||||
|
- [ ] `npm run lint` 通过
|
||||||
|
- [ ] 不引入新依赖(recharts / lucide-react / motion 已有)
|
||||||
@@ -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) |
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>羚牛氢能车辆资产</title>
|
<title>羚牛氢能</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+5142
-68
File diff suppressed because it is too large
Load Diff
+16
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.5",
|
"version": "1.1.19",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||||
@@ -9,25 +9,38 @@
|
|||||||
"dev:client": "vite --port=3000 --host=0.0.0.0",
|
"dev:client": "vite --port=3000 --host=0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"start": "node --import tsx src/server/index.ts",
|
"start": "node --import tsx src/server/index.ts",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "node --import tsx --test $(find src -type f -name '*.test.ts' -print | sort)",
|
||||||
|
"test:mileage-report": "node --import tsx --test src/server/routes/mileage/daily-report-model.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"ali-oss": "^6.23.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"cn": "^0.4.0",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
|
"exceljs": "4.4.0",
|
||||||
"hono": "^4.7.0",
|
"hono": "^4.7.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"mysql2": "^3.11.0",
|
"mysql2": "^3.11.0",
|
||||||
|
"pg": "^8.22.0",
|
||||||
|
"radix-ui": "^1.6.7",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"tsx": "^4.21.0"
|
"tsx": "^4.21.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@types/ali-oss": "^6.23.3",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
+85
-30
@@ -1,39 +1,65 @@
|
|||||||
import { useMemo } from 'react';
|
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import { Truck, Route, Activity } from 'lucide-react';
|
import { Shell } from "./components/Shell";
|
||||||
import { Shell, type ModuleConfig } from './components/Shell';
|
import AuthProvider from "./auth/AuthProvider";
|
||||||
import AssetsModule from './modules/assets/AssetsModule';
|
import PermissionPreview, { PermissionPreviewPage } from "./auth/PermissionPreview";
|
||||||
import MileageModule from './modules/mileage/MileageModule';
|
import { useAuth } from "./auth/useAuth";
|
||||||
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
||||||
import AuthProvider from './auth/AuthProvider';
|
import { canAccessEnergy } from "./shared/auth/roles";
|
||||||
import { useAuth } from './auth/useAuth';
|
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
|
||||||
import UnauthorizedPage from './auth/UnauthorizedPage';
|
import { buildModules } from "./app/modules";
|
||||||
import { canAccessScheduling } from './shared/auth/roles';
|
import {
|
||||||
|
normalizeBrowserPath,
|
||||||
|
readBrowserRoute,
|
||||||
|
} from "./app/routing";
|
||||||
|
|
||||||
const BASE_MODULES: ModuleConfig[] = [
|
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
|
||||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
|
||||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
normalizeBrowserPath();
|
||||||
];
|
|
||||||
|
|
||||||
const SCHEDULING_MODULE: ModuleConfig = {
|
|
||||||
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
|
|
||||||
};
|
|
||||||
|
|
||||||
function AuthGate() {
|
function AuthGate() {
|
||||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||||
|
const [route, setRoute] = useState(readBrowserRoute);
|
||||||
|
const { routeKey, pathSet } = route;
|
||||||
|
|
||||||
const modules = useMemo(() => {
|
// 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效
|
||||||
if (canAccessScheduling(user?.roles)) {
|
useEffect(() => {
|
||||||
return [...BASE_MODULES, SCHEDULING_MODULE];
|
const update = () => {
|
||||||
}
|
setRoute(readBrowserRoute());
|
||||||
return BASE_MODULES;
|
};
|
||||||
}, [user?.roles]);
|
window.addEventListener("hashchange", update);
|
||||||
|
window.addEventListener("popstate", update);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("hashchange", update);
|
||||||
|
window.removeEventListener("popstate", update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = routeKey === "who" ? "权限视角 - 羚牛氢能 BI" : pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
|
||||||
|
}, [pathSet, routeKey]);
|
||||||
|
|
||||||
|
const modules = useMemo(
|
||||||
|
() => buildModules(pathSet, user?.roles),
|
||||||
|
[pathSet, user?.roles],
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
<div className="min-h-screen bg-[var(--app-bg)] p-4 text-slate-800 md:p-8">
|
||||||
<div className="text-center">
|
<div className="mx-auto flex max-w-5xl flex-col gap-4">
|
||||||
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
|
<div className="rounded-2xl border border-white/70 bg-white/86 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
|
||||||
<p className="text-xs text-slate-400 font-bold">正在验证身份...</p>
|
<div className="mb-3 text-[11px] font-black text-blue-600">LN BI ACCESS</div>
|
||||||
|
<div className="text-xl font-black text-slate-950">正在进入羚牛氢能 BI</div>
|
||||||
|
<div className="mt-2 text-xs font-bold text-slate-400">校验登录态、权限与本地开发环境配置</div>
|
||||||
|
</div>
|
||||||
|
<SurfaceCard className="p-4">
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
{[0, 1, 2, 3].map(item => <SkeletonBlock key={item} className="h-24" />)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<LoadingState label="正在验证身份" />
|
||||||
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -43,13 +69,42 @@ function AuthGate() {
|
|||||||
return <UnauthorizedPage message={error || undefined} />;
|
return <UnauthorizedPage message={error || undefined} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Shell modules={modules} />;
|
if (routeKey === "who") return <PermissionPreviewPage />;
|
||||||
|
|
||||||
|
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
||||||
|
if (routeKey === "ele/import") {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingState label="正在加载导入工作台" />}>
|
||||||
|
<EleImportPage />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (routeKey === "admin/feedback") {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingState label="正在加载反馈后台" />}>
|
||||||
|
<FeedbackAdminPage />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// /energy 整组按能源权限控制
|
||||||
|
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
|
||||||
|
return <UnauthorizedPage message="无能源管理模块访问权限" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// key={pathSet} 让两套底栏切换时 Shell 内部 state 重置,避免残留旧 activeModule
|
||||||
|
return <Shell key={pathSet} modules={modules} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthenticatedWorkspace() {
|
||||||
|
const { identityVersion = 0 } = useAuth();
|
||||||
|
return <PermissionPreview><AuthGate key={identityVersion} /></PermissionPreview>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AuthGate />
|
<AuthenticatedWorkspace />
|
||||||
</AuthProvider>
|
</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),
|
||||||
|
};
|
||||||
|
}
|
||||||
+140
-24
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, type ReactNode } from 'react';
|
import { useState, useEffect, useRef, type ReactNode } from 'react';
|
||||||
import { AuthContext, type AuthState } from './useAuth';
|
import { AuthContext, type AuthState, type AuthUser } from './useAuth';
|
||||||
import { setTokenGetter } from './api-client';
|
import { setTokenGetter, setPreviewTokenGetter, invalidateAuthenticatedRequests, fetchPreviewControl } from './api-client';
|
||||||
|
|
||||||
const AUTH_API = '/api/auth';
|
const AUTH_API = '/api/auth';
|
||||||
|
|
||||||
@@ -13,49 +13,168 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
const tokenRef = useRef<string | null>(null);
|
const tokenRef = useRef<string | null>(null);
|
||||||
const authStarted = useRef(false);
|
const authStarted = useRef(false);
|
||||||
|
const previewTokenRef = useRef<string | null>(null);
|
||||||
|
const actorRef = useRef<AuthUser | null>(null);
|
||||||
|
const transitionRef = useRef(0);
|
||||||
|
const [identityVersion, setIdentityVersion] = useState(0);
|
||||||
|
const [preview, setPreview] = useState<{ user: AuthUser; expiresAt: string } | null>(null);
|
||||||
|
const [canPreview, setCanPreview] = useState(false);
|
||||||
|
const [previewNotice, setPreviewNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function resetPreview(message: string | null = null) {
|
||||||
|
transitionRef.current += 1;
|
||||||
|
previewTokenRef.current = null;
|
||||||
|
sessionStorage.removeItem('bi_preview_token');
|
||||||
|
invalidateAuthenticatedRequests();
|
||||||
|
setIdentityVersion(v => v + 1);
|
||||||
|
setPreview(null);
|
||||||
|
setPreviewNotice(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreviewContext(user: AuthUser | null) {
|
||||||
|
actorRef.current = user;
|
||||||
|
try {
|
||||||
|
const context = await fetchPreviewControl<{ canPreview: boolean; actor: AuthUser }>('context');
|
||||||
|
actorRef.current = context.actor;
|
||||||
|
setCanPreview(context.canPreview);
|
||||||
|
if (context.canPreview) {
|
||||||
|
const savedPreview = sessionStorage.getItem('bi_preview_token');
|
||||||
|
if (savedPreview) {
|
||||||
|
previewTokenRef.current = savedPreview;
|
||||||
|
try {
|
||||||
|
const current = await fetchPreviewControl<{ user: AuthUser; expiresAt: string }>('current', undefined, true);
|
||||||
|
setPreview(current);
|
||||||
|
} catch {
|
||||||
|
resetPreview('上次权限视角已失效,已返回本人视角。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resetPreview();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
resetPreview();
|
||||||
|
setCanPreview(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishAuthentication(user: AuthUser | null) {
|
||||||
|
await loadPreviewContext(user);
|
||||||
|
if (!tokenRef.current) return;
|
||||||
|
setState({ isLoading: false, isAuthenticated: true, user: actorRef.current, error: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPreview(userId: string) {
|
||||||
|
const transition = ++transitionRef.current;
|
||||||
|
const next = await fetchPreviewControl<{ previewToken: string; user: AuthUser; expiresAt: string }>('start', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }),
|
||||||
|
});
|
||||||
|
if (transition !== transitionRef.current) return;
|
||||||
|
invalidateAuthenticatedRequests();
|
||||||
|
previewTokenRef.current = next.previewToken;
|
||||||
|
sessionStorage.setItem('bi_preview_token', next.previewToken);
|
||||||
|
setPreview({ user: next.user, expiresAt: next.expiresAt });
|
||||||
|
setIdentityVersion(v => v + 1);
|
||||||
|
setPreviewNotice(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopPreview() {
|
||||||
|
const stopping = fetchPreviewControl('stop', { method: 'POST' }, true);
|
||||||
|
resetPreview();
|
||||||
|
await stopping.catch(() => { setPreviewNotice('已退出本地预览;服务端凭证将自动到期。'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preview) return;
|
||||||
|
const token = previewTokenRef.current;
|
||||||
|
const validate = async () => {
|
||||||
|
try {
|
||||||
|
const current = await fetchPreviewControl<{ user: AuthUser; expiresAt: string }>('current', undefined, true);
|
||||||
|
if (token !== previewTokenRef.current) return;
|
||||||
|
if (JSON.stringify(current.user) !== JSON.stringify(preview.user)) {
|
||||||
|
invalidateAuthenticatedRequests();
|
||||||
|
setPreview(current);
|
||||||
|
setIdentityVersion(v => v + 1);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (token === previewTokenRef.current) resetPreview('权限视角验证失败,已返回本人视角,请重新选择。');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const timer = window.setInterval(validate, 60_000);
|
||||||
|
const expiry = window.setTimeout(() => resetPreview('权限视角已到期,已返回本人视角。'), Math.max(0, Date.parse(preview.expiresAt) - Date.now()));
|
||||||
|
window.addEventListener('focus', validate);
|
||||||
|
return () => { clearInterval(timer); clearTimeout(expiry); window.removeEventListener('focus', validate); };
|
||||||
|
}, [preview]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 设置全局 token getter
|
// 设置全局 token getter
|
||||||
setTokenGetter(() => tokenRef.current);
|
setTokenGetter(() => tokenRef.current);
|
||||||
|
|
||||||
// 防止 StrictMode 双重调用(jumpToken 一次性使用)
|
setPreviewTokenGetter(() => previewTokenRef.current);
|
||||||
if (authStarted.current) return;
|
|
||||||
authStarted.current = true;
|
|
||||||
|
|
||||||
// 监听 401 事件
|
// 监听 401 事件
|
||||||
const onUnauthorized = () => {
|
const onUnauthorized = () => {
|
||||||
|
resetPreview();
|
||||||
|
setCanPreview(false);
|
||||||
tokenRef.current = null;
|
tokenRef.current = null;
|
||||||
sessionStorage.removeItem('bi_jwt');
|
sessionStorage.removeItem('bi_jwt');
|
||||||
|
sessionStorage.removeItem('bi_user');
|
||||||
|
actorRef.current = null;
|
||||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '会话已过期' });
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: '会话已过期' });
|
||||||
};
|
};
|
||||||
window.addEventListener('auth:unauthorized', onUnauthorized);
|
window.addEventListener('auth:unauthorized', onUnauthorized);
|
||||||
|
|
||||||
authenticate();
|
const onPreviewEnded = (event: Event) => {
|
||||||
|
if ((event as CustomEvent).detail === 'PREVIEW_REVOKED') setCanPreview(false);
|
||||||
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
|
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() {
|
async function authenticate() {
|
||||||
|
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 检查 sessionStorage 中是否有 JWT
|
// 1. 检查 sessionStorage 中是否有 JWT
|
||||||
const savedToken = sessionStorage.getItem('bi_jwt');
|
const savedToken = sessionStorage.getItem('bi_jwt');
|
||||||
if (savedToken) {
|
if (savedToken) {
|
||||||
tokenRef.current = savedToken;
|
tokenRef.current = savedToken;
|
||||||
// 验证 token 是否仍然有效(尝试请求 health)
|
// 从受保护的身份接口验证 JWT;不使用本地缓存用户决定权限。
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/health', {
|
const res = await fetch('/api/auth/me', {
|
||||||
headers: { Authorization: `Bearer ${savedToken}` },
|
headers: { Authorization: `Bearer ${savedToken}` },
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const savedUser = sessionStorage.getItem('bi_user');
|
const verifiedUser: AuthUser = await res.json();
|
||||||
setState({
|
if (!verifiedUser.userId) throw new Error('无效的用户身份');
|
||||||
isLoading: false,
|
await finishAuthentication(verifiedUser);
|
||||||
isAuthenticated: true,
|
|
||||||
user: savedUser ? JSON.parse(savedUser) : null,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch { /* token 无效,继续流程 */ }
|
} catch { /* token 无效,继续流程 */ }
|
||||||
|
tokenRef.current = null;
|
||||||
|
resetPreview();
|
||||||
sessionStorage.removeItem('bi_jwt');
|
sessionStorage.removeItem('bi_jwt');
|
||||||
sessionStorage.removeItem('bi_user');
|
sessionStorage.removeItem('bi_user');
|
||||||
}
|
}
|
||||||
@@ -91,19 +210,16 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
: `${window.location.pathname}${window.location.hash}`;
|
: `${window.location.pathname}${window.location.hash}`;
|
||||||
window.history.replaceState({}, '', cleanUrl);
|
window.history.replaceState({}, '', cleanUrl);
|
||||||
|
|
||||||
setState({
|
await finishAuthentication(data.user);
|
||||||
isLoading: false,
|
|
||||||
isAuthenticated: true,
|
|
||||||
user: data.user,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '认证过程出错' });
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: '认证过程出错' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={state}>
|
<AuthContext.Provider value={{ ...state, actor: actorRef.current || state.user,
|
||||||
|
user: preview?.user || state.user, preview, canPreview, identityVersion, previewNotice,
|
||||||
|
startPreview, stopPreview, clearPreviewNotice: () => setPreviewNotice(null) }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</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,19 +1,24 @@
|
|||||||
import { ShieldX, Monitor, Smartphone } from 'lucide-react';
|
import { ShieldX, Monitor, Smartphone } from 'lucide-react';
|
||||||
|
import { PageFrame, SurfaceCard } from '../components/ui/surface';
|
||||||
|
|
||||||
export default function UnauthorizedPage({ message }: { message?: string }) {
|
export default function UnauthorizedPage({ message }: { message?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
|
<PageFrame
|
||||||
<div className="text-center max-w-sm">
|
title="未授权访问"
|
||||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-slate-100 flex items-center justify-center">
|
subtitle={message || '获取用户认证信息失败,可能是跳转令牌已过期或无效。'}
|
||||||
<ShieldX size={36} className="text-slate-400" />
|
icon={ShieldX}
|
||||||
|
eyebrow="ACCESS CONTROL"
|
||||||
|
meta="请从授权入口重新进入"
|
||||||
|
maxWidth="max-w-xl"
|
||||||
|
>
|
||||||
|
<SurfaceCard className="p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
||||||
|
<ShieldX size={30} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-lg font-black text-slate-800 mb-2">未授权访问</h1>
|
<p className="mb-4 text-[10px] font-bold uppercase tracking-wider text-slate-400">请通过以下方式进入</p>
|
||||||
<p className="text-xs text-slate-400 mb-4">
|
</div>
|
||||||
{message || '获取用户认证信息失败,可能是跳转令牌已过期或无效'}
|
<div className="space-y-3">
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 p-4 text-left space-y-3">
|
|
||||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider text-center">请通过以下方式进入</p>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3 p-2.5 rounded-xl bg-slate-50">
|
<div className="flex items-start gap-3 p-2.5 rounded-xl bg-slate-50">
|
||||||
<Monitor size={16} className="text-blue-500 flex-shrink-0 mt-0.5" />
|
<Monitor size={16} className="text-blue-500 flex-shrink-0 mt-0.5" />
|
||||||
@@ -31,7 +36,7 @@ export default function UnauthorizedPage({ message }: { message?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SurfaceCard>
|
||||||
</div>
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-13
@@ -1,24 +1,61 @@
|
|||||||
/** 全局认证 fetch 客户端 */
|
/** 登录身份和预览身份分别传递;切换视角会取消旧视角的在途请求。 */
|
||||||
|
|
||||||
let tokenGetter: () => string | null = () => null;
|
let tokenGetter: () => string | null = () => null;
|
||||||
|
let previewTokenGetter: () => string | null = () => null;
|
||||||
export function setTokenGetter(fn: () => string | null) {
|
let identityAbort = new AbortController();
|
||||||
tokenGetter = fn;
|
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']);
|
||||||
|
|
||||||
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
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();
|
const token = tokenGetter();
|
||||||
const res = await fetch(url, {
|
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||||
...options,
|
headers.delete('X-BI-Preview');
|
||||||
headers: {
|
const previewToken = preview ? previewTokenGetter() : null;
|
||||||
...options?.headers,
|
if (previewToken) headers.set('X-BI-Preview', previewToken);
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
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) {
|
if (res.status === 401) {
|
||||||
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
||||||
throw new Error('Unauthorized');
|
throw new Error('Unauthorized');
|
||||||
}
|
}
|
||||||
|
if (body.code === 'PREVIEW_READ_ONLY') {
|
||||||
|
window.dispatchEvent(new CustomEvent('auth:preview-readonly'));
|
||||||
|
throw new Error('权限视角为只读,请退出预览后操作');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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}`);
|
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-18
@@ -1,25 +1,27 @@
|
|||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
loginName?: string;
|
||||||
|
permissionLevel: string;
|
||||||
|
depCode?: string;
|
||||||
|
depName: string;
|
||||||
|
roles?: string[];
|
||||||
|
}
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
user: {
|
user: AuthUser | null;
|
||||||
userId: string;
|
|
||||||
userName: string;
|
|
||||||
permissionLevel: string;
|
|
||||||
depName: string;
|
|
||||||
roles?: string[];
|
|
||||||
} | null;
|
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
actor?: AuthUser | null;
|
||||||
|
canPreview?: boolean;
|
||||||
|
preview?: { user: AuthUser; expiresAt: string } | null;
|
||||||
|
identityVersion?: number;
|
||||||
|
previewNotice?: string | null;
|
||||||
|
startPreview?: (userId: string) => Promise<void>;
|
||||||
|
stopPreview?: () => Promise<void>;
|
||||||
|
clearPreviewNotice?: () => void;
|
||||||
}
|
}
|
||||||
|
export const AuthContext = createContext<AuthState>({ isLoading: true, isAuthenticated: false, user: null, error: null });
|
||||||
export const AuthContext = createContext<AuthState>({
|
export function useAuth() { return useContext(AuthContext); }
|
||||||
isLoading: true,
|
|
||||||
isAuthenticated: false,
|
|
||||||
user: null,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function useAuth() {
|
|
||||||
return useContext(AuthContext);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { fetchJson, fetchAuthenticated } from '../auth/api-client';
|
||||||
|
import { useAuth } from '../auth/useAuth';
|
||||||
|
import { canManageFeedback } from '../shared/auth/roles';
|
||||||
|
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
|
||||||
|
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;
|
||||||
|
|
||||||
|
async function uploadImage(file: File): Promise<string> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const res = await fetchAuthenticated('/api/feedback/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`);
|
||||||
|
return json.url as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAsDataUrl(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = () => resolve(String(r.result || ''));
|
||||||
|
r.onerror = reject;
|
||||||
|
r.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectModule(): string {
|
||||||
|
const hash = (window.location.hash || '').slice(1);
|
||||||
|
const path = window.location.pathname;
|
||||||
|
if (path.includes('/ele/')) return 'ele';
|
||||||
|
if (hash.includes('ele')) return 'ele';
|
||||||
|
if (hash.startsWith('/')) return hash.split('/')[1] || '';
|
||||||
|
return hash || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 显式覆盖当前模块(否则自动从 URL 检测) */
|
||||||
|
module?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdmin = canManageFeedback(user?.roles);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [step, setStep] = useState<FeedbackStep>(1);
|
||||||
|
const [type, setType] = useState<FeedbackType | null>(null);
|
||||||
|
const [mod, setMod] = useState<string>('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [shots, setShots] = useState<UploadedImg[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const addFiles = async (files: FileList | File[]) => {
|
||||||
|
const list = Array.from(files).filter(f => f.type.startsWith('image/'));
|
||||||
|
if (list.length === 0) return;
|
||||||
|
setError(null);
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
for (const f of list) {
|
||||||
|
if (shots.length >= MAX_SCREENSHOTS) break;
|
||||||
|
if (f.size > MAX_IMG_SIZE_MB * 1024 * 1024) {
|
||||||
|
setError(`「${f.name}」超过 ${MAX_IMG_SIZE_MB}MB`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const thumbDataUrl = await readAsDataUrl(f);
|
||||||
|
const url = await uploadImage(f);
|
||||||
|
setShots(prev => prev.length >= MAX_SCREENSHOTS ? prev : [...prev, { url, thumbDataUrl }]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && step === 1) {
|
||||||
|
setMod(moduleProp ?? detectModule());
|
||||||
|
}
|
||||||
|
}, [open, step, moduleProp]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = prev; };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setStep(1);
|
||||||
|
setType(null);
|
||||||
|
setContent('');
|
||||||
|
setShots([]);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setOpen(false);
|
||||||
|
setTimeout(reset, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!type || !content.trim()) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await fetchJson('/api/feedback/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type,
|
||||||
|
module: mod || null,
|
||||||
|
content: content.trim(),
|
||||||
|
screenshots: shots.map(s => s.url),
|
||||||
|
userAgent: navigator.userAgent.slice(0, 500),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setStep(3);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
if (step === 1) {
|
||||||
|
if (!type) return;
|
||||||
|
setStep(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === 2) {
|
||||||
|
if (!content.trim()) { taRef.current?.focus(); return; }
|
||||||
|
submit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<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)} />
|
||||||
|
|
||||||
|
<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); }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import { X, MailOpen, Loader2, ArrowLeft } from 'lucide-react';
|
||||||
|
import { fetchJson } from '../auth/api-client';
|
||||||
|
|
||||||
|
interface FeedbackItem {
|
||||||
|
id: number;
|
||||||
|
type: 'dimension' | 'bug' | 'ux' | 'other';
|
||||||
|
module: string | null;
|
||||||
|
content: string;
|
||||||
|
contact: string | null;
|
||||||
|
screenshots: string[] | string | null;
|
||||||
|
status: 'open' | 'in_progress' | 'done' | 'rejected';
|
||||||
|
reply_content: string | null;
|
||||||
|
reply_user: string | null;
|
||||||
|
reply_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
|
dimension: '💡 新维度',
|
||||||
|
bug: '🐛 Bug',
|
||||||
|
ux: '🎨 体验',
|
||||||
|
other: '📝 其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
open: '待处理',
|
||||||
|
in_progress: '处理中',
|
||||||
|
done: '已完成',
|
||||||
|
rejected: '已忽略',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_STYLE: Record<string, string> = {
|
||||||
|
open: 'bg-slate-100 text-slate-500',
|
||||||
|
in_progress: 'bg-amber-100 text-amber-600',
|
||||||
|
done: 'bg-emerald-100 text-emerald-600',
|
||||||
|
rejected: 'bg-rose-100 text-rose-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onBack?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
|
||||||
|
if (!s) return [];
|
||||||
|
if (Array.isArray(s)) return s;
|
||||||
|
try { return JSON.parse(String(s)); } catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackHistoryDrawer({ open, onClose, onBack }: Props) {
|
||||||
|
const [items, setItems] = useState<FeedbackItem[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setItems(null);
|
||||||
|
setError(null);
|
||||||
|
fetchJson<{ items: FeedbackItem[] }>('/api/feedback/mine')
|
||||||
|
.then(d => setItems(d.items))
|
||||||
|
.catch(e => setError(e instanceof Error ? e.message : String(e)));
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[100] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<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-lg 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 flex items-center gap-2">
|
||||||
|
{onBack && (
|
||||||
|
<button onClick={onBack} className="p-1 -ml-1 text-slate-500 hover:text-slate-700">
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center">
|
||||||
|
<MailOpen size={14} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-black text-slate-800 leading-tight">我的反馈</div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold">查看进展与回复</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-1.5 text-slate-400 hover:text-slate-700">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||||
|
{error ? (
|
||||||
|
<div className="bg-rose-50 text-rose-600 rounded-xl p-3 text-[12px] font-bold">{error}</div>
|
||||||
|
) : items === null ? (
|
||||||
|
<div className="py-10 text-center text-slate-400 text-[12px] font-bold flex items-center justify-center gap-1.5">
|
||||||
|
<Loader2 size={14} className="animate-spin" /> 加载中…
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="py-10 text-center text-slate-300 text-[12px] font-bold">还没有提过反馈</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{items.map(it => {
|
||||||
|
const shots = parseScreenshots(it.screenshots);
|
||||||
|
return (
|
||||||
|
<div key={it.id} className="bg-slate-50 rounded-xl p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span className="text-[10px] font-bold text-slate-500">{TYPE_LABEL[it.type] || it.type}</span>
|
||||||
|
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${STATUS_STYLE[it.status]}`}>
|
||||||
|
{STATUS_LABEL[it.status]}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 ml-auto">
|
||||||
|
{(it.created_at || '').replace('T', ' ').slice(0, 16)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">
|
||||||
|
{it.content}
|
||||||
|
</div>
|
||||||
|
{shots.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{shots.map((url, i) => (
|
||||||
|
<a key={i} href={url} target="_blank" rel="noreferrer" className="block w-12 h-12 rounded overflow-hidden border border-slate-200">
|
||||||
|
<img src={url} alt="" className="w-full h-full object-cover" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{it.reply_content && (
|
||||||
|
<div className="bg-blue-50 border border-blue-100 rounded-lg p-2.5 mt-1">
|
||||||
|
<div className="text-[10px] font-bold text-blue-500 mb-0.5">
|
||||||
|
{it.reply_user || '产品同学'} 回复
|
||||||
|
{it.reply_at && <span className="text-blue-300 ml-1">{(it.reply_at || '').replace('T', ' ').slice(0, 16)}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">
|
||||||
|
{it.reply_content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const FOOTER_HINTS = [
|
||||||
|
'想看哪个角度的数据?告诉我们一下嘛',
|
||||||
|
'更多统计维度接入中,欢迎您的建议 ~',
|
||||||
|
'下一个图表,可能就是您建议的那个',
|
||||||
|
'数据科学家正在深夜挖掘新维度…',
|
||||||
|
'维度灵感正在路上,钉一下产品同学也行',
|
||||||
|
'数字背后还有故事,等下一次上线揭晓',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 自定义提示词集合,默认使用通用文案 */
|
||||||
|
hints?: string[];
|
||||||
|
/** 切换间隔,默认 4 秒 */
|
||||||
|
intervalMs?: number;
|
||||||
|
/** 额外类名 */
|
||||||
|
className?: string;
|
||||||
|
/** 点击时回调(一般用来打开反馈弹窗) */
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RotatingFooterHint({ hints = FOOTER_HINTS, intervalMs = 4000, className = '', onClick }: Props) {
|
||||||
|
const [idx, setIdx] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (hints.length <= 1) return;
|
||||||
|
const t = setInterval(() => setIdx(i => (i + 1) % hints.length), intervalMs);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [hints, intervalMs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1.5 text-[11px] text-slate-400 font-bold ${onClick ? 'cursor-pointer hover:text-blue-500 transition-colors' : ''} ${className || 'mt-1 justify-center'}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
style={{ animation: 'rotatingHintFade 0.5s ease' }}
|
||||||
|
>
|
||||||
|
{hints[idx]}
|
||||||
|
</span>
|
||||||
|
<style>{`
|
||||||
|
@keyframes rotatingHintFade {
|
||||||
|
from { opacity: 0; transform: translateY(2px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+130
-46
@@ -1,69 +1,87 @@
|
|||||||
import { useState, useEffect, useMemo, type ComponentType } from 'react';
|
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { DemoModeProvider } from './Blur';
|
import { DemoModeProvider } from './Blur';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
import { LoadingState } from './ui/surface';
|
||||||
|
|
||||||
export interface ModuleConfig {
|
export interface ModuleConfig {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: ComponentType<{ size?: number; className?: string }>;
|
icon: ComponentType<{ size?: number; className?: string }>;
|
||||||
component: ComponentType;
|
component?: ElementType;
|
||||||
|
children?: ModuleConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** path 到模块 id 的映射 */
|
function flattenModules(modules: ModuleConfig[]): ModuleConfig[] {
|
||||||
const PATH_MAP: Record<string, string> = {
|
return modules.flatMap((module) => module.children ?? [module]);
|
||||||
'/vehicle': 'assets',
|
}
|
||||||
'/assets': 'assets',
|
|
||||||
'/mileage': 'mileage',
|
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id) */
|
||||||
'/scheduling': 'scheduling',
|
function getHashHead(): string {
|
||||||
};
|
return window.location.hash.slice(1).split('/')[0];
|
||||||
|
}
|
||||||
|
|
||||||
function getInitialModule(modules: ModuleConfig[]): string {
|
function getInitialModule(modules: ModuleConfig[]): string {
|
||||||
// 优先看 hash
|
const head = getHashHead();
|
||||||
const hash = window.location.hash.slice(1);
|
if (modules.some((m) => m.id === head)) return head;
|
||||||
if (modules.some((m) => m.id === hash)) return hash;
|
|
||||||
// 再看 pathname
|
|
||||||
const pathModule = PATH_MAP[window.location.pathname];
|
|
||||||
if (pathModule && modules.some((m) => m.id === pathModule)) return pathModule;
|
|
||||||
// 默认第一个
|
|
||||||
return modules[0]?.id ?? '';
|
return modules[0]?.id ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHashModule(modules: ModuleConfig[]): string {
|
function getHashModule(modules: ModuleConfig[]): string {
|
||||||
const hash = window.location.hash.slice(1);
|
const head = getHashHead();
|
||||||
return modules.some((m) => m.id === hash) ? hash : '';
|
return modules.some((m) => m.id === head) ? head : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||||
const [activeModule, setActiveModule] = useState(() => getInitialModule(modules));
|
const flatModules = useMemo(() => flattenModules(modules), [modules]);
|
||||||
|
const [activeModule, setActiveModule] = useState(() => getInitialModule(flattenModules(modules)));
|
||||||
|
const [openGroupId, setOpenGroupId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onHashChange = () => {
|
const onHashChange = () => {
|
||||||
const h = getHashModule(modules);
|
const h = getHashModule(flatModules);
|
||||||
if (h) setActiveModule(h);
|
if (h) setActiveModule(h);
|
||||||
};
|
};
|
||||||
window.addEventListener('hashchange', onHashChange);
|
window.addEventListener('hashchange', onHashChange);
|
||||||
return () => window.removeEventListener('hashchange', onHashChange);
|
return () => window.removeEventListener('hashchange', onHashChange);
|
||||||
}, [modules]);
|
}, [flatModules]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
if (!openGroupId) return undefined;
|
||||||
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (window.location.hash.slice(1) !== activeModule) {
|
if (event.key === 'Escape') setOpenGroupId(null);
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [openGroupId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
||||||
|
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出。
|
||||||
|
// 注意只比对一级段,避免把子模块写入的 `#<id>/<sub>` 二级段抹掉。
|
||||||
|
if (getHashHead() !== activeModule) {
|
||||||
const { pathname, search } = window.location;
|
const { pathname, search } = window.location;
|
||||||
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
|
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
|
||||||
}
|
}
|
||||||
}, [activeModule]);
|
}, [activeModule]);
|
||||||
|
|
||||||
const switchModule = (id: string) => {
|
const switchModule = (id: string) => {
|
||||||
if (window.location.hash.slice(1) === id) return;
|
setOpenGroupId(null);
|
||||||
|
if (getHashHead() === id) {
|
||||||
|
setActiveModule(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { pathname, search } = window.location;
|
const { pathname, search } = window.location;
|
||||||
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
||||||
setActiveModule(id);
|
setActiveModule(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
|
const ActiveComponent = flatModules.find((module) => module.id === activeModule)?.component ?? flatModules[0]?.component;
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
|
||||||
const watermarkText = useMemo(() => {
|
const watermarkText = useMemo(() => {
|
||||||
const name = user?.userName || '未登录';
|
const name = user?.userName || '未登录';
|
||||||
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
|
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
|
||||||
@@ -72,7 +90,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DemoModeProvider enabled={false}>
|
<DemoModeProvider enabled={false}>
|
||||||
<div className="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="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
||||||
<div className="absolute inset-0" style={{
|
<div className="absolute inset-0" style={{
|
||||||
@@ -81,48 +99,114 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
|||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
{/* Web 侧边栏 (md 及以上) */}
|
{/* Web 侧边栏 (md 及以上) */}
|
||||||
<nav className="hidden md:flex flex-col items-center w-16 bg-white border-r border-gray-100 fixed top-0 left-0 h-full z-50 py-6 gap-2">
|
<nav className="fixed left-0 top-0 z-50 hidden h-full w-20 flex-col items-center border-r border-white/10 bg-slate-950 px-2 py-4 text-white shadow-[12px_0_40px_rgba(15,23,42,0.16)] md:flex">
|
||||||
|
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-blue-600 shadow-lg shadow-blue-950/20">
|
||||||
|
<Building2 size={22} />
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-1 flex-col items-center gap-2">
|
||||||
{modules.map((m) => {
|
{modules.map((m) => {
|
||||||
const Icon = m.icon;
|
const Icon = m.icon;
|
||||||
const isActive = m.id === activeModule;
|
const isGroup = Boolean(m.children?.length);
|
||||||
|
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
|
||||||
|
const isOpen = openGroupId === m.id;
|
||||||
return (
|
return (
|
||||||
|
<div key={m.id} className="relative">
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
type="button"
|
||||||
onClick={() => switchModule(m.id)}
|
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
|
||||||
className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
|
aria-expanded={isGroup ? isOpen : undefined}
|
||||||
isActive
|
aria-haspopup={isGroup ? 'menu' : undefined}
|
||||||
? 'bg-blue-50 text-blue-600'
|
className={cn(
|
||||||
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
|
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
|
||||||
}`}
|
isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white',
|
||||||
|
)}
|
||||||
|
title={m.label}
|
||||||
>
|
>
|
||||||
<Icon size={22} />
|
{isActive ? (
|
||||||
<span className="text-[10px] mt-1 leading-tight">{m.label}</span>
|
<motion.span layoutId="desktop-shell-active" className="absolute inset-0 rounded-2xl bg-blue-600 shadow-lg shadow-blue-950/30" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
|
||||||
|
) : null}
|
||||||
|
<Icon size={21} className="relative mb-1" />
|
||||||
|
<span className="relative leading-tight">{m.label}</span>
|
||||||
|
{isGroup ? <ChevronRight size={11} className={cn('absolute right-1 top-1/2 -translate-y-1/2 transition-transform', isOpen && 'rotate-180')} /> : null}
|
||||||
|
</button>
|
||||||
|
{isGroup && isOpen ? (
|
||||||
|
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute left-[calc(100%+12px)] top-1/2 w-36 -translate-y-1/2 rounded-2xl border border-slate-700 bg-slate-900 p-2 shadow-2xl shadow-slate-950/30">
|
||||||
|
{m.children!.map((child) => {
|
||||||
|
const ChildIcon = child.icon;
|
||||||
|
const childActive = child.id === activeModule;
|
||||||
|
return (
|
||||||
|
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-black transition-colors', childActive ? 'bg-blue-600 text-white' : 'text-slate-300 hover:bg-white/8 hover:text-white')}>
|
||||||
|
<ChildIcon size={16} />{child.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col items-center gap-2 border-t border-white/10 pt-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-400/10 text-emerald-300 ring-1 ring-emerald-300/20" title={user?.userName || '当前用户'}>
|
||||||
|
<ShieldCheck size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 内容区 */}
|
{/* 内容区 */}
|
||||||
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-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>
|
||||||
|
<span>{activeLabel}</span>
|
||||||
|
</div>
|
||||||
|
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
|
||||||
{ActiveComponent && <ActiveComponent />}
|
{ActiveComponent && <ActiveComponent />}
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* 移动端底部导航 (md 以下) */}
|
{/* 移动端底部导航 (md 以下) */}
|
||||||
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-50">
|
<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) => {
|
{modules.map((m) => {
|
||||||
const Icon = m.icon;
|
const Icon = m.icon;
|
||||||
const isActive = m.id === activeModule;
|
const isGroup = Boolean(m.children?.length);
|
||||||
|
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
|
||||||
|
const isOpen = openGroupId === m.id;
|
||||||
return (
|
return (
|
||||||
|
<div key={m.id} className="relative flex justify-center">
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
type="button"
|
||||||
onClick={() => switchModule(m.id)}
|
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
|
||||||
className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
|
aria-expanded={isGroup ? isOpen : undefined}
|
||||||
|
aria-haspopup={isGroup ? 'menu' : undefined}
|
||||||
|
className={cn('relative flex min-h-12 w-full flex-col items-center justify-center gap-0.5 rounded-2xl text-[10px] font-medium transition-colors', isActive ? 'text-blue-700' : 'text-slate-400')}
|
||||||
>
|
>
|
||||||
<Icon size={20} />
|
{isActive ? (
|
||||||
<span className="text-[10px] mt-1">{m.label}</span>
|
<motion.span layoutId="mobile-shell-active" className="absolute inset-0 rounded-2xl bg-blue-50 ring-1 ring-blue-100" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
|
||||||
|
) : null}
|
||||||
|
<Icon size={20} className="relative" />
|
||||||
|
<span className="relative">{m.label}</span>
|
||||||
|
</button>
|
||||||
|
{isGroup && isOpen ? (
|
||||||
|
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute bottom-[calc(100%+14px)] left-1/2 w-36 -translate-x-1/2 rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl shadow-slate-900/15">
|
||||||
|
{m.children!.map((child) => {
|
||||||
|
const ChildIcon = child.icon;
|
||||||
|
const childActive = child.id === activeModule;
|
||||||
|
return (
|
||||||
|
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-medium transition-colors', childActive ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50')}>
|
||||||
|
<ChildIcon size={16} />{child.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</DemoModeProvider>
|
</DemoModeProvider>
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { useState, type ComponentType, type ReactNode } from 'react';
|
||||||
|
import { AlertCircle, Info, Loader2, SearchX, X } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import { cn } from '../../lib/cn';
|
||||||
|
|
||||||
|
export type SurfaceIcon = ComponentType<{ size?: number; className?: string }>;
|
||||||
|
|
||||||
|
export function PageFrame({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
icon: Icon,
|
||||||
|
eyebrow,
|
||||||
|
meta,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
maxWidth = 'max-w-6xl',
|
||||||
|
compactInfo = false,
|
||||||
|
hideHeader = false,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
icon?: SurfaceIcon;
|
||||||
|
eyebrow?: string;
|
||||||
|
meta?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
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')}>
|
||||||
|
{!hideHeader ? <motion.header
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.28, ease: 'easeOut' }}
|
||||||
|
className={cn(
|
||||||
|
'relative rounded-2xl border border-white/70 bg-white/86 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl',
|
||||||
|
compactInfo ? 'overflow-visible' : 'overflow-hidden',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-blue-600 via-cyan-400 to-emerald-400" />
|
||||||
|
{compactInfo ? (
|
||||||
|
<>
|
||||||
|
<div className="flex min-h-[48px] items-center gap-2 px-3 py-1.5 md:min-h-[54px] md:gap-3 md:px-4 md:py-2">
|
||||||
|
{Icon ? (
|
||||||
|
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100 md:h-9 md:w-9">
|
||||||
|
<Icon size={17} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{eyebrow ? <span className="shrink-0 text-[11px] font-black text-blue-600">{eyebrow}</span> : null}
|
||||||
|
{meta ? <span className="hidden truncate text-[11px] font-bold text-slate-400 sm:inline">{meta}</span> : null}
|
||||||
|
</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="hidden shrink-0 items-center gap-2 md:flex">{actions}</div> : null}
|
||||||
|
{subtitle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setInfoOpen(open => !open)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-slate-400 transition-colors md:h-9 md:w-9',
|
||||||
|
infoOpen ? 'border-blue-100 bg-blue-50 text-blue-600' : 'border-slate-100 bg-slate-50 hover:bg-blue-50 hover:text-blue-600',
|
||||||
|
)}
|
||||||
|
aria-label={infoOpen ? '收起页面说明' : '展开页面说明'}
|
||||||
|
title={infoOpen ? '收起说明' : '页面说明'}
|
||||||
|
>
|
||||||
|
{infoOpen ? <X size={16} /> : <Info size={16} />}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{infoOpen && subtitle ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||||
|
className="absolute left-0 right-0 top-full z-40 mt-2 overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-xl md:static md:mt-0 md:rounded-none md:border-x-0 md:border-b-0 md:shadow-none"
|
||||||
|
>
|
||||||
|
<div className="px-4 py-3 text-xs font-bold leading-relaxed text-slate-500 md:text-sm">
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4 p-4 md:flex-row md:items-end md:justify-between md:p-5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
{Icon ? (
|
||||||
|
<span className="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
|
||||||
|
<Icon size={17} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{eyebrow ? <span className="text-[11px] font-black text-blue-600">{eyebrow}</span> : null}
|
||||||
|
{meta ? <span className="text-[11px] font-bold text-slate-400">{meta}</span> : null}
|
||||||
|
</div>
|
||||||
|
<h1 className="truncate text-xl font-black tracking-tight text-slate-950 md:text-2xl">{title}</h1>
|
||||||
|
{subtitle ? <p className="mt-2 max-w-3xl text-xs font-bold leading-relaxed text-slate-500 md:text-sm">{subtitle}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.header> : null}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SurfaceCard({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title?: ReactNode;
|
||||||
|
subtitle?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className={cn('rounded-2xl border border-slate-100 bg-white shadow-sm', className)}>
|
||||||
|
{(title || subtitle || actions) && (
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-slate-100 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
{title ? <h2 className="text-sm font-black text-slate-900">{title}</h2> : null}
|
||||||
|
{subtitle ? <p className="mt-1 text-[11px] font-bold text-slate-400">{subtitle}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricTile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
helper,
|
||||||
|
icon: Icon,
|
||||||
|
tone = 'blue',
|
||||||
|
className,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
unit?: string;
|
||||||
|
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',
|
||||||
|
emerald: 'bg-emerald-50 text-emerald-600 ring-emerald-100',
|
||||||
|
amber: 'bg-amber-50 text-amber-600 ring-amber-100',
|
||||||
|
rose: 'bg-rose-50 text-rose-600 ring-rose-100',
|
||||||
|
slate: 'bg-slate-100 text-slate-600 ring-slate-200',
|
||||||
|
}[tone];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 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={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={cn('font-bold leading-relaxed text-slate-500', compact ? 'mt-2 text-[10px]' : 'mt-3 text-[11px]')}>{helper}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SegmentedNav<T extends string>({
|
||||||
|
tabs,
|
||||||
|
active,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[];
|
||||||
|
active: T;
|
||||||
|
onChange: (id: T) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-2xl border border-white/70 bg-white/90 p-1 shadow-sm backdrop-blur-xl', className)}>
|
||||||
|
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}>
|
||||||
|
{tabs.map(({ id, label, icon: Icon }) => {
|
||||||
|
const isActive = active === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(id)}
|
||||||
|
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',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isActive ? (
|
||||||
|
<motion.span
|
||||||
|
layoutId="segmented-active-pill"
|
||||||
|
className="absolute inset-0 rounded-xl bg-blue-50 shadow-sm ring-1 ring-blue-100"
|
||||||
|
transition={{ type: 'spring', stiffness: 430, damping: 34 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{Icon ? <Icon size={15} className="relative" /> : null}
|
||||||
|
<span className="relative truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonBlock({ className }: { className?: string }) {
|
||||||
|
return <div className={cn('overflow-hidden rounded-xl bg-slate-100 shimmer', className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingState({ label = '数据加载中' }: { label?: string }) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
title = '暂无数据',
|
||||||
|
description = '换个筛选条件或稍后再试',
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<div className="text-sm font-black">加载失败</div>
|
||||||
|
<div className="mt-1 text-xs font-bold leading-relaxed">{message}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FadeIn({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -6 }}
|
||||||
|
transition={{ duration: 0.22, ease: 'easeOut' }}
|
||||||
|
className={cn('w-full min-w-0', className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--app-bg: #f4f7fb;
|
||||||
|
--panel-bg: rgba(255, 255, 255, 0.9);
|
||||||
|
--hairline: rgba(148, 163, 184, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
background: var(--app-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -13,4 +20,79 @@ html {
|
|||||||
body {
|
body {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #fff;
|
||||||
|
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, select {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amap-logo,
|
||||||
|
.amap-copyright {
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amap-toolbar {
|
||||||
|
border: 1px solid #e2e8f0 !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
box-shadow: 0 8px 24px rgb(15 23 42 / 10%) !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.amap-logo,
|
||||||
|
.amap-copyright {
|
||||||
|
bottom: 60px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marquee {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes floatUp {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-marquee {
|
||||||
|
animation: marquee 30s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility no-scrollbar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
&::-webkit-scrollbar { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.72), transparent);
|
||||||
|
animation: shimmer 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enterprise-grid-bg {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 16% 0%, rgba(59, 130, 246, 0.08), transparent 28%),
|
||||||
|
radial-gradient(circle at 90% 12%, rgba(20, 184, 166, 0.08), transparent 26%),
|
||||||
|
linear-gradient(180deg, #f8fbff 0%, var(--app-bg) 42%, #f7f9fc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-date-input::-webkit-calendar-picker-indicator {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
|
return parts.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
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, fetchAuthenticated } from '../../auth/api-client';
|
||||||
|
import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface';
|
||||||
|
|
||||||
|
interface FeedbackItem {
|
||||||
|
id: number;
|
||||||
|
type: 'dimension' | 'bug' | 'ux' | 'other';
|
||||||
|
module: string | null;
|
||||||
|
content: string;
|
||||||
|
contact: string | null;
|
||||||
|
screenshots: string[] | string | null;
|
||||||
|
user_id: string | null;
|
||||||
|
user_name: string | null;
|
||||||
|
status: 'open' | 'in_progress' | 'done' | 'rejected';
|
||||||
|
reply_content: string | null;
|
||||||
|
reply_user: string | null;
|
||||||
|
reply_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
|
dimension: '新维度',
|
||||||
|
bug: 'Bug',
|
||||||
|
ux: '体验',
|
||||||
|
other: '其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
|
||||||
|
{ key: 'open', label: '待处理', cls: 'bg-slate-100 text-slate-500 border-slate-200' },
|
||||||
|
{ key: 'in_progress', label: '处理中', cls: 'bg-amber-100 text-amber-600 border-amber-200' },
|
||||||
|
{ key: 'done', label: '已完成', cls: 'bg-emerald-100 text-emerald-600 border-emerald-200' },
|
||||||
|
{ key: 'rejected', label: '已忽略', cls: 'bg-rose-100 text-rose-500 border-rose-200' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODULE_LABELS: Record<string, string> = {
|
||||||
|
assets: '资产管理',
|
||||||
|
mileage: '里程管理',
|
||||||
|
energy: '能源管理',
|
||||||
|
scheduling: '智能调度',
|
||||||
|
ele: '充电导入',
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
|
||||||
|
if (!s) return [];
|
||||||
|
if (Array.isArray(s)) return s;
|
||||||
|
try { return JSON.parse(String(s)); } catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchItem(id: number, data: { status?: string; reply?: string }): Promise<void> {
|
||||||
|
const res = await fetchAuthenticated(`/api/feedback/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok || !json.ok) throw new Error(json.message || `更新失败 (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackAdminPage() {
|
||||||
|
const [items, setItems] = useState<FeedbackItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'' | FeedbackItem['status']>('');
|
||||||
|
const [active, setActive] = useState<FeedbackItem | null>(null);
|
||||||
|
const [replyDraft, setReplyDraft] = useState('');
|
||||||
|
const [replyStatus, setReplyStatus] = useState<FeedbackItem['status']>('done');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [hint, setHint] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (statusFilter) params.set('status', statusFilter);
|
||||||
|
params.set('limit', '200');
|
||||||
|
const d = await fetchJson<{ items: FeedbackItem[] }>(`/api/feedback/list?${params.toString()}`);
|
||||||
|
setItems(d.items);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [statusFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { reload(); }, [reload]);
|
||||||
|
|
||||||
|
const open = (it: FeedbackItem) => {
|
||||||
|
setActive(it);
|
||||||
|
setReplyDraft(it.reply_content || '');
|
||||||
|
setReplyStatus(it.status === 'open' ? 'done' : it.status);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!active) return;
|
||||||
|
setSaving(true);
|
||||||
|
setHint(null);
|
||||||
|
try {
|
||||||
|
await patchItem(active.id, { status: replyStatus, reply: replyDraft });
|
||||||
|
setHint('已保存');
|
||||||
|
setActive(null);
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
setHint(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
setTimeout(() => setHint(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatusOnly = async (it: FeedbackItem, status: FeedbackItem['status']) => {
|
||||||
|
try {
|
||||||
|
await patchItem(it.id, { status });
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
setHint(e instanceof Error ? e.message : String(e));
|
||||||
|
setTimeout(() => setHint(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const counters = items.reduce<Record<string, number>>((m, it) => {
|
||||||
|
m[it.status] = (m[it.status] || 0) + 1;
|
||||||
|
return m;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageFrame
|
||||||
|
title="用户反馈管理"
|
||||||
|
subtitle="查看、回复、跟进用户提交的建议,形成从问题发现到处理闭环的运营后台。"
|
||||||
|
icon={Inbox}
|
||||||
|
eyebrow="FEEDBACK OPS"
|
||||||
|
meta={`当前 ${items.length} 条反馈`}
|
||||||
|
actions={(
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// 优先 history.back(来自 SPA 内部跳转);否则回到主页
|
||||||
|
if (window.history.length > 1) window.history.back();
|
||||||
|
else { window.location.hash = '#mileage'; }
|
||||||
|
}}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
|
||||||
|
title="返回"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<button onClick={reload} className="flex h-9 w-9 items-center justify-center rounded-xl bg-slate-900 text-white shadow-sm transition-colors hover:bg-slate-800" title="刷新">
|
||||||
|
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
maxWidth="max-w-5xl"
|
||||||
|
>
|
||||||
|
|
||||||
|
{/* 状态过滤 */}
|
||||||
|
<SurfaceCard className="p-2">
|
||||||
|
<div className="flex items-center gap-1 overflow-x-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||||
|
>全部 {items.length}</button>
|
||||||
|
{STATUS_OPTIONS.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
onClick={() => setStatusFilter(statusFilter === o.key ? '' : o.key)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === o.key ? `${o.cls} border` : 'text-slate-500 hover:bg-slate-50'}`}
|
||||||
|
>
|
||||||
|
{o.label} {counters[o.key] ?? 0}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600">
|
||||||
|
<AlertCircle size={14} /> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AnimatePresence>
|
||||||
|
{hint && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
className="bg-emerald-50 border border-emerald-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-emerald-600"
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={14} /> {hint}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 列表 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{loading && items.length === 0 ? (
|
||||||
|
<LoadingState label="正在加载用户反馈" />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<EmptyState title="还没有反馈" description="新的用户反馈会出现在这里" />
|
||||||
|
) : items.map(it => {
|
||||||
|
const shots = parseScreenshots(it.screenshots);
|
||||||
|
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={it.id}
|
||||||
|
className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 cursor-pointer hover:border-blue-200 transition-colors"
|
||||||
|
onClick={() => open(it)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap mb-1.5">
|
||||||
|
<span className="text-[11px] font-bold text-slate-500">{TYPE_LABEL[it.type] || it.type}</span>
|
||||||
|
{it.module && <span className="text-[10px] text-slate-400 font-bold">{MODULE_LABELS[it.module] || it.module}</span>}
|
||||||
|
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border ${statusOpt?.cls || 'bg-slate-50 text-slate-400 border-slate-200'}`}>
|
||||||
|
{statusOpt?.label || it.status}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 ml-auto">
|
||||||
|
{(it.user_name || it.user_id || '匿名')} · {(it.created_at || '').replace('T', ' ').slice(0, 16)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-slate-700 leading-relaxed line-clamp-2 break-words">{it.content}</div>
|
||||||
|
{(shots.length > 0 || it.contact) && (
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
|
||||||
|
{shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} 张</span>}
|
||||||
|
{it.contact && <span>联系 {it.contact}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{it.reply_content && (
|
||||||
|
<div className="bg-blue-50 border border-blue-100 rounded-lg px-2.5 py-1.5 mt-2 text-[11px] text-slate-600 line-clamp-1">
|
||||||
|
回复: {it.reply_content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{it.status === 'open' && (
|
||||||
|
<div className="flex gap-1 mt-2 pt-2 border-t border-slate-50" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button onClick={() => setStatusOnly(it, 'in_progress')} className="flex-1 px-2 py-1 rounded text-[10px] font-bold bg-amber-50 text-amber-600 hover:bg-amber-100">标记处理中</button>
|
||||||
|
<button onClick={() => setStatusOnly(it, 'rejected')} className="px-2 py-1 rounded text-[10px] font-bold bg-rose-50 text-rose-500 hover:bg-rose-100">忽略</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* 详情 / 回复弹窗 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{active && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[80] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||||
|
onClick={() => setActive(null)}
|
||||||
|
>
|
||||||
|
<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-xl 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 flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-black text-slate-800 leading-tight">反馈详情 #{active.id}</div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold">
|
||||||
|
{active.user_name || active.user_id || '匿名'} · {(active.created_at || '').replace('T', ' ').slice(0, 16)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setActive(null)} className="p-1.5 text-slate-400 hover:text-slate-700">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
|
||||||
|
<div className="bg-slate-50 rounded-xl p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap text-[10px] font-bold">
|
||||||
|
<span className="text-slate-500">{TYPE_LABEL[active.type] || active.type}</span>
|
||||||
|
{active.module && <span className="text-slate-400">板块: {MODULE_LABELS[active.module] || active.module}</span>}
|
||||||
|
{active.contact && <span className="text-slate-400">联系: {active.contact}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">{active.content}</div>
|
||||||
|
{parseScreenshots(active.screenshots).length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
{parseScreenshots(active.screenshots).map((u, i) => (
|
||||||
|
<a key={i} href={u} target="_blank" rel="noreferrer" className="block w-20 h-20 rounded-lg overflow-hidden border border-slate-200">
|
||||||
|
<img src={u} alt="" className="w-full h-full object-cover" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">状态</p>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{STATUS_OPTIONS.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
onClick={() => setReplyStatus(o.key)}
|
||||||
|
className={`px-3 py-1 rounded-full text-[11px] font-bold border ${replyStatus === o.key ? o.cls : 'bg-white text-slate-500 border-slate-200 hover:border-slate-300'}`}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">回复内容</p>
|
||||||
|
<textarea
|
||||||
|
value={replyDraft}
|
||||||
|
onChange={(e) => setReplyDraft(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder="给用户的回复(用户在「我的反馈」里能看到)"
|
||||||
|
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">{replyDraft.length} / 2000</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
onClick={() => setActive(null)}
|
||||||
|
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50"
|
||||||
|
>取消</button>
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Send size={13} />}
|
||||||
|
{saving ? '保存中…' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</PageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
+301
-1433
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 };
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ export async function fetchByType(subject?: string | null): Promise<TypeSummary[
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchVehicleList(params: {
|
export async function fetchVehicleList(params: {
|
||||||
|
inventoryKeys?: string[];
|
||||||
|
brand?: string;
|
||||||
batch?: string;
|
batch?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
@@ -50,8 +52,10 @@ export async function fetchVehicleList(params: {
|
|||||||
department?: string;
|
department?: string;
|
||||||
attendance?: string;
|
attendance?: string;
|
||||||
subject?: string | null;
|
subject?: string | null;
|
||||||
|
source?: string;
|
||||||
}): Promise<VehicleListItem[]> {
|
}): Promise<VehicleListItem[]> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
|
if (params.brand) query.set('brand', params.brand);
|
||||||
if (params.batch) query.set('batch', params.batch);
|
if (params.batch) query.set('batch', params.batch);
|
||||||
if (params.model) query.set('model', params.model);
|
if (params.model) query.set('model', params.model);
|
||||||
if (params.location) query.set('location', params.location);
|
if (params.location) query.set('location', params.location);
|
||||||
@@ -65,7 +69,12 @@ export async function fetchVehicleList(params: {
|
|||||||
if (params.department) query.set('department', params.department);
|
if (params.department) query.set('department', params.department);
|
||||||
if (params.attendance) query.set('attendance', params.attendance);
|
if (params.attendance) query.set('attendance', params.attendance);
|
||||||
if (params.subject) query.set('subject', params.subject);
|
if (params.subject) query.set('subject', params.subject);
|
||||||
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
|
if (params.source) query.set('source', params.source);
|
||||||
|
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 {
|
export interface WeeklyDetailItem {
|
||||||
@@ -76,6 +85,45 @@ export interface WeeklyDetailItem {
|
|||||||
customer_name: string | null;
|
customer_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FlowType = 'delivered' | 'returned' | 'replaced';
|
||||||
|
|
||||||
|
export interface FlowDailyPoint {
|
||||||
|
date: string;
|
||||||
|
delivered: number;
|
||||||
|
returned: number;
|
||||||
|
replaced: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowDetailItem {
|
||||||
|
id: string;
|
||||||
|
type: FlowType;
|
||||||
|
typeLabel: string;
|
||||||
|
date: string;
|
||||||
|
truckId: string;
|
||||||
|
plateNumber: string;
|
||||||
|
eventTime: string | null;
|
||||||
|
submitTime: string | null;
|
||||||
|
department: string;
|
||||||
|
manager: string;
|
||||||
|
customerName: string | null;
|
||||||
|
contractNo: string | null;
|
||||||
|
projectName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowStatsResponse {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
daily: FlowDailyPoint[];
|
||||||
|
totals: {
|
||||||
|
delivered: number;
|
||||||
|
returned: number;
|
||||||
|
replaced: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
details: FlowDetailItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> {
|
export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> {
|
||||||
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
|
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
|
||||||
}
|
}
|
||||||
@@ -112,6 +160,25 @@ export async function fetchRegionChart(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWeeklyDetail(type: string): Promise<WeeklyDetailItem[]> {
|
export async function fetchWeeklyDetail(
|
||||||
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?type=${type}`);
|
type: 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()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFlowStats(params: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
subject?: string | null;
|
||||||
|
}): Promise<FlowStatsResponse> {
|
||||||
|
const query = new URLSearchParams({ start: params.start, end: params.end });
|
||||||
|
if (params.subject) query.set('subject', params.subject);
|
||||||
|
return fetchJson<FlowStatsResponse>(`${BASE}/flow-stats?${query.toString()}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,943 @@
|
|||||||
|
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 "./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: "现在多少车在运营、多少车在库存?",
|
||||||
|
text: BUSINESS_QUESTIONS[0],
|
||||||
|
detail: "总资产 · 运营 · 库存 · 运营率",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Truck,
|
||||||
|
title: "库存集中在哪些车型和区域?",
|
||||||
|
text: BUSINESS_QUESTIONS[1],
|
||||||
|
detail: "库存分布 · 继续查看车辆",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: History,
|
||||||
|
title: "最近7天交车、还车分别多少?",
|
||||||
|
text: BUSINESS_QUESTIONS[2],
|
||||||
|
detail: "按日趋势 · 业务明细",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ListIcon,
|
||||||
|
title: "有哪些车辆需要跟进?",
|
||||||
|
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 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>{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 && (
|
||||||
|
<button
|
||||||
|
className="aq-latest-answer"
|
||||||
|
onClick={() => revealTurn(turns[turns.length - 1].id)}
|
||||||
|
>
|
||||||
|
回到最新回答 <ArrowUpRight size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{lastPlan && (
|
||||||
|
<div className="aq-context">
|
||||||
|
<span>正在分析</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>
|
||||||
|
)}
|
||||||
|
{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,302 @@
|
|||||||
|
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 { 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 [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";
|
||||||
|
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>
|
||||||
|
<strong>{result.title || "综合分析"}</strong>
|
||||||
|
<span>{sections.length}项分析</span>
|
||||||
|
</div>
|
||||||
|
<p 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?.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,682 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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 [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 (
|
||||||
|
<>
|
||||||
|
<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 { 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 } };
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
<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 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">
|
||||||
|
{presentationMetrics(result, block.metrics).map((metric) => (
|
||||||
|
<div key={metric.label}>
|
||||||
|
<span>{metric.label}</span>
|
||||||
|
<strong>
|
||||||
|
{metric.value.toLocaleString()}
|
||||||
|
<small>{metric.unit}</small>
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{interaction && (
|
||||||
|
<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 GenerativeResultViewContent({
|
||||||
|
result,
|
||||||
|
operate,
|
||||||
|
restore,
|
||||||
|
active,
|
||||||
|
activate,
|
||||||
|
subject,
|
||||||
|
disabled,
|
||||||
|
correctionMessage,
|
||||||
|
correctionResultId,
|
||||||
|
showCorrection,
|
||||||
|
}: ResultViewProps) {
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
<div className="aq-answer-actions">
|
||||||
|
<button
|
||||||
|
disabled={disabled || saving}
|
||||||
|
onClick={() => void save("export")}
|
||||||
|
>
|
||||||
|
<ArrowDownToLine size={14} />
|
||||||
|
导出明细
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={disabled || active || !!correctionMessage}
|
||||||
|
onClick={activate}
|
||||||
|
>
|
||||||
|
{active ? "正在基于此结果追问" : "基于此结果追问"}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
{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,950 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) {
|
||||||
|
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>
|
||||||
|
<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 [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}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<div className="aq-answer-actions">
|
||||||
|
{plan.kind !== "clarify" && (!result.conciseCount || result.metrics[0]?.value > 0) && (
|
||||||
|
<button
|
||||||
|
disabled={disabled || saving}
|
||||||
|
onClick={() => void download()}
|
||||||
|
aria-label="导出当前结果"
|
||||||
|
>
|
||||||
|
<ArrowDownToLine size={14} />
|
||||||
|
导出明细
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{plan.kind !== "clarify" && (
|
||||||
|
<button
|
||||||
|
disabled={disabled || active || !!correctionMessage}
|
||||||
|
onClick={activate}
|
||||||
|
>
|
||||||
|
{active ? "正在基于此结果追问" : "基于此结果追问"}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
{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,109 @@
|
|||||||
|
// 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";
|
||||||
|
|
||||||
|
export function ThinkingState({
|
||||||
|
steps,
|
||||||
|
working,
|
||||||
|
started,
|
||||||
|
failed,
|
||||||
|
stopped,
|
||||||
|
outcome,
|
||||||
|
}: {
|
||||||
|
steps: string[];
|
||||||
|
working: boolean;
|
||||||
|
started: number;
|
||||||
|
failed?: boolean;
|
||||||
|
stopped?: boolean;
|
||||||
|
outcome?: string;
|
||||||
|
}) {
|
||||||
|
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;
|
||||||
|
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
|
||||||
|
? "正在分析"
|
||||||
|
: 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,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">{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.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,174 @@
|
|||||||
|
import { useCallback, 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';
|
||||||
|
|
||||||
|
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 [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 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 });
|
||||||
|
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"><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>
|
||||||
|
<p className="bi-flow-modal-definition">交车包含替换交车;业务类型按实际记录拆分,同车多笔业务分别计次。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div ref={scrollRoot} className="bi-flow-modal-body">
|
||||||
|
{!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>
|
||||||
|
|
||||||
|
<div className="bi-flow-modal-section-heading"><div><h4>{showDetails ? '业务明细' : dimensions.length ? '组合统计' : '筛选汇总'}</h4><p>{showDetails ? `共 ${countLabel(detailRows.length)} 条业务记录` : `共 ${countLabel(groups.length)} 组 · 占比以当前筛选的 ${countLabel(filteredRows.length)} 车次为基数`}</p></div>
|
||||||
|
{!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">
|
||||||
|
<AnalysisExportButton disabled={!selectedRows.length} onExport={exportWorkbook} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!showDetails ? groups.length ? <>
|
||||||
|
<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={() => { setDetailKey(group.key); }}>查看明细</button></td>
|
||||||
|
</tr>)}
|
||||||
|
</tbody></table></div>
|
||||||
|
<ProgressiveRowsStatus {...groupProgress} unit="组" />
|
||||||
|
</> : <div className="bi-flow-modal-empty">当前筛选范围暂无交还车数据,请调整筛选。</div>
|
||||||
|
: detailRows.length ? <>
|
||||||
|
<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>
|
||||||
|
</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,64 @@
|
|||||||
|
import { AssetSummarySection } from './overview/AssetSummarySection';
|
||||||
|
import { FlowStatisticsCard } from './overview/FlowStatisticsCard';
|
||||||
|
import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection';
|
||||||
|
import { SummaryMetrics } from './overview/SummaryMetrics';
|
||||||
|
import type { OverviewViewProps } from './overview/types';
|
||||||
|
|
||||||
|
export function OverviewView(props: OverviewViewProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SummaryMetrics
|
||||||
|
summary={props.summary}
|
||||||
|
operatingRate={props.operatingRate}
|
||||||
|
inventoryRate={props.inventoryRate}
|
||||||
|
pendingRate={props.pendingRate}
|
||||||
|
setVehicleSelection={props.setVehicleSelection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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,89 @@
|
|||||||
|
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, getDimensionValueLabel, type AnalysisFilterValues, type AnalysisView } from '../../analytics-model';
|
||||||
|
|
||||||
|
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 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();
|
||||||
|
};
|
||||||
|
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,296 @@
|
|||||||
|
import { useEffect, 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 { downloadAnalysisWorkbook, statisticsExportRows, vehicleExportRows } from '../../analysis-export';
|
||||||
|
import './asset-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 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 [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 });
|
||||||
|
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' : ''}`}>
|
||||||
|
<header className="analysis-header"><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} /> : <>
|
||||||
|
{showDetails && <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 && <>
|
||||||
|
<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>}
|
||||||
|
{view === 'abnormal' && <p className="analysis-definition">已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}</p>}
|
||||||
|
<div className="analysis-table-tools"><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>没有符合条件的数据</h3><p>移除部分筛选条件,或恢复当前入口的全部数据。</p>{Object.keys(filterValues).length > 0 && <button className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div> : <>
|
||||||
|
<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={() => setDetailGroup(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">
|
||||||
|
<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"><span>统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围</span><span>{showDetails ? '仅展示当前分组与筛选条件内的车辆' : '同维度筛选为“或”,不同维度筛选为“且”'}</span></footer>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VehicleRows({ vehicles, inventory = false }: { vehicles: VehicleListItem[]; inventory?: boolean }) {
|
||||||
|
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(' · ');
|
||||||
|
};
|
||||||
|
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 }: { rows: WeeklyDetailItem[] }) {
|
||||||
|
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 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 });
|
||||||
|
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) : '当前筛选结果'}`);
|
||||||
|
};
|
||||||
|
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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Filter } from 'lucide-react';
|
||||||
|
|
||||||
|
type Row = { key: string; label: string; values: ReactNode[]; count: number; share: number; note?: ReactNode };
|
||||||
|
type Props = {
|
||||||
|
dimensions: string[];
|
||||||
|
rows: Row[];
|
||||||
|
unit: string;
|
||||||
|
merged: boolean;
|
||||||
|
setMerged: (value: boolean) => void;
|
||||||
|
spans: number[][];
|
||||||
|
parentTotals: number[][];
|
||||||
|
onDetails: (index: number) => void;
|
||||||
|
onFilter?: (index: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Retain table semantics and actual parent row spans at narrow viewport widths. */
|
||||||
|
export function MobileGroupedTable({ dimensions, rows, unit, merged, setMerged, spans, parentTotals, onDetails, onFilter }: Props) {
|
||||||
|
const multiple = dimensions.length > 1;
|
||||||
|
return <section className={`analysis-mobile-grid${onFilter ? '' : ' without-filter'}`}>
|
||||||
|
<div className="analysis-mobile-grid-heading"><strong>分组统计</strong>{multiple ? <label><input type="checkbox" role="switch" checked={merged} onChange={e => setMerged(e.target.checked)} /><span>合并单元格</span></label> : <span>数量 · 占比</span>}</div>
|
||||||
|
{multiple && <p className="analysis-mobile-grid-hint">左右滑动查看完整表格{merged ? ' · 相同上级已合并' : ' · 每行独立显示'}</p>}
|
||||||
|
<div className="analysis-mobile-grid-scroll" role="region" aria-label="分组统计表,可左右滑动" tabIndex={0}>
|
||||||
|
<table style={{ minWidth: multiple ? `${dimensions.length * 108 + 192}px` : '100%' }}>
|
||||||
|
<thead><tr>{dimensions.length ? dimensions.map((label, i) => <th scope="col" key={i}>{label}</th>) : <th scope="col">范围</th>}<th scope="col" className="mobile-grid-number">数量<small>({unit})</small></th><th scope="col" className="mobile-grid-share">占比</th><th scope="col" className="mobile-grid-actions">操作</th></tr></thead>
|
||||||
|
<tbody>{rows.map((row, rowIndex) => <tr key={row.key} className={merged && spans[rowIndex]?.[0] > 0 ? 'mobile-grid-group-start' : undefined}>
|
||||||
|
{dimensions.length ? row.values.map((value, colIndex) => {
|
||||||
|
const span = merged ? spans[rowIndex]?.[colIndex] ?? 1 : 1;
|
||||||
|
if (!span) return null;
|
||||||
|
const parent = merged && colIndex < dimensions.length - 1;
|
||||||
|
return <td key={colIndex} rowSpan={span} className={parent ? 'mobile-grid-parent' : 'mobile-grid-label'}>{value}{parent && <small>合计 <b>{parentTotals[rowIndex]?.[colIndex]?.toLocaleString('zh-CN')}</b> {unit}</small>}</td>;
|
||||||
|
}) : <td className="mobile-grid-label">当前结果</td>}
|
||||||
|
<td className="mobile-grid-number"><strong>{row.count.toLocaleString('zh-CN')}</strong>{row.note && <small>{row.note}</small>}</td>
|
||||||
|
<td className="mobile-grid-share"><span>{row.share.toFixed(1)}%</span><i aria-hidden="true"><i style={{ width: `${row.share}%` }} /></i></td>
|
||||||
|
<td className="mobile-grid-actions"><div>{onFilter && dimensions.length > 0 && <button className="mobile-grid-filter" aria-label={`筛选分组 ${row.label}`} title="筛选此组" onClick={() => onFilter(rowIndex)}><Filter size={15} /></button>}<button aria-label={`查看明细 ${row.label}`} onClick={() => onDetails(rowIndex)}>明细</button></div></td>
|
||||||
|
</tr>)}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { RefCallback } from 'react';
|
||||||
|
import './progressive-rows.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visibleCount: number;
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
loadMore: () => void;
|
||||||
|
sentinelRef: RefCallback<HTMLDivElement>;
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressiveRowsStatus({ visibleCount, total, hasMore, loadMore, sentinelRef, unit = '条' }: Props) {
|
||||||
|
return <div ref={sentinelRef} className="analysis-progressive-status">
|
||||||
|
<span role="status" aria-live="polite">已加载 {visibleCount.toLocaleString('zh-CN')} / {total.toLocaleString('zh-CN')} {unit}</span>
|
||||||
|
{hasMore ? <><span className="analysis-progressive-hint">下滚继续加载</span><button type="button" onClick={loadMore}>加载更多</button></> : <span className="analysis-progressive-hint">已显示全部</span>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
.asset-analysis-overlay { position:fixed; inset:0; z-index:70; background:rgb(0 0 0 / .4); backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center; padding:24px; }
|
||||||
|
.asset-analysis-dialog { --analysis-line:#d5deea; --analysis-boundary:#b8c6d8; --analysis-accent:#2563eb; --analysis-tint:#eff6ff; display:flex; flex-direction:column; width:min(1040px,100%); max-height:90dvh; min-height:430px; background:white; border-radius:12px; box-shadow:0 24px 60px rgb(15 23 42 / .2); overflow:hidden; color:#334155; font-size:12px; outline:none; }
|
||||||
|
.asset-analysis-dialog.analysis-amber { --analysis-accent:#b45309; --analysis-tint:#fffbeb; }
|
||||||
|
.asset-analysis-dialog button { cursor:pointer; font:inherit; transition:background-color .15s,color .15s; }
|
||||||
|
.asset-analysis-dialog button:disabled { cursor:default; opacity:.45; }
|
||||||
|
.asset-analysis-dialog button:focus-visible,.asset-analysis-dialog input:focus-visible,.asset-analysis-dialog select:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
|
||||||
|
.analysis-header { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:14px 18px; background:#1e293b; color:white; flex-shrink:0; }
|
||||||
|
.analysis-header h2 { display:flex; align-items:center; gap:8px; font-size:14px; font-weight:650; }
|
||||||
|
.analysis-header h2 svg { color:#60a5fa; }
|
||||||
|
.analysis-header p { font-size:11px; color:#cbd5e1; margin-top:4px; line-height:1.5; overflow-wrap:anywhere; }
|
||||||
|
.analysis-header button { padding:9px; border-radius:6px; flex-shrink:0; }
|
||||||
|
.analysis-header button:hover { background:#334155; }
|
||||||
|
.analysis-scroll { overflow-y:auto; overscroll-behavior:contain; min-height:0; padding:16px 18px; flex:1; }
|
||||||
|
.analysis-builder { border:1px solid #e2e8f0; border-radius:8px; padding:12px; background:#fff; }
|
||||||
|
.analysis-builder-heading { display:flex; align-items:center; flex-wrap:wrap; gap:8px; margin-bottom:10px; }
|
||||||
|
.analysis-builder-heading h3 { font-size:12px; font-weight:650; color:#1e293b; }
|
||||||
|
.analysis-builder-heading>span { color:#64748b; font-size:11px; }
|
||||||
|
.analysis-reset { display:inline-flex; align-items:center; gap:4px; color:#64748b; margin-left:auto; padding:3px 0; font-size:11px!important; }
|
||||||
|
.analysis-reset:hover { color:#2563eb; }
|
||||||
|
.analysis-dimension-options { display:flex; flex-wrap:wrap; gap:6px; }
|
||||||
|
.analysis-dimension-options button { display:inline-flex; align-items:center; gap:5px; min-height:30px; border:1px solid #e2e8f0; background:#f8fafc; color:#475569; padding:5px 9px; border-radius:5px; font-size:12px; }
|
||||||
|
.analysis-dimension-options button:hover { border-color:#93c5fd; }
|
||||||
|
.analysis-dimension-options button[aria-pressed=true] { background:#eff6ff; color:#1d4ed8; border-color:#93c5fd; }
|
||||||
|
.analysis-dimension-order { display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px; font-size:10px; border-radius:3px; background:#2563eb; color:white; }
|
||||||
|
.analysis-filter-row { display:flex; align-items:center; flex-wrap:wrap; gap:6px; border-top:1px solid #edf2f7; margin-top:12px; padding-top:10px; }
|
||||||
|
.analysis-filter-label { display:inline-flex; align-items:center; gap:4px; color:#475569; font-weight:600; margin-right:3px; }
|
||||||
|
.analysis-filter-chip { display:inline-flex; align-items:center; max-width:100%; background:#f1f5f9; border:1px solid #e2e8f0; border-radius:5px; overflow:hidden; }
|
||||||
|
.analysis-filter-chip button { padding:5px 7px; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.analysis-filter-chip button:hover { background:#e2e8f0; }
|
||||||
|
.analysis-filter-chip button:last-child { flex-shrink:0; padding:6px; }
|
||||||
|
.analysis-add-filter { display:inline-flex; align-items:center; gap:3px; border:1px dashed #94a3b8; border-radius:5px; padding:5px 7px; color:#475569; background:white; }
|
||||||
|
.analysis-add-filter:hover { color:#2563eb; border-color:#2563eb; }
|
||||||
|
.analysis-filter-empty { color:#64748b; font-size:11px; }
|
||||||
|
.analysis-text-button { color:#2563eb; padding:5px; }
|
||||||
|
.analysis-filter-editor { margin-top:12px; border:1px solid #bfdbfe; border-radius:7px; box-shadow:0 4px 16px rgb(15 23 42 / .05); background:#fff; }
|
||||||
|
.analysis-filter-editor-top { display:flex; align-items:center; gap:12px; padding:10px 12px; border-bottom:1px solid #e2e8f0; }
|
||||||
|
.analysis-filter-editor-top>label:first-child { display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||||||
|
.analysis-filter-editor select { padding:5px 8px; border:1px solid #cbd5e1; background:white; border-radius:5px; }
|
||||||
|
.analysis-filter-search { display:flex; align-items:center; gap:6px; min-width:0; margin-left:auto; border:1px solid #e2e8f0; padding:6px 8px; border-radius:5px; color:#64748b; }
|
||||||
|
.analysis-filter-search input { min-width:0; width:130px; outline:none; color:#334155; }
|
||||||
|
.analysis-icon-button { padding:5px; }
|
||||||
|
.analysis-filter-options { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:3px 12px; padding:8px 12px; max-height:190px; overflow:auto; }
|
||||||
|
.analysis-filter-options label { display:flex; align-items:center; gap:7px; min-width:0; padding:7px 4px; cursor:pointer; border-radius:4px; }
|
||||||
|
.analysis-filter-options label:hover { background:#f8fafc; }
|
||||||
|
.analysis-filter-options input { accent-color:#2563eb; flex-shrink:0; }
|
||||||
|
.analysis-filter-options label span { overflow-wrap:anywhere; }
|
||||||
|
.analysis-filter-options small { margin-left:auto; color:#64748b; font-size:11px; white-space:nowrap; }
|
||||||
|
.analysis-filter-options>p { grid-column:1/-1; padding:12px 0; color:#64748b; }
|
||||||
|
.analysis-filter-editor-bottom { display:flex; align-items:center; flex-wrap:wrap; gap:8px; border-top:1px solid #e2e8f0; padding:8px 12px; }
|
||||||
|
.analysis-filter-editor-bottom>span { color:#64748b; font-size:11px; margin-right:auto; }
|
||||||
|
.analysis-primary,.analysis-button { display:inline-flex; align-items:center; justify-content:center; gap:5px; min-height:30px; padding:6px 10px; font-size:12px; border-radius:5px; background:#2563eb; color:#fff; }
|
||||||
|
.analysis-primary:hover { background:#1d4ed8; }
|
||||||
|
.analysis-button { background:#f1f5f9; color:#475569; }
|
||||||
|
.analysis-result-bar { display:flex; align-items:center; gap:18px; flex-wrap:wrap; padding:13px 0 8px; color:#64748b; }
|
||||||
|
.analysis-result-bar strong { font-size:22px; font-weight:650; color:var(--analysis-accent); font-variant-numeric:tabular-nums; margin:0 5px; }
|
||||||
|
.analysis-result-dimensions { margin-left:auto; font-size:11px; max-width:65%; line-height:1.7; }
|
||||||
|
.analysis-inventory-subsets { display:flex; align-items:center; flex-wrap:wrap; gap:6px 16px; margin:2px 0 12px; font-size:12px; }
|
||||||
|
.analysis-inventory-subsets strong { font-size:13px; font-weight:600; font-variant-numeric:tabular-nums; }
|
||||||
|
.analysis-inventory-subsets small { font-size:11px; color:#64748b; }
|
||||||
|
.analysis-pending-count { color:#2563eb; font-variant-numeric:tabular-nums; }
|
||||||
|
.analysis-abnormal-count { color:#b45309; font-variant-numeric:tabular-nums; }
|
||||||
|
.analysis-zero-count { color:#94a3b8; font-variant-numeric:tabular-nums; }
|
||||||
|
.analysis-definition { font-size:11px; line-height:1.7; padding:8px 10px; background:#fffbeb; color:#92400e; border-radius:5px; margin:8px 0; }
|
||||||
|
.analysis-table-tools { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px; margin:10px 0; }
|
||||||
|
.analysis-table-tools h3 { font-size:13px; font-weight:600; color:#1e293b; }
|
||||||
|
.analysis-table-tools h3 small { font-size:11px; color:#64748b; font-weight:400; margin-left:6px; }
|
||||||
|
.analysis-table-tools>p { font-size:11px; color:#64748b; }
|
||||||
|
.analysis-table-tools label { display:flex; align-items:center; gap:6px; font-size:11px; color:#64748b; }
|
||||||
|
.analysis-table-tools select { border:1px solid #e2e8f0; border-radius:5px; padding:5px 7px; background:white; color:#475569; font-size:11px; }
|
||||||
|
.analysis-group-table { overflow:auto; border:1px solid var(--analysis-boundary); border-radius:6px 6px 0 0; }
|
||||||
|
.analysis-group-table table { border-collapse:collapse; width:100%; font-size:12px; }
|
||||||
|
.analysis-group-table th { padding:10px 12px; color:#64748b; font-weight:500; text-align:left; background:#f8fafc; white-space:nowrap; }
|
||||||
|
.analysis-group-table td { padding:10px 12px; border-top:1px solid var(--analysis-line); min-width:84px; max-width:230px; overflow-wrap:anywhere; }
|
||||||
|
.analysis-group-table tbody tr:hover { background:#f8fafc; }
|
||||||
|
.analysis-group-table .analysis-numeric { text-align:right; white-space:nowrap; }
|
||||||
|
.analysis-group-table .analysis-numeric strong { font-size:14px; font-weight:650; color:var(--analysis-accent); font-variant-numeric:tabular-nums; }
|
||||||
|
.analysis-share-cell { min-width:90px!important; text-align:right; font-variant-numeric:tabular-nums; color:#64748b; }
|
||||||
|
.analysis-bar-track { display:block; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden; margin-top:5px; }
|
||||||
|
.analysis-bar-track>span { display:block; height:100%; background:var(--analysis-accent); border-radius:2px; }
|
||||||
|
.analysis-group-actions { white-space:nowrap; min-width:170px!important; }
|
||||||
|
.analysis-group-actions button { display:inline-flex; align-items:center; gap:4px; padding:5px 6px; color:#2563eb; font-size:11px; border-radius:4px; }
|
||||||
|
.analysis-group-actions button:hover { background:#eff6ff; }
|
||||||
|
.analysis-group-actions button+button { margin-left:5px; }
|
||||||
|
.analysis-table-total { display:flex; justify-content:space-between; gap:12px; padding:10px 12px; color:#64748b; font-size:11px; background:#f8fafc; border:1px solid var(--analysis-boundary); border-top:0; border-radius:0 0 6px 6px; }
|
||||||
|
.analysis-navigation { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 18px; border-bottom:1px solid #e2e8f0; flex-shrink:0; }
|
||||||
|
.analysis-navigation nav { display:flex; flex-wrap:wrap; gap:6px; }
|
||||||
|
.analysis-back { display:flex; gap:5px; align-items:center; font-size:12px; color:#2563eb; white-space:nowrap; padding:5px 0; }
|
||||||
|
.analysis-detail-scope { color:#64748b; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.analysis-search { border:1px solid #e2e8f0; padding:6px 8px; border-radius:5px; }
|
||||||
|
.analysis-search input { width:220px; color:#334155; outline:none; min-width:0; font-size:12px; }
|
||||||
|
.analysis-detail-table { overflow-x:auto; }
|
||||||
|
.analysis-detail-table table { width:100%; min-width:600px; border-collapse:collapse; font-size:12px; }
|
||||||
|
.analysis-detail-table th { font-weight:600; text-align:left; background:#f8fafc; padding:10px; color:#64748b; }
|
||||||
|
.analysis-detail-table td { padding:10px; border-bottom:1px solid #e2e8f0; }
|
||||||
|
.analysis-detail-table td small { display:block; font-size:11px; color:#64748b; margin-top:3px; }
|
||||||
|
.analysis-details .text-base { font-size:13px; }.analysis-details .text-sm { font-size:12px; }
|
||||||
|
.analysis-message { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; padding:40px 16px; text-align:center; color:#64748b; flex:1; font-size:12px; }
|
||||||
|
.analysis-message h3 { font-size:13px; font-weight:600; color:#334155; }
|
||||||
|
.analysis-footer { display:flex; justify-content:space-between; gap:8px; flex-wrap:wrap; font-size:10px; color:#64748b; padding:9px 18px; background:#f8fafc; border-top:1px solid #e2e8f0; flex-shrink:0; }
|
||||||
|
/* Legacy weekly entries share the compact analysis vocabulary. */
|
||||||
|
.analysis-summary { display:flex; align-items:center; justify-content:space-between; gap:15px; background:var(--analysis-tint); padding:12px; border-radius:6px; margin:10px 0; }
|
||||||
|
.analysis-summary strong { display:block; font-size:22px; color:var(--analysis-accent); }.analysis-summary small { font-size:11px; margin-left:4px; }
|
||||||
|
.analysis-row { display:grid; grid-template-columns:1fr 1fr auto 16px; gap:12px; width:100%; text-align:left; align-items:center; padding:12px; border-bottom:1px solid #e2e8f0; }
|
||||||
|
@media(max-width:640px) {
|
||||||
|
.asset-analysis-overlay { padding:8px; }.asset-analysis-dialog { max-height:94dvh; min-height:58dvh; border-radius:10px; }
|
||||||
|
.analysis-header { padding:12px; }.analysis-scroll { padding:12px; }.analysis-builder { padding:10px; }
|
||||||
|
.analysis-builder-heading>span { font-size:10px; }.analysis-dimension-options { gap:5px; }.analysis-dimension-options button { min-height:34px; padding:6px 8px; }
|
||||||
|
.analysis-filter-row { gap:6px; }.analysis-filter-empty { width:100%; margin-top:1px; font-size:10px; }
|
||||||
|
.analysis-filter-editor-top { flex-wrap:wrap; gap:8px; padding:8px; }.analysis-filter-search { order:3; width:100%; }.analysis-filter-search input { width:100%; }.analysis-filter-editor-top .analysis-icon-button { margin-left:auto; }
|
||||||
|
.analysis-filter-options { grid-template-columns:repeat(2,minmax(0,1fr)); gap:2px 8px; padding:8px; }.analysis-filter-options label { padding:9px 2px; }.analysis-filter-options small { font-size:10px; }
|
||||||
|
.analysis-filter-editor-bottom { gap:6px; padding:8px; }.analysis-filter-editor-bottom>span { width:100%; }.analysis-filter-editor-bottom .analysis-text-button { margin-right:auto; }
|
||||||
|
.analysis-result-bar { gap:10px; }.analysis-result-dimensions { max-width:100%; width:100%; margin:0; font-size:10px; }
|
||||||
|
.analysis-table-tools>p { font-size:10px; }.analysis-definition { font-size:10px; }
|
||||||
|
.analysis-group-table { border:0; overflow:visible; }.analysis-group-table table,.analysis-group-table tbody { display:block; }.analysis-group-table thead { display:none; }
|
||||||
|
.analysis-group-table tr { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); border:1px solid #e2e8f0; border-radius:6px; margin-bottom:8px; padding:7px; gap:4px 8px; }
|
||||||
|
.analysis-group-table td { display:flex; flex-direction:column; gap:3px; min-width:0!important; max-width:none; padding:4px; border:0; font-size:12px; }
|
||||||
|
.analysis-group-table td[data-label]::before { content:attr(data-label); font-size:10px; color:#64748b; }
|
||||||
|
.analysis-group-table .analysis-numeric,.analysis-group-table .analysis-share-cell { text-align:left; }.analysis-group-table .analysis-share-cell .analysis-bar-track { max-width:100px; }
|
||||||
|
.analysis-group-table .analysis-group-actions { grid-column:1/-1; display:flex; flex-direction:row; justify-content:flex-end; border-top:1px solid #edf2f7; padding-top:5px; }
|
||||||
|
.analysis-group-actions button { min-height:32px; }.analysis-table-total { border-radius:6px; border-top:1px solid #e2e8f0; font-size:10px; }
|
||||||
|
.analysis-navigation { padding:8px 12px; }.analysis-search { width:100%; }.analysis-search input { width:100%; }.analysis-footer { padding:8px 12px; font-size:10px; }
|
||||||
|
}
|
||||||
|
@media(prefers-reduced-motion:reduce) { .asset-analysis-dialog * { transition:none!important; animation:none!important; } }
|
||||||
|
/* Dimension labels and their Excel-style filter dropdowns form one control. */
|
||||||
|
.analysis-builder { position:relative; }
|
||||||
|
.analysis-dimension-column { display:inline-flex; min-width:0; max-width:100%; }
|
||||||
|
.analysis-dimension-options .analysis-dimension-name { border-radius:5px 0 0 5px; white-space:nowrap; }
|
||||||
|
.analysis-dimension-options .analysis-dimension-dropdown { border-radius:0 5px 5px 0; border-left:0; padding:5px 7px; min-width:28px; justify-content:center; }
|
||||||
|
.analysis-dimension-dropdown svg { flex-shrink:0; }
|
||||||
|
.analysis-inline-filter { max-width:105px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:10px; }
|
||||||
|
.analysis-dimension-column.has-filter .analysis-dimension-dropdown { background:#eff6ff; color:#2563eb; border-color:#93c5fd; }
|
||||||
|
.analysis-active-note { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:4px; margin-top:9px; font-size:10px; color:#64748b; }
|
||||||
|
.analysis-active-note .analysis-text-button { font-size:10px; }
|
||||||
|
.analysis-column-popover { position:absolute; z-index:5; width:330px; max-width:calc(100% - 16px); margin-top:0; box-shadow:0 8px 28px rgb(15 23 42 / .18); }
|
||||||
|
.analysis-column-popover .analysis-filter-editor-top { gap:7px; padding:9px 10px; }
|
||||||
|
.analysis-column-popover .analysis-filter-editor-top strong { font-size:12px; }
|
||||||
|
.analysis-column-popover .analysis-filter-editor-top>span { font-size:10px; color:#64748b; }
|
||||||
|
.analysis-column-popover .analysis-icon-button { margin-left:auto; }
|
||||||
|
.analysis-column-popover .analysis-filter-search { margin:8px 10px 0; width:auto; }
|
||||||
|
.analysis-column-popover .analysis-filter-search input { width:100%; font-size:12px; }
|
||||||
|
.analysis-filter-selection { display:flex; align-items:center; gap:12px; padding:8px 12px 2px; font-size:11px; }
|
||||||
|
.analysis-filter-selection button { color:#2563eb; padding:3px 0; }
|
||||||
|
.analysis-filter-selection>span { margin-left:auto; color:#64748b; }
|
||||||
|
.analysis-column-popover .analysis-filter-options { grid-template-columns:1fr; max-height:180px; padding:4px 10px 8px; }
|
||||||
|
.analysis-column-popover .analysis-filter-options label { padding:7px 3px; }
|
||||||
|
.analysis-column-popover .analysis-filter-editor-bottom { gap:6px; padding:8px 10px; }
|
||||||
|
.analysis-column-popover .analysis-filter-editor-bottom>span { width:auto; margin-right:auto; font-size:10px; }
|
||||||
|
@media(max-width:640px) { .analysis-column-popover .analysis-filter-options { max-height:160px; }.analysis-inline-filter { max-width:72px; } }
|
||||||
|
.analysis-table-options { display:flex; flex-wrap:wrap; align-items:center; gap:14px; }
|
||||||
|
.analysis-parent-total { display:block; margin-top:7px; color:#64748b; font-size:11px; font-weight:400; line-height:1.6; white-space:nowrap; }
|
||||||
|
.analysis-parent-total strong { color:var(--analysis-accent); font-size:13px; font-weight:600; font-variant-numeric:tabular-nums; }
|
||||||
|
.analysis-parent-children { display:block; margin-top:3px; color:#64748b; font-size:11px; font-weight:400; line-height:1.6; white-space:nowrap; }
|
||||||
|
.analysis-parent-children strong { font-weight:600; color:#475569; font-variant-numeric:tabular-nums; }
|
||||||
|
.analysis-merge-toggle { cursor:pointer; white-space:nowrap; }
|
||||||
|
.analysis-merge-toggle input { accent-color:#2563eb; width:13px; height:13px; }
|
||||||
|
.analysis-group-table.is-merged td { vertical-align:top; }
|
||||||
|
.analysis-group-table.is-merged td[rowspan]:not([rowspan="1"]) { background:#f8fafc; border-right:1px solid var(--analysis-boundary); border-bottom:1px solid var(--analysis-boundary); font-weight:500; }
|
||||||
|
@media(max-width:640px) {
|
||||||
|
.analysis-group-table.is-merged { overflow:auto; border:1px solid var(--analysis-boundary); border-radius:6px; }
|
||||||
|
.analysis-group-table.is-merged table { display:table; min-width:560px; }
|
||||||
|
.analysis-group-table.is-merged thead { display:table-header-group; }
|
||||||
|
.analysis-group-table.is-merged tbody { display:table-row-group; }
|
||||||
|
.analysis-group-table.is-merged tr { display:table-row; padding:0; border:0; }
|
||||||
|
.analysis-group-table.is-merged td { display:table-cell; padding:10px 12px; border-top:1px solid var(--analysis-line); min-width:80px!important; }
|
||||||
|
.analysis-group-table.is-merged td[data-label]::before { display:none; }
|
||||||
|
.analysis-group-table.is-merged .analysis-numeric,.analysis-group-table.is-merged .analysis-share-cell { text-align:right; }
|
||||||
|
.analysis-group-table.is-merged .analysis-group-actions { display:table-cell; min-width:170px!important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep a one-pixel grid; darker boundaries separate parent groups. */
|
||||||
|
.analysis-group-table tbody tr:first-child > td,
|
||||||
|
.analysis-group-table.is-merged .analysis-group-start > td { border-top-color:var(--analysis-boundary); }
|
||||||
|
|
||||||
|
.analysis-export-action { display:inline-flex; flex-direction:column; gap:3px; flex-shrink:0; }
|
||||||
|
.analysis-export-action button:disabled { opacity:.45; cursor:not-allowed; }
|
||||||
|
.analysis-export-action small { color:#b91c1c; font-size:11px; }
|
||||||
|
.analysis-export-actions { display:flex; flex-wrap:wrap; align-items:center; gap:8px; }
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
.adp-row { display:flex; flex-wrap:wrap; align-items:flex-start; gap:6px; }
|
||||||
|
.analysis-dimension-picker { display:inline-flex; flex-direction:column; align-items:stretch; min-width:0; max-width:190px; vertical-align:top; font-size:12px; }
|
||||||
|
.analysis-dimension-picker .adp-split { display:flex; min-height:30px; border:1px solid #e2e8f0; border-radius:5px; background:#f8fafc; overflow:hidden; }
|
||||||
|
.analysis-dimension-picker .adp-split button { display:inline-flex; align-items:center; justify-content:center; gap:4px; min-height:28px; padding:5px 8px; border:0; border-radius:0; background:transparent; color:#475569; font-size:12px; line-height:1.3; }
|
||||||
|
.analysis-dimension-picker .adp-split .adp-arrow { border-left:1px solid #e2e8f0; padding:5px 6px; font-size:10px; }
|
||||||
|
.analysis-dimension-picker.adp-grouped .adp-split { background:#eff6ff; border-color:#93c5fd; }
|
||||||
|
.analysis-dimension-picker.adp-grouped .adp-name { color:#1d4ed8; }
|
||||||
|
.analysis-dimension-picker.adp-filtered .adp-arrow { color:#1d4ed8; background:#dbeafe; }
|
||||||
|
.analysis-dimension-picker .adp-split button:hover { background:#e2e8f0; }
|
||||||
|
.analysis-dimension-picker .adp-order { display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px; background:#2563eb; border-radius:3px; font-size:10px; color:white; }
|
||||||
|
.analysis-dimension-picker .adp-selected { text-align:left; padding:3px 2px 0; border:0; background:none; color:#2563eb; font-size:10px; max-width:190px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.analysis-dimension-picker .adp-menu { position:fixed; z-index:1100; display:flex; flex-direction:column; width:min(320px,calc(100vw - 16px)); border:1px solid #cbd5e1; border-radius:7px; background:#fff; color:#334155; box-shadow:0 8px 30px rgb(15 23 42 / .17); overflow:hidden; font-size:12px; }
|
||||||
|
.analysis-dimension-picker .adp-menu button { padding:5px 8px; border:0; border-radius:4px; background:transparent; color:#475569; font-size:11px; min-height:26px; line-height:1.4; }
|
||||||
|
.analysis-dimension-picker .adp-menu button:hover { background:#f1f5f9; }
|
||||||
|
.analysis-dimension-picker .adp-menu-heading { display:flex; align-items:center; justify-content:space-between; padding:7px 10px; border-bottom:1px solid #e2e8f0; flex-shrink:0; }
|
||||||
|
.analysis-dimension-picker .adp-menu-heading strong { font-size:12px; font-weight:600; }
|
||||||
|
.analysis-dimension-picker .adp-menu-heading button { padding:4px; min-height:22px; }
|
||||||
|
.analysis-dimension-picker .adp-search { display:flex; align-items:center; gap:6px; margin:9px 10px 5px; padding:6px 8px; border:1px solid #e2e8f0; border-radius:5px; flex-shrink:0; color:#64748b; }
|
||||||
|
.analysis-dimension-picker .adp-search input { width:100%; min-width:0; border:0; outline:none; background:white; color:#334155; font-size:12px; }
|
||||||
|
.analysis-dimension-picker .adp-select-tools { display:flex; align-items:center; gap:2px; padding:0 6px 4px; flex-shrink:0; }
|
||||||
|
.analysis-dimension-picker .adp-select-tools button { color:#2563eb; font-size:10px; padding:4px; }
|
||||||
|
.analysis-dimension-picker .adp-select-tools>span { margin-left:auto; padding-right:4px; font-size:10px; color:#64748b; }
|
||||||
|
.analysis-dimension-picker .adp-options { min-height:0; flex:1; overflow-y:auto; overscroll-behavior:contain; padding:0 8px 6px; }
|
||||||
|
.analysis-dimension-picker .adp-options label { display:flex; align-items:center; gap:7px; min-height:30px; padding:6px 4px; border-radius:4px; cursor:pointer; font-size:12px; }
|
||||||
|
.analysis-dimension-picker .adp-options label:hover { background:#f8fafc; }
|
||||||
|
.analysis-dimension-picker .adp-options input { accent-color:#2563eb; flex-shrink:0; width:13px; height:13px; }
|
||||||
|
.analysis-dimension-picker .adp-options label>span { overflow-wrap:anywhere; }
|
||||||
|
.analysis-dimension-picker .adp-options small { margin-left:auto; padding-left:5px; color:#64748b; font-size:10px; font-variant-numeric:tabular-nums; }
|
||||||
|
.analysis-dimension-picker .adp-options p { padding:16px; text-align:center; color:#64748b; font-size:11px; }
|
||||||
|
.analysis-dimension-picker .adp-menu-footer { border-top:1px solid #e2e8f0; padding:8px 10px; flex-shrink:0; }
|
||||||
|
.analysis-dimension-picker .adp-menu-footer>span { display:block; font-size:10px; color:#64748b; margin-bottom:7px; }
|
||||||
|
.analysis-dimension-picker .adp-menu-footer>div { display:flex; justify-content:flex-end; gap:6px; }
|
||||||
|
.analysis-dimension-picker .adp-menu-footer .adp-apply { background:#2563eb; color:white; }
|
||||||
|
.analysis-dimension-picker .adp-menu-footer .adp-apply:hover { background:#1d4ed8; }
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/* Shared mobile presentation for asset statistics, filters and vehicle details. */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.asset-analysis-overlay, .bi-flow-modal-backdrop { padding: 0; align-items: stretch; }
|
||||||
|
.asset-analysis-dialog, .bi-flow-modal { width: 100%; max-width: none; height: 100dvh; max-height: 100dvh; min-height: 0; border-radius: 0; font-size: 13px; }
|
||||||
|
.analysis-header, .bi-flow-modal-header { padding: max(6px, env(safe-area-inset-top)) 10px 6px; background: #fff; color: var(--color-slate-800, #1e293b); border-bottom: 1px solid var(--color-slate-200, #e2e8f0); }
|
||||||
|
.analysis-header { gap: 8px; }
|
||||||
|
.analysis-header > div { flex: 1; min-width: 0; }
|
||||||
|
.analysis-header h2, .bi-flow-modal-header h3 { font-size: 15px; }
|
||||||
|
.analysis-header h2 > svg { display: none; }
|
||||||
|
.analysis-header h2, .bi-flow-modal-header h3 { border-left: 3px solid var(--color-blue-600, #2563eb); padding-left: 8px; }
|
||||||
|
.analysis-header p, .bi-flow-modal-header p { font-size: 11px; line-height: 1.5; color: var(--color-slate-500, #64748b); }
|
||||||
|
.analysis-header button, .bi-flow-modal-close, .bi-flow-mobile-back { display: grid; place-items: center; flex-shrink: 0; width: 44px; height: 44px; padding: 0; color: var(--color-slate-500, #64748b); background: var(--color-slate-50, #f8fafc); border-radius: 8px; }
|
||||||
|
.analysis-header button:hover, .bi-flow-modal-close:hover { background: var(--color-blue-50, #eff6ff); }
|
||||||
|
.analysis-scroll, .bi-flow-modal-body { padding: 12px 16px max(16px, env(safe-area-inset-bottom)); background: var(--app-bg, #f4f7fb); }
|
||||||
|
.analysis-result-bar { padding: 10px 14px; gap: 8px; font-size: 12px; background: var(--color-blue-50, #eff6ff); border: 1px solid var(--color-blue-100, #dbeafe); border-radius: 10px; color: var(--color-slate-600, #475569); margin-bottom: 10px; }
|
||||||
|
.analysis-result-bar strong { font-size: 28px; margin: 0 4px; color: var(--color-blue-600, #2563eb); }
|
||||||
|
.analysis-result-dimensions { display: none; }
|
||||||
|
.analysis-inventory-subsets { gap: 5px 12px; font-size: 11px; margin: -11px 0 8px; padding: 8px 14px; background: var(--color-blue-50, #eff6ff); border: 1px solid var(--color-blue-100, #dbeafe); border-top: 0; border-radius: 0 0 10px 10px; }
|
||||||
|
.analysis-inventory-subsets small { width: 100%; font-size: 11px; }
|
||||||
|
.analysis-mobile-toolbar { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; }
|
||||||
|
.analysis-mobile-toolbar > button { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border: 1px solid #d8e0eb; border-radius: 9px; padding: 8px 12px; background: #f8fafc; color: #334155; font-size: 13px; }
|
||||||
|
.analysis-mobile-toolbar > button:first-child { justify-content: space-between; }
|
||||||
|
.analysis-mobile-toolbar > button > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.analysis-mobile-toolbar svg { flex-shrink: 0; }
|
||||||
|
.analysis-mobile-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||||
|
.analysis-mobile-chips button { display: flex; align-items: center; gap: 6px; max-width: 100%; min-height: 44px; padding: 6px 10px; border-radius: 8px; color: var(--color-blue-700, #1d4ed8); background: #eff6ff; font-size: 11px; }
|
||||||
|
.analysis-mobile-chips span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.analysis-mobile-chips svg { flex-shrink: 0; }
|
||||||
|
.analysis-overview-tools > p, .analysis-overview-tools .analysis-merge-toggle { display: none; }
|
||||||
|
.analysis-overview-tools.is-empty { display: none; }
|
||||||
|
.analysis-overview-tools .analysis-table-options { width: 100%; justify-content: space-between; gap: 6px; }
|
||||||
|
.analysis-table-tools { margin: 8px 0; }
|
||||||
|
.analysis-table-tools label, .analysis-table-tools select, .analysis-export-action button { font-size: 11px; }
|
||||||
|
.analysis-table-tools select, .analysis-export-action button { min-height: 44px; }
|
||||||
|
.analysis-table-tools h3 { font-size: 14px; }
|
||||||
|
.analysis-table-tools h3 small { font-size: 12px; }
|
||||||
|
.analysis-table-total { border: 0; border-radius: 0; background: none; padding: 8px 0; font-size: 11px; line-height: 1.6; }
|
||||||
|
.analysis-table-total > span:last-child { display: none; }
|
||||||
|
.analysis-mobile-detail-scope { font-size: 12px; color: #64748b; margin-bottom: 8px; }
|
||||||
|
.analysis-search { width: 100%; min-height: 44px; padding: 9px 12px; border-radius: 8px; }
|
||||||
|
.analysis-search input { font-size: 16px; width: 100%; }
|
||||||
|
.analysis-vehicle-cards { display: grid; gap: 8px; margin-top: 10px; }
|
||||||
|
.analysis-vehicle-card { min-width: 0; padding: 12px; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; }
|
||||||
|
.analysis-vehicle-card header { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 6px 12px; justify-content: space-between; }
|
||||||
|
.analysis-vehicle-card h4 { color: var(--color-blue-700, #1d4ed8); font-size: 14px; font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; }
|
||||||
|
.analysis-vehicle-card header > span { font-size: 11px; padding: 3px 6px; border-radius: 4px; color: #475569; background: #f1f5f9; }
|
||||||
|
.analysis-vehicle-card > p { font-size: 12px; color: #64748b; line-height: 1.6; margin-top: 6px; overflow-wrap: anywhere; }
|
||||||
|
.analysis-vehicle-card summary { display: flex; align-items: center; min-height: 44px; font-size: 12px; color: var(--color-blue-600, #2563eb); cursor: pointer; }
|
||||||
|
.analysis-vehicle-card summary::after { content: '+'; margin-left: auto; }
|
||||||
|
.analysis-vehicle-card details[open] summary::after { content: '−'; }
|
||||||
|
.analysis-vehicle-card dl > div { display: grid; grid-template-columns: 90px minmax(0,1fr); gap: 10px; padding: 4px 0; font-size: 12px; line-height: 1.6; }
|
||||||
|
.analysis-vehicle-card dt { color: #64748b; }
|
||||||
|
.analysis-vehicle-card dd { color: #334155; overflow-wrap: anywhere; }
|
||||||
|
.analysis-footer { padding: 0 16px max(0px, env(safe-area-inset-bottom)); background: #fff; font-size: 11px; }
|
||||||
|
.analysis-footer details { width: 100%; max-height: 28dvh; overflow-y: auto; }
|
||||||
|
.analysis-footer summary { display: flex; align-items: center; min-height: 44px; cursor: pointer; }
|
||||||
|
.analysis-footer details p { margin-bottom: 10px; line-height: 1.6; }
|
||||||
|
.analysis-message { font-size: 13px; min-height: 240px; }
|
||||||
|
.analysis-message h3 { font-size: 15px; }
|
||||||
|
.analysis-definition { font-size: 11px; }
|
||||||
|
.analysis-details .text-base { font-size: 14px; }
|
||||||
|
.analysis-details .text-sm { font-size: 13px; }
|
||||||
|
.analysis-details .text-xs { font-size: 11px; }
|
||||||
|
.analysis-details .space-y-3.px-3 { padding: 0; }
|
||||||
|
.analysis-details .space-y-3 article { box-shadow: none; }
|
||||||
|
.analysis-settings-backdrop { position: fixed; inset: 0; z-index: 30; background: rgb(15 23 42 / 38%); display: flex; align-items: flex-end; }
|
||||||
|
.analysis-settings-panel { display: flex; flex-direction: column; width: 100%; height: min(680px, 90dvh); min-height: 0; background: #fff; border-radius: 12px 12px 0 0; color: #334155; box-shadow: 0 -12px 36px #0f172a20; outline: none; }
|
||||||
|
.analysis-settings-panel header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid #e2e8f0; }
|
||||||
|
.analysis-settings-panel header h3 { flex: 1; font-size: 15px; font-weight: 600; }
|
||||||
|
.analysis-settings-panel header button { width: 44px; height: 44px; display: grid; place-items: center; }
|
||||||
|
.analysis-settings-search { display: flex; align-items: center; gap: 8px; margin: 12px 16px 0; padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 8px; }
|
||||||
|
.analysis-settings-search input { min-width: 0; width: 100%; font-size: 16px; outline: none; }
|
||||||
|
.analysis-settings-content { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 12px 16px; }
|
||||||
|
.analysis-settings-content > p { color: #64748b; font-size: 12px; line-height: 1.6; margin: 0 0 12px; }
|
||||||
|
.analysis-settings-groups { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; margin-bottom: 18px; }
|
||||||
|
.analysis-settings-groups button { min-height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border: 1px solid #d8e0eb; border-radius: 8px; font-size: 13px; }
|
||||||
|
.analysis-settings-groups button[aria-pressed=true] { color: var(--color-blue-700, #1d4ed8); background: #eff6ff; border-color: #93c5fd; }
|
||||||
|
.analysis-settings-groups .analysis-dimension-order { width: 20px; height: 20px; font-size: 11px; }
|
||||||
|
.analysis-settings-row { display: flex; gap: 10px; width: 100%; align-items: center; min-height: 48px; padding: 8px 0; border-bottom: 1px solid #e2e8f0; text-align: left; font-size: 13px; }
|
||||||
|
.analysis-settings-row > span { flex: 1; }
|
||||||
|
.analysis-settings-row > small { font-size: 12px; color: #64748b; }
|
||||||
|
.analysis-settings-select { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; font-size: 12px; }
|
||||||
|
.analysis-settings-select button { min-height: 44px; color: var(--color-blue-600, #2563eb); }
|
||||||
|
.analysis-settings-select > span { margin-left: auto; color: #64748b; }
|
||||||
|
.analysis-settings-option { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 8px 0; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
|
||||||
|
.analysis-settings-option input { width: 18px; height: 18px; accent-color: var(--color-blue-600, #2563eb); flex-shrink: 0; }
|
||||||
|
.analysis-settings-option span { flex: 1; overflow-wrap: anywhere; }
|
||||||
|
.analysis-settings-option small { color: #64748b; font-size: 12px; }
|
||||||
|
.analysis-settings-panel footer { display: grid; grid-template-columns: 1fr 2fr; gap: 12px; padding: 12px 16px max(12px, env(safe-area-inset-bottom)); border-top: 1px solid #e2e8f0; }
|
||||||
|
.analysis-settings-panel footer button { min-height: 46px; border-radius: 9px; background: #f1f5f9; font-size: 13px; }
|
||||||
|
.analysis-settings-panel footer .analysis-settings-apply { background: var(--color-blue-600, #2563eb); color: #fff; }
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.bi-flow-modal-header-row { align-items: center; gap: 8px; }
|
||||||
|
.bi-flow-modal-header-row > div { flex: 1; min-width: 0; }
|
||||||
|
.bi-flow-modal-summary { margin: 0 0 12px; background: var(--color-blue-50, #eff6ff); border-color: var(--color-blue-100, #dbeafe); }
|
||||||
|
.bi-flow-modal-summary > div { padding: 10px 8px; gap: 6px; }
|
||||||
|
.bi-flow-modal-summary span, .bi-flow-modal strong small { font-size: 11px; }
|
||||||
|
.bi-flow-modal-summary strong { font-size: 23px; }
|
||||||
|
.bi-flow-modal-section-heading { margin: 12px 0 4px; }
|
||||||
|
.bi-flow-modal-section-heading h4 { font-size: 14px; }
|
||||||
|
.bi-flow-modal-section-heading p { font-size: 11px; }
|
||||||
|
.bi-flow-modal-empty { font-size: 13px; line-height: 1.7; color: #64748b; }
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.analysis-details .space-y-3 dt { font-size: 11px; line-height: 1.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.analysis-mobile-controls { position: sticky; top: -12px; z-index: 5; background: var(--app-bg, #f4f7fb); padding: 0 0 6px; }
|
||||||
|
.analysis-mobile-dimension-heading { display: flex; align-items: center; gap: 8px; color: var(--color-slate-700, #334155); font-size: 12px; }
|
||||||
|
.analysis-mobile-dimension-heading > span { color: var(--color-slate-500, #64748b); font-size: 11px; }
|
||||||
|
.analysis-mobile-dimension-heading > button { margin-left: auto; min-height: 44px; color: var(--color-blue-600, #2563eb); font-size: 11px; }
|
||||||
|
.analysis-mobile-toolbar { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
|
||||||
|
.analysis-mobile-toolbar > button { align-self: start; height: 44px; padding: 0 8px; gap: 4px; white-space: nowrap; color: var(--color-blue-600, #2563eb); border-color: var(--color-blue-100, #dbeafe); background: var(--color-blue-50, #eff6ff); }
|
||||||
|
.analysis-dimension-strip { display: flex; gap: 6px; min-width: 0; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; scrollbar-color: var(--color-slate-300, #cbd5e1) transparent; padding-bottom: 4px; }
|
||||||
|
.analysis-dimension-strip button { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 44px; padding: 0 11px; border: 1px solid var(--color-slate-200, #e2e8f0); border-radius: 8px; background: #fff; color: var(--color-slate-600, #475569); font-size: 12px; }
|
||||||
|
.analysis-dimension-strip button[aria-pressed=true] { background: var(--color-blue-600, #2563eb); border-color: var(--color-blue-600, #2563eb); color: #fff; box-shadow: 0 2px 4px rgb(37 99 235 / 12%); }
|
||||||
|
.analysis-dimension-strip small { display: grid; place-items: center; width: 17px; height: 17px; border-radius: 50%; background: #ffffff2b; font-size: 10px; }
|
||||||
|
.analysis-mobile-grid { border: 1px solid var(--color-slate-200, #e2e8f0); border-radius: 10px; overflow: hidden; background: #fff; box-shadow: 0 2px 6px rgb(15 23 42 / 3%); }
|
||||||
|
.analysis-mobile-grid-heading { min-height: 44px; display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 0 12px; background: var(--color-blue-50, #eff6ff); color: var(--color-slate-700, #334155); font-size: 12px; }
|
||||||
|
.analysis-mobile-grid-heading > span { font-size: 11px; color: var(--color-slate-500, #64748b); }
|
||||||
|
.analysis-mobile-grid-heading label { display: inline-flex; align-items: center; gap: 7px; min-height: 44px; font-size: 11px; cursor: pointer; }
|
||||||
|
.analysis-mobile-grid-heading input { appearance: none; width: 30px; height: 18px; border-radius: 10px; background: var(--color-slate-400, #94a3b8); position: relative; cursor: pointer; }
|
||||||
|
.analysis-mobile-grid-heading input::after { content: ''; position: absolute; top: 3px; left: 3px; width: 12px; height: 12px; border-radius: 50%; background: #fff; }
|
||||||
|
.analysis-mobile-grid-heading input:checked { background: var(--color-blue-600, #2563eb); }
|
||||||
|
.analysis-mobile-grid-heading input:checked::after { left: 15px; }
|
||||||
|
.analysis-mobile-grid-hint { padding: 6px 12px; font-size: 11px; color: var(--color-slate-500, #64748b); border-bottom: 1px solid var(--color-slate-200, #e2e8f0); background: var(--color-slate-50, #f8fafc); }
|
||||||
|
.analysis-mobile-grid-scroll { overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; }
|
||||||
|
.analysis-mobile-grid table { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; font-size: 12px; }
|
||||||
|
.analysis-mobile-grid th { background: var(--color-slate-50, #f8fafc); color: var(--color-slate-500, #64748b); font-weight: 500; padding: 7px 6px; text-align: left; border-bottom: 1px solid var(--color-slate-200, #e2e8f0); }
|
||||||
|
.analysis-mobile-grid th:first-child { padding-left: 12px; }
|
||||||
|
.analysis-mobile-grid td { padding: 4px 6px; border-bottom: 1px solid var(--color-slate-100, #f1f5f9); vertical-align: middle; overflow-wrap: anywhere; color: var(--color-slate-700, #334155); line-height: 1.5; }
|
||||||
|
.analysis-mobile-grid tr:nth-child(even) td { background: var(--color-slate-50, #f8fafc); }
|
||||||
|
.analysis-mobile-grid tr:last-child td { border-bottom: 0; }
|
||||||
|
.analysis-mobile-grid td:first-child { padding-left: 12px; }
|
||||||
|
.analysis-mobile-grid .mobile-grid-parent { background: var(--color-blue-50, #eff6ff); border-right: 1px solid var(--color-blue-100, #dbeafe); font-weight: 600; vertical-align: top; padding-top: 14px; }
|
||||||
|
.analysis-mobile-grid .mobile-grid-parent small { display: block; margin-top: 6px; font-size: 11px; font-weight: 400; color: var(--color-slate-500, #64748b); }
|
||||||
|
.analysis-mobile-grid .mobile-grid-group-start:not(:first-child) td { border-top: 1px solid var(--color-slate-300, #cbd5e1); }
|
||||||
|
.analysis-mobile-grid .mobile-grid-number { width: 60px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.analysis-mobile-grid th small { display: block; font-size: 10px; }
|
||||||
|
.analysis-mobile-grid td.mobile-grid-number strong { font-size: 17px; color: var(--color-blue-600, #2563eb); font-weight: 600; }
|
||||||
|
.analysis-mobile-grid td.mobile-grid-number small { display: block; font-size: 10px; color: var(--color-amber-600, #d97706); }
|
||||||
|
.analysis-mobile-grid .mobile-grid-share { width: 58px; text-align: right; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||||
|
.mobile-grid-share > i { display: block; margin-top: 5px; height: 3px; border-radius: 3px; overflow: hidden; background: var(--color-blue-100, #dbeafe); }
|
||||||
|
.mobile-grid-share > i > i { display: block; height: 100%; background: var(--color-blue-500, #3b82f6); }
|
||||||
|
.analysis-mobile-grid .mobile-grid-actions { width: 90px; text-align: center; padding-left: 0; padding-right: 4px; }
|
||||||
|
.analysis-mobile-grid.without-filter .mobile-grid-actions { width: 56px; }
|
||||||
|
.mobile-grid-actions > div { display: flex; justify-content: flex-end; }
|
||||||
|
.mobile-grid-actions button { display: grid; place-items: center; min-width: 44px; height: 44px; color: var(--color-blue-600, #2563eb); font-size: 11px; border-radius: 7px; }
|
||||||
|
.mobile-grid-actions button:active { background: #dbeafe; }
|
||||||
|
.mobile-grid-actions .mobile-grid-filter { color: var(--color-slate-400, #94a3b8); }
|
||||||
|
.analysis-table-tools { margin-top: 0; }
|
||||||
|
.analysis-mobile-detail-scope { padding: 10px 12px; border-left: 3px solid var(--color-blue-600, #2563eb); border-radius: 0 6px 6px 0; background: var(--color-blue-50, #eff6ff); color: var(--color-slate-600, #475569); }
|
||||||
|
.analysis-vehicle-card { border-color: var(--color-slate-200, #e2e8f0); border-top: 3px solid var(--color-blue-100, #dbeafe); }
|
||||||
|
.bi-flow-modal-summary strong { color: var(--color-blue-600, #2563eb); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.analysis-progressive-status { display:flex; align-items:center; justify-content:center; flex-wrap:wrap; gap:8px 14px; min-height:44px; padding:10px 12px; color:#64748b; font-size:11px; font-variant-numeric:tabular-nums; }
|
||||||
|
.analysis-progressive-hint { color:#94a3b8; }
|
||||||
|
.analysis-progressive-status button { padding:5px 10px; border:1px solid #e2e8f0; border-radius:5px; background:#fff; color:#2563eb; cursor:pointer; font:inherit; }
|
||||||
|
.analysis-progressive-status button:hover { background:#eff6ff; }
|
||||||
|
.analysis-progressive-status button:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState, type RefObject } from 'react';
|
||||||
|
|
||||||
|
interface ProgressiveRowsOptions {
|
||||||
|
total: number;
|
||||||
|
rootRef: RefObject<HTMLElement | null>;
|
||||||
|
resetKey: unknown;
|
||||||
|
enabled?: boolean;
|
||||||
|
batchSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a cumulative prefix, loading another batch near the actual scroll body's bottom. */
|
||||||
|
export function useProgressiveRows({ total, rootRef, resetKey, enabled = true, batchSize = 40 }: ProgressiveRowsOptions) {
|
||||||
|
const context = useMemo(() => ({ resetKey, enabled, batchSize }), [resetKey, enabled, batchSize]);
|
||||||
|
const [progress, setProgress] = useState(() => ({ context, limit: batchSize }));
|
||||||
|
const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);
|
||||||
|
const visibleCount = Math.min(total, progress.context === context ? progress.limit : batchSize);
|
||||||
|
const hasMore = visibleCount < total;
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
setProgress(previous => ({ context, limit: Math.min(total, (previous.context === context ? previous.limit : batchSize) + batchSize) }));
|
||||||
|
}, [enabled, context, total, batchSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled && rootRef.current) rootRef.current.scrollTop = 0;
|
||||||
|
}, [context, enabled, rootRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = rootRef.current;
|
||||||
|
if (!enabled || !hasMore || !root || !sentinel || typeof IntersectionObserver === 'undefined') return;
|
||||||
|
let active = true;
|
||||||
|
let queued = false;
|
||||||
|
let frame: number | undefined;
|
||||||
|
const observer = new IntersectionObserver(entries => {
|
||||||
|
if (!active || queued || !entries.some(entry => entry.isIntersecting)) return;
|
||||||
|
queued = true;
|
||||||
|
// One append per observer: let layout settle before deciding whether more rows fit.
|
||||||
|
frame = requestAnimationFrame(loadMore);
|
||||||
|
}, { root, rootMargin: '0px 0px 240px 0px', threshold: 0 });
|
||||||
|
observer.observe(sentinel);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
observer.disconnect();
|
||||||
|
if (frame !== undefined) cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, [enabled, hasMore, rootRef, sentinel, visibleCount, loadMore]);
|
||||||
|
|
||||||
|
return { visibleCount, total, hasMore, loadMore, sentinelRef: setSentinel };
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
.bi-flow-modal-backdrop { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px; background: rgb(2 6 23 / 45%); backdrop-filter: blur(4px); }
|
||||||
|
.bi-flow-modal-parent-total { display:block; margin-top:7px; color:#64748b; font-size:11px; font-weight:400; line-height:1.6; white-space:nowrap; }
|
||||||
|
.bi-flow-modal-parent-total strong { color:#2563eb; font-size:13px; font-weight:600; font-variant-numeric:tabular-nums; }
|
||||||
|
.bi-flow-modal-parent-children { display:block; margin-top:3px; color:#64748b; font-size:11px; font-weight:400; line-height:1.6; white-space:nowrap; }
|
||||||
|
.bi-flow-modal-parent-children strong { font-weight:600; color:#475569; font-variant-numeric:tabular-nums; }
|
||||||
|
.bi-flow-modal { --flow-line:#d5deea; --flow-boundary:#b8c6d8; display: flex; flex-direction: column; width: 100%; max-width: 960px; max-height: 88dvh; min-width: 0; overflow: hidden; background: white; border-radius: 12px; color: #334155; font-size: 12px; box-shadow: 0 24px 64px rgb(15 23 42 / 20%); }
|
||||||
|
.bi-flow-modal button, .bi-flow-modal select { font: inherit; cursor: pointer; }
|
||||||
|
.bi-flow-modal button:disabled { cursor: not-allowed; opacity: .4; }
|
||||||
|
.bi-flow-modal button:focus-visible, .bi-flow-modal select:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; }
|
||||||
|
.bi-flow-modal-header { flex-shrink: 0; padding: 14px 18px; background: #1e293b; color: white; }
|
||||||
|
.bi-flow-modal-header-row { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; }
|
||||||
|
.bi-flow-modal-header h3 { margin: 0 0 5px; font-size: 14px; line-height: 1.5; font-weight: 700; }
|
||||||
|
.bi-flow-modal-header p { margin: 0; font-size: 11px; color: #cbd5e1; }
|
||||||
|
.bi-flow-modal-close { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; flex-shrink: 0; border: 0; border-radius: 50%; background: rgb(255 255 255 / 10%); color: white; }
|
||||||
|
.bi-flow-modal-close:hover { background: rgb(255 255 255 / 20%); }
|
||||||
|
.bi-flow-modal-header .bi-flow-modal-definition { margin-top: 8px; color: #94a3b8; font-size: 10px; }
|
||||||
|
.bi-flow-modal-body { overflow-y: auto; overscroll-behavior: contain; min-height:0; flex:1; padding: 16px 18px; }
|
||||||
|
.bi-flow-modal-controls { padding-bottom: 14px; border-bottom: 1px solid #e2e8f0; }
|
||||||
|
.bi-flow-modal-control-row { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.bi-flow-modal-control-label { flex: 0 0 50px; color: #64748b; font-size: 11px; }
|
||||||
|
.bi-flow-modal-chip { display: inline-flex; align-items: center; gap: 4px; min-height: 28px; padding: 4px 9px; border: 1px solid #e2e8f0; border-radius: 6px; background: white; color: #64748b; font-size: 12px; }
|
||||||
|
.bi-flow-modal-chip:hover { border-color: #93c5fd; }
|
||||||
|
.bi-flow-modal-chip-active { border-color: #bfdbfe; background: #eff6ff; color: #2563eb; }
|
||||||
|
.bi-flow-modal-control-hint { margin: 7px 0 12px 62px; font-size: 10px; color: #94a3b8; line-height: 1.6; }
|
||||||
|
.bi-flow-modal-clear { color: #64748b; background: none; border: 0; font-size: 10px !important; }
|
||||||
|
.bi-flow-modal-detail-nav { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.bi-flow-modal-detail-nav > span { font-size: 11px; color: #64748b; overflow-wrap: anywhere; }
|
||||||
|
.bi-flow-modal-back { display: inline-flex; align-items: center; gap: 5px; border: 0; background: #f1f5f9; color: #475569; padding: 6px 8px; border-radius: 6px; font-size: 11px !important; }
|
||||||
|
.bi-flow-modal-back:hover { background: #e2e8f0; }
|
||||||
|
.bi-flow-modal-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; margin: 14px 0 16px; }
|
||||||
|
.bi-flow-modal-summary > div { padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||||
|
.bi-flow-modal-summary > div + div { border-left: 1px solid #e2e8f0; }
|
||||||
|
.bi-flow-modal-summary span { font-size: 11px; color: #64748b; }
|
||||||
|
.bi-flow-modal-summary strong { color: #2563eb; font-size: 22px; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
|
||||||
|
.bi-flow-modal strong small { margin-left: 4px; font-size: 10px; font-weight: 400; color: #64748b; }
|
||||||
|
.bi-flow-modal-purple { color: #7c3aed !important; }
|
||||||
|
.bi-flow-modal-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||||
|
.bi-flow-modal-section-heading h4 { margin: 0; font-size: 12px; font-weight: 600; color: #0f172a; }
|
||||||
|
.bi-flow-modal-section-heading p { margin: 4px 0 0; font-size: 10px; line-height: 1.6; color: #64748b; }
|
||||||
|
.bi-flow-modal-export { display: inline-flex; gap: 5px; align-items: center; background: #eff6ff; border: 1px solid #dbeafe; color: #2563eb; border-radius: 6px; padding: 6px 8px; font-size: 11px !important; }
|
||||||
|
.bi-flow-modal-export:hover { background: #dbeafe; }
|
||||||
|
.bi-flow-modal-table-wrap { max-width: 100%; overflow-x: auto; border: 1px solid var(--flow-boundary); border-radius: 8px; }
|
||||||
|
.bi-flow-modal-table { width: 100%; border-collapse: collapse; text-align: left; font-size: 12px; }
|
||||||
|
.bi-flow-modal-table th { padding: 10px 12px; background: #f8fafc; color: #64748b; font-weight: 500; white-space: nowrap; }
|
||||||
|
.bi-flow-modal-table td { height: 40px; padding: 7px 12px; border-top: 1px solid var(--flow-line); line-height: 1.5; max-width: 200px; overflow-wrap: anywhere; }
|
||||||
|
.bi-flow-modal-table tr:hover td { background: #f8fafc; }
|
||||||
|
.bi-flow-modal-number { text-align: right; white-space: nowrap; width: 65px; }
|
||||||
|
.bi-flow-modal-number strong { font-size: 14px; font-weight: 600; color: #0f172a; font-variant-numeric: tabular-nums; }
|
||||||
|
.bi-flow-modal-share { width: 126px; white-space: nowrap; }
|
||||||
|
.bi-flow-modal-share > div { display: flex; align-items: center; justify-content: flex-end; gap: 7px; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||||
|
.bi-flow-modal-share > div > span:last-child { min-width: 42px; text-align: right; }
|
||||||
|
.bi-flow-modal-track { display: block; width: 48px; flex-shrink: 0; height: 5px; background: #f1f5f9; overflow: hidden; border-radius: 3px; }
|
||||||
|
.bi-flow-modal-fill { display: block; height: 100%; border-radius: 3px; }
|
||||||
|
.bi-flow-modal-fill-delivered { background: #2563eb; }
|
||||||
|
.bi-flow-modal-fill-returned { background: #ea580c; }
|
||||||
|
.bi-flow-modal-fill-replaced { background: #7c3aed; }
|
||||||
|
.bi-flow-modal-action { width: 78px; white-space: nowrap; text-align: right; }
|
||||||
|
.bi-flow-modal-action button { padding: 3px 0; color: #2563eb; font-size: 11px; background: none; border: 0; }
|
||||||
|
.bi-flow-modal-action button:hover { text-decoration: underline; }
|
||||||
|
.bi-flow-modal-detail-table { min-width: 1120px; }
|
||||||
|
.bi-flow-modal-detail-table td:nth-child(8), .bi-flow-modal-detail-table td:nth-child(9) { min-width: 160px; overflow-wrap: anywhere; }
|
||||||
|
.bi-flow-modal-detail-table td { font-size: 12px; min-width: 80px; }
|
||||||
|
.bi-flow-modal-detail-table td:nth-child(3), .bi-flow-modal-detail-table td:nth-child(4) { min-width: 133px; font-size: 11px; }
|
||||||
|
.bi-flow-modal-empty { padding: 30px 12px; text-align: center; color: #94a3b8; font-size: 12px; background: #f8fafc; border-radius: 8px; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.bi-flow-modal-backdrop { padding: 0; align-items: flex-end; }
|
||||||
|
.bi-flow-modal { border-radius: 12px 12px 0 0; max-height: 90dvh; }
|
||||||
|
.bi-flow-modal-header, .bi-flow-modal-body { padding: 12px; }
|
||||||
|
.bi-flow-modal-control-row { align-items: flex-start; gap: 8px; }
|
||||||
|
.bi-flow-modal-control-label { flex-basis: 44px; padding-top: 6px; font-size: 10px; }
|
||||||
|
.bi-flow-modal-control-hint { margin-left: 52px; }
|
||||||
|
.bi-flow-modal-summary > div { padding: 10px 8px; }
|
||||||
|
.bi-flow-modal-summary span { font-size: 10px; }
|
||||||
|
.bi-flow-modal-summary strong { font-size: 22px; }
|
||||||
|
.bi-flow-modal-table th, .bi-flow-modal-table td { padding-left: 9px; padding-right: 9px; }
|
||||||
|
.bi-flow-modal-table th:not(.bi-flow-modal-number):not(.bi-flow-modal-share):not(.bi-flow-modal-action) { min-width: 72px; }
|
||||||
|
.bi-flow-modal-share { width: 65px; }
|
||||||
|
.bi-flow-modal-track { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bi-flow-modal-merge-toggle { display:inline-flex; align-items:center; gap:6px; color:#64748b; font-size:11px; cursor:pointer; white-space:nowrap; }
|
||||||
|
.bi-flow-modal-merge-toggle input { width:13px; height:13px; accent-color:#2563eb; }
|
||||||
|
.bi-flow-modal-table td.bi-flow-modal-parent-cell { vertical-align:top; padding-top:11px; background:#f8fafc; border-right:1px solid var(--flow-boundary); border-bottom:1px solid var(--flow-boundary); font-weight:500; }
|
||||||
|
|
||||||
|
/* Parent boundaries are stronger in color, never thicker. */
|
||||||
|
.bi-flow-modal-table tbody tr:first-child > td,
|
||||||
|
.bi-flow-modal-table .bi-flow-modal-group-start > td { border-top-color:var(--flow-boundary); }
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import type { AssetSummarySectionProps } from './types';
|
||||||
|
|
||||||
|
export function AssetSummaryDesktopTable({
|
||||||
|
processedData,
|
||||||
|
theme,
|
||||||
|
expandedAssetTypes,
|
||||||
|
toggleAssetType,
|
||||||
|
expandedModels,
|
||||||
|
toggleModel,
|
||||||
|
setVehicleSelection: setShowPlateNumbers,
|
||||||
|
}: AssetSummarySectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="hidden lg:block overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse table-fixed min-w-[1300px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-100">
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 w-24">车型</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 w-48">品牌型号</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/30 w-24">车辆总资产</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存总数</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-江浙沪</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-广东</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-北京</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-新疆</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-其他</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center text-amber-600 w-24">异动</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">待交车</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-green-50/30 w-24">当前在运营</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/20 w-24">本周交车</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-orange-50/20 w-24">本周还车</th>
|
||||||
|
<th className="p-3 font-semibold text-center bg-purple-50/20 w-24">本周替换</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-xs">
|
||||||
|
{processedData.map((typeGroup) => (
|
||||||
|
<React.Fragment key={typeGroup.type}>
|
||||||
|
{/* Category Header Row */}
|
||||||
|
<tr className={`border-b border-gray-100 cursor-pointer transition-all ${
|
||||||
|
theme === 'vibrant' ? 'bg-blue-600 text-white hover:bg-blue-700' :
|
||||||
|
theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 hover:bg-gray-50' :
|
||||||
|
'bg-blue-50/50 hover:bg-blue-50 transition-colors'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleAssetType(typeGroup.type)}>
|
||||||
|
<td colSpan={15} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedAssetTypes.has(typeGroup.type) ?
|
||||||
|
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} /> :
|
||||||
|
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-gray-400'} />
|
||||||
|
}
|
||||||
|
<span>{typeGroup.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`flex gap-6 text-[11px] font-normal mr-4 ${theme === 'vibrant' ? 'text-white/80' : 'text-gray-500'}`}>
|
||||||
|
<span>资产 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
|
||||||
|
<span>库存 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
|
||||||
|
<span>异动 <span className="font-bold">{typeGroup.totalAbnormal}</span></span>
|
||||||
|
<span>运营 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{expandedAssetTypes.has(typeGroup.type) && typeGroup.models.map((model) => (
|
||||||
|
<React.Fragment key={model.model}>
|
||||||
|
<motion.tr
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer ${expandedModels.has(model.model) ? 'bg-blue-50/10' : ''}`}
|
||||||
|
onClick={() => toggleModel(model.model)}
|
||||||
|
>
|
||||||
|
<td className="p-3 border-r border-gray-100 text-gray-300 text-center italic">{typeGroup.type}</td>
|
||||||
|
<td className="p-3 border-r border-gray-100 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||||
|
<span className={expandedModels.has(model.model) ? 'font-bold text-blue-700' : ''}>{model.model}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 text-[9px] font-normal text-gray-400">
|
||||||
|
<span>资产 <span className="font-bold text-gray-600">{model.total}</span></span>
|
||||||
|
<span>库存 <span className="font-bold text-blue-500">{model.inventory}</span></span>
|
||||||
|
<span>运营 <span className="font-bold text-green-500">{model.operating}</span></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 font-medium">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.total}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 font-medium">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.inventory}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
{['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => (
|
||||||
|
<td key={reg} className="p-3 text-center border-r border-gray-100">
|
||||||
|
{model.inventoryRegions[reg] > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory', source: 'asset', title: `${model.model} - ${reg} 库存` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.inventoryRegions[reg]}
|
||||||
|
</button>
|
||||||
|
) : ''}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="p-3 text-center border-r border-gray-100">
|
||||||
|
<button type="button" className="font-bold text-amber-600 hover:underline"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Abnormal', source: 'asset', title: `${model.model} - 异动` }); }}>
|
||||||
|
{model.abnormal}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100">
|
||||||
|
{model.pending > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.pending}
|
||||||
|
</button>
|
||||||
|
) : model.pending}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 text-green-600 font-bold bg-green-50/10">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` });
|
||||||
|
}}
|
||||||
|
className="text-green-600 hover:underline font-bold"
|
||||||
|
>
|
||||||
|
{model.operating}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 text-blue-600 bg-blue-50/5">
|
||||||
|
{model.weeklyDelivered > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.weeklyDelivered}
|
||||||
|
</button>
|
||||||
|
) : model.weeklyDelivered}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 text-orange-600 bg-orange-50/5">
|
||||||
|
{model.weeklyReturned > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.weeklyReturned}
|
||||||
|
</button>
|
||||||
|
) : model.weeklyReturned}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center text-purple-600 bg-purple-50/5 font-medium">
|
||||||
|
{model.weeklyReplaced > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.weeklyReplaced}
|
||||||
|
</button>
|
||||||
|
) : model.weeklyReplaced}
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user