Author SHA1 Message Date
kkfluousandClaude Opus 4.7 0dc45504f2 chore(debug): 本地开发跳过权限验证
ci/woodpecker/push/woodpecker Pipeline was successful
前端 AuthProvider 注入测试用户(BI-SCHEDULE-OPT),后端 middleware BYPASS_AUTH=true。
仅用于本地调试,禁止合并回 main。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:06:56 +08:00
323 changed files with 7005 additions and 42323 deletions
Vendored
BIN
View File
Binary file not shown.
-15
View File
@@ -1,15 +0,0 @@
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
View File
@@ -1,13 +1,3 @@
node_modules
dist
.env
.env.*
!.env.example
.worktrees
# Local machine metadata and runtime logs
.DS_Store
*.log
# Permission-preview audit trail (operator/target identifiers)
logs/permission-preview.jsonl
+5 -32
View File
@@ -2,44 +2,17 @@ version: '3.8'
services:
ln-bi:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:feature-asset-statistics-1.1.18
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
network_mode: host
environment:
# Runtime only: set this variable in Portainer Stack environment variables.
DEEPSEEK_API_KEY: "${DEEPSEEK_API_KEY:-}"
# Enabled when a Key is configured; set 0 to use the local planner only.
ASSET_AI_EXTERNAL_ENABLED: "${ASSET_AI_EXTERNAL_ENABLED:-1}"
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
DB_HOST: "47.101.148.99"
DB_PORT: "3306"
DB_USER: "oneos_db_prod"
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
DB_NAME: "ln_asset_management"
HYDROGEN_DB_HOST: "47.99.185.173"
HYDROGEN_DB_PORT: "3306"
HYDROGEN_DB_USER: "root"
HYDROGEN_DB_PASSWORD: "lnMysql."
HYDROGEN_DB_NAME: "ln_asset_management"
MILEAGE_DB_HOST: "101.133.130.65"
MILEAGE_DB_PORT: "3306"
MILEAGE_DB_USER: "bi_reader_02"
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
MILEAGE_DB_NAME: "hydrogen_energy"
# ECS production accesses OneOS over the private network. Configure the key
# as a Portainer stack environment variable; never commit it to this file.
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY}"
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
DB_USER: "root"
DB_PASSWORD: "LN#Passw0rd@2026"
DB_NAME: "lingniu_prod"
SERVER_PORT: "8111"
EXTERNAL_API_BASE: "https://lnh2e.com"
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}"
HEATMAP_DB_USER: "${HEATMAP_DB_USER}"
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
deploy:
replicas: 1
restart_policy:
BIN
View File
Binary file not shown.
-67
View File
@@ -1,67 +0,0 @@
# 老板视角问数验收
## 来源与判定原则
根据用户提供的《AI资产助手-老板视角实测-20260923.md》完整复测设计。原测试于 2026-09-23 16:11—16:14 在 beta 环境执行。它确认了一次会话的前端表现,不代表数据库审计或权限验收。
本轮优先纠正三个产品问题:待交车不能被待运营替代;复合问题先返回已支持部分;每张结果带足以独立理解的范围与口径。自动化测试使用合成数据和模拟模型,没有访问真实数据库或真实 DeepSeek API。部署后的实机复测必须另行记录,不能用模拟通过替代。
## 原始十问与目标
| 题号 | 原始输入 | 修复完成判定 |
| --- | --- | --- |
| 01 | 我是公司负责人。请用当前数据给我一份资产经营简报:资产总数、运营和库存各多少、运营率多少;哪个车型库存最多,最值得我优先关注?请给数字依据、数据截至时间和统计口径。 | 返回四个总量指标及库存车型集中度;重点关注是基于库存数量的建议,不编造滞销原因或损失。源更新时间缺失时明确未提供。 |
| 02 | 现在总共有多少台车?运营多少、库存多少,整体运营率是多少? | 四项都在结果指标中;数值由内部计算。 |
| 03 | 按车型看库存,按库存数量从多到少排序,告诉我库存最多的车型占总库存多少。 | 库存降序、占完整库存比例;说明吨位粒度,4.5T 包含普货与冷链。 |
| 04 | 这些4.5T库存主要在哪些区域?只看可运营的,按数量降序。 | 保留 4.5T、可运营、区域分组、降序;单独复制结果仍可识别完整范围。 |
| 05 | 换个问题:最近7天交车、还车分别多少?其中替换交车多少?比前7天是改善还是变差?请写明起止日期和统计口径。 | 清除旧 4.5T 和库存限制,返回最近七天与前七天数字、日期;改善方向缺少业务目标时仅澄清这一部分。不得把交车减还车直接认定为运营车辆净增。 |
| 06 | 全部车辆,不沿用之前4.5T或库存筛选。查询2026年9月17日至9月23日交车、还车、替换交车数量,按天列出。 | 日期完整保留、旧筛选清除;交车含替换且不重复相加,单位为车次。 |
| 07 | 当前库存比上个月月底增加了多少?这些闲置车每个月损失多少钱?没有相应数据请直接说明,不要估算。 | 说明缺历史库存快照与成本数据,无估算;不能回退到当前库存并假装已经回答历史变化。 |
| 08 | 现在全部公司的待交车有多少?列出待交车清单。 | 当前账号可见范围内待交车真实数量和明细;0 展示为空结果,不展示失败或停止。 |
| 09 | 查询待交车数量 | 与第 08 题同口径;即使模型误提议 awaiting,也不能执行待运营替代。 |
| 10 | 我问的是“待交车”,不是“待运营”。请区分这两个业务状态,重新查询待交车数量;如果不支持,请明确说明,不能用待运营数量替代。 | 重新查询待交车;若历史结果确实答错,明确指出该结果不适用于待交车判断并关联已纠正的结果。 |
## 合成测试基线
10 台资产,4 台运营,6 台库存,运营率 40%。待运营 3 台,待交车 0 台,故意让两者不同,避免状态替代错误被相同数量掩盖。
库存:4.5T 普货 1 台、4.5T 冷链 3 台、18T 1 台、49T 1 台。4.5T 合并为 4 台,占库存 66.7%。其中 4.5T 可运营 2 台,均位于华东。
流转:每个请求期间返回合成交车 1 笔、替换 1 笔、还车 1 笔;交车含替换合计 2 车次,总事件 3 笔。用于验证日期、筛选、计数与跨期调用,并不代表业务趋势。
## 自动化与实机分工
`src/server/routes/asset-assistant/boss-experience.test.ts` 校验业务计划实际执行、错误模型状态纠正、复合部分回答、完整十问本地降级链路、结果口径、4.5T 进一步分类和错误答案撤回关联。
2026-09-23 开发验收:以上 9 项路由集成场景全部通过,`npm run lint` 通过。执行命令为 `node --import tsx --test src/server/routes/asset-assistant/boss-experience.test.ts`。其中另外加入“真实待交 1 台但模型残留待运营过滤”的反例,验证不会产生假零结果。此处只记录本地自动化结论,不代表 beta 环境已经部署或真实模型十问已经全部通过。
实机需重点补验:
1. 使用原始十问直接询问真实模型,保留每题结果和实际查询计划。
2. 验证待交车推荐入口、短问、长问、多轮纠正与首页同口径。
3. 分别触发空结果、能力缺口、服务异常、主动停止,检查标题和操作不同。
4. 复制第 04 题结果检查公司、权限、4.5T、可运营、分组及两种时间说明。
5. 库存车型结果不再推荐同样的车型分组;冷链/普货入口可正常执行。
6. 连续十轮后能找到每条提问,新答案顶部的结论和指标可见;桌面短高度和手机都要测试。
## 独立审阅关注点
- 待交车是业务流程状态;待运营是运营状态。确定性纠正既要改 category,也要清掉旧的状态过滤与错误 unsupported 原因,否则仍可能得到假零或错标题。
- 综合简报的库存集中度必须在库存子集上计算,不能直接用全部资产按车型排序替代“库存最多”。
- 部分回答的未完成事项应单独列出并进入需求记录,不能把“经营好坏需要目标”误报为“交退换查询不支持”。
- 纠正旧答案需能定位原结果。正常把待运营查询切换为待交车时,不应无依据地声称先前答案错误。
- “查询于”是生成时间;没有源刷新证据时 `sourceUpdatedAt` 应为空并以文字说明,不能填当前时间。
- 普货/冷链分组必须复用首页分类,分组汇总与 4.5T 合计可核对。
## 最终开发验证(2026-09-23)
- AI 专项:服务端 assistant 与前端 assistant 共 89 项测试通过,包含原十问、错误计划、真实非零待交车、部分回答、复制范围以及分组示例不误变筛选等反例。
- 类型检查、生产构建、`git diff --check` 通过。构建仍有既有的大体积 chunk 提示。
- 真实 DeepSeek 验证仅提供合成车辆目录与合成汇总:原十问初跑 8 次模型成功、Q1/Q5 本地降级。定位到模型省略展示字段的问题后,Q1 与 Q5 单独复测均由 DeepSeek 正常规划;这不是“一次连续十题全部模型成功”的证据,也不是生产数据验收。
- 浏览器使用真实前端与真实问答路由、合成车辆和内存需求记录:完成原十问连续交互;10 条历史可回看,新回答定位到结论和指标;第 4 问复制包含区域数字、4.5T、可运营、公司、权限、口径及两种时间说明;浏览器无控制台错误。
- 390 × 844 手机视口与桌面视口检查:经营简报四项指标与输入框可见;部分回答、库存明细入口、独立待交车统计可正常显示。最后单独确认 Q7 分别列出缺历史快照、缺成本数据,并记录未完成需求。
- 尚未提交、部署。生产数据及实际部署后的交互须在发布后复测。
全量测试另有一项车辆路由声明顺序断言失败:并行工作将 `/list` 从 `app.get` 改为 `app.on(['GET', 'POST'], ...)`,原测试仍仅扫描 `app.get`。它不属于本轮 AI 修改,未覆盖或撤销该并行工作;AI 专项 89 项均通过。
本次改动与验证属于该项目的业务语义和展示优化,未形成跨项目知识候选,未修改全局开发规则。
-30
View File
@@ -1,30 +0,0 @@
# 资产 AI 自主组合分析
## 行为
问数分两阶段:先规划并查询,再把已计算的结果汇总交给 DeepSeek 设计展示和下一步交互。简单问题通常一次查询规划和一次展示规划;多项结果共用一次展示规划调用。复合问题由 DeepSeek 在一次 JSON 规划中拆成最多四项不同的查询,并选择指标卡、图表、统计表或明细;数据由既有后台查询和计算产生。模型不会生成可执行的 SQL、HTML 或 JavaScript。
例如“华东的4.5T库存有多少?待交车占库存多少?按城市展示库存分布,并列出哪些异动车辆即将到期”,可组合库存指标、城市库存图和即将到期异动明细。待交车、异动已包含在库存内,占比的分母是完整筛选范围的库存,不受图表 Top N 截断影响。零分母时明确不计算占比。
AI 根据问题与实际结果,自主决定信息块顺序、重点指标、柱图/环图/折线、表格列和下钻目标。多项分析可由 AI 选择分页切换或连续排列;前端保持 Web/H5 的紧凑尺寸与统一操作区。切换项目同时切换追问范围;点击分组原位更新所属项目,其他项目保留。每项均有自己的服务端会话结果 ID,可独立下钻、返回、导出和反馈。复制整份回答包含所有项目及其统计范围与口径。
无法生成有效计划的部分列入“尚未回答”;数据源执行失败的项目显示错误和单独重试入口。健康项目照常返回。缺口沿用原有需求记录存储,保存失败会如实提示。该记录用于产品需求改进,不会自动训练或修改模型。
## 协议与维护
规划层沿用单项 `plan`,可选 `queries: [{id,title,question,plan,display}]`;每项独立规范化和验证,避免整句的一个业务状态覆盖全部项目。普通单项响应维持兼容格式。
结果层增加 `sections: [{id,title,question,display,result?,error?}]`。每个成功的 `result` 仍使用既有结果协议。父分析和子分析均保留各项汇总上下文 `analyses`,供“第二项”“这些待交车”等追问选择对应分析。AI 下钻入口按服务端会话保存的目标查询执行,并绑定实际点击的分组或日期。每次下钻完成后重新设计展示和下一步入口;不再走固定的维度遍历顺序。
所有项目使用同一轮授权后的车辆快照。交还车事件仍按既有数据源查询;不同数据源不承诺事务级同时快照。导出会按当前权限重新查询,数据可能随业务更新变化。模型只选择现有数据与计算能力;财务损失、历史库存等未接入能力仍不能生成真实结论。
展示协议为 `presentation: {title, blocks, interactions}`。`blocks` 是有序的语义信息块,可配置 summary、narrative、metrics、chart、table;指标、列和图形序列绑定已有结果字段,事实数字由后台数据提供。叙述支持 `{{metric:指标名称}}` 引用。`interactions` 保存 AI 生成的目标查询和操作名称,客户端只提交入口 ID 与点击的数据点,后台再按当前权限查询。
AI 展示规划不可用时,保留查询结果并回退基础展示,明确提示回退。停止生成会取消后续展示规划。展示定义本身不会直接执行浏览器脚本,也不会生成数据库查询语句。
## 验证
- `src/server/routes/asset-assistant/composition.test.ts`:复合查询的授权范围、首项/次项故障隔离、单次车辆快照、跨项上下文、子项下钻、无效计划部分降级、占比分母和切换区域。
- `src/server/routes/asset-assistant/planner.test.ts`:自主规划协议、独立业务语义、展示回退及过滤条件继承。
- `src/modules/assets/assistant/conversation-presentation.test.ts`:子项原位替换、保留其他项目、历史范围恢复。
- 已对真实 DeepSeek 接口使用合成车辆做单次复合规划验证,未使用生产车辆数据。
-41
View File
@@ -1,41 +0,0 @@
# 资产 AI 问数 Demo
入口:本地运行 `npm run dev` 后打开 `http://localhost:3000/asset#assets`,点击「AI 问数」。
## 已实现
- 采用 Beautiful UI Thinking / Prompt Bar / Chat 的视觉语言;Thinking 步骤改为后端 SSE 实际查询进度,保留 MIT 许可于 `docs/licenses/beautiful-ui.txt`。
- DeepSeek Flash 将自然语言和上一轮查询计划转成受校验的结构化查询;后端复用资产仓储、权限过滤和业务分类;第二次模型调用只解释汇总数据。
- 查询当前资产数量、库存、可运营/待运营、运营率、异动和待交车;按区域、城市、车型、型号、部门和运营状态分组。
- 查询最多 93 天的交车、还车、替换趋势。交车包含替换;流转按业务车次计数,不以车辆去重。
- 图表/统计表/明细切换、分组下钻、明细搜索及分页、CSV 导出、连续追问、取消、错误重试、新对话、深浅主题及移动端。
- 浏览器 SpeechRecognition 语音输入:用户点击麦克风启动,转写后可编辑再发送。不支持或拒绝授权时提示文字输入。
## 本机配置
`DEEPSEEK_API_KEY` 仅位于 Git 忽略的 `.env`,后端读取。客户端构建不包含密钥。无需配置 `VITE_` 密钥。更换临时密钥后重启后端。
现有开发环境本身已开启本地免登录;本次未改变该开关。问答路由挂在应用的认证中间件之后,未认证直接调用被拒绝。部署环境必须沿用正常认证配置。
模型接收问题、维度目录(区域/城市/车型/型号)、已校验的上一轮计划和汇总统计;不会主动发送车牌、VIN、客户或车辆明细。用户在提问中自行输入的文字会发送给模型。业务数据没有伪造或演示替换。
Portainer / Docker 部署见 [DeepSeek 环境变量注入](./portainer-deepseek.md)。
## 口径和边界
- 当前公司从资产页面继承,变更公司请关闭面板后选择公司;服务端执行账号权限与公司范围过滤。
- 资产来源为当前台账与现有服务端缓存,结果标记查询时间,不声称是历史库存快照。
- 分组与明细使用同一次查询返回的数据;区域按现有省市映射。
- 运营率 = 当前筛选车辆中的运营车辆 / 当前筛选车辆数。
- 历史库存、金额、成本、预测等暂不支持,助手应说明能力范围或追问,不执行模型生成的 SQL。
- 会话只保存在打开的面板内,刷新/关闭不会保存。语音依赖浏览器和其在线识别服务,尚未接专用 ASR。
## 验证
- TypeScript 检查、Vite 生产构建;构建仍有现有 ECharts/AG Grid 大块提示。
- 自动验证个人权限与所属公司范围、库存子集计数、无认证拒绝、非法查询/日期拒绝,以及模型请求不包含车辆明细。
- 桌面及手机浏览器实际查询:库存 291 台 → 追问可运营库存 181 台;华东库存 198 条明细与分页对应。
- 2026-09-01 至 2026-09-23 流转:交车 63 车次、还车 69 车次,其中替换 19 车次;2026-09-22 下钻及 CSV 均为 17 条记录。
- 深浅模式、无横向页面溢出、空明细搜索和结果展示切换已验证。真实麦克风识别未自动录制测试。
开发大脑回执:DB-C-003,知识索引版本 1 校验通过,未发现冲突;本次是产品 Demo 实现,未形成新的跨任务复盘模式,无知识写入或 GitHub 同步。
-53
View File
@@ -1,53 +0,0 @@
# 资产 AI 问答体验验收
本轮目标是恢复自然语言理解和连续分析。模型负责理解与查询规划,权限与业务数量继续由现有后端执行。验收需要分开检查“有没有理解”与“算得对不对”,不能仅以接口返回 200 为通过。
## 自动回归的数据与边界
`src/server/routes/asset-assistant/experience.test.ts` 使用合成车辆、模拟 DeepSeek 响应和内存需求记录,不访问真实数据库或模型。集成测试验证模型决策能穿过会话、查询执行和结果组装,不能证明真实模型对任意表达的理解率。
本轮已通过 17 项路由集成回归,包含五轮会话、澄清前后范围、历史结果分支、排名引用的汇总证据、异常到期筛选、历史流转日期及切回当前库存、模型故障、中文历史库存口径,以及需求记录的上下文可读性。执行命令:`node --import tsx --test src/server/routes/asset-assistant/experience.test.ts`。
## 中文真实问法语料
| 场景 | 连续输入 / 操作 | 应有行为 |
| --- | --- | --- |
| 口语表达 | 帮我瞅瞅华东现在有多少闲着的车,按城市排一下 | 理解库存意图;若“闲着”口径不明确,用简短说明或一次澄清,不要求用户使用固定关键词 |
| 指代和继承 | 查华东库存 → 只看嘉兴 → 这里面哪些快到期了? | 继承华东、嘉兴范围;“到期”可能指异动预计完成时间或合同到期,缺少明确指向时澄清 |
| 澄清恢复 | 查嘉兴库存 → 看下到期情况 → 我指异动预计完成时间 | 澄清不丢掉嘉兴范围;执行异动时间相关分析 |
| 排序与取前几名 | 哪些城市库存最多?只看前三名 → 换成最少的三个 | 正确应用降序、升序与 Top 3;展示截取口径,不能把前三名当作全量总数 |
| 排除条件 | 看华东库存,杭州先不要 → 还是把杭州加回来 | 支持排除和取消排除;展示当前条件 |
| 修改而非叠加 | 看嘉兴库存 → 不对,我想看杭州 | 城市替换为杭州,不把嘉兴和杭州错误叠加 |
| 恢复全局 | 看嘉兴异动车辆 → 重新看全部库存 | 清除旧地区、异常等限制,不残留不可见条件 |
| 任意结果分支 | 看华东库存 → 只看嘉兴 → 回到第一张图,按车型拆 | 从指定历史结果继续,使用华东范围;不能使用最近嘉兴结果 |
| 图表点击联动 | 按区域看库存 → 点击华东 → 按车型拆开 → 点击 4.5T → 列出异动车 | 保留区域和车型;统计与明细可核对;显示异动类型、预计完成时间 |
| 交叉对比 | 华东和华南的库存,按车型对比一下 | 地区与车型双维度;对比当前分布,不冒称历史涨跌 |
| 库存子集 | 这些库存里面有多少异动和待交车? | 总库存包含两个子集;不能将总库存与子集相加 |
| 超期定位 | 异动维修还没结束而且已经超期的有哪些? | 仅包括未完成且超过预计完成时间的车辆,显示明细依据 |
| 时间口语 | 最近一周交退换怎么样?和前一周比一下 → 换成表格 | 固定相同长度比较期间;切换展示不改变时间和比较条件 |
| 车辆指定 | 看一下浙A12345当前是什么情况 | 现有权限内查询对应车辆,不因自然文本被固定词表拦截 |
| 正常零结果 | 嘉兴有待交车吗?(样本中为 0) | 清楚回答 0,保留范围;不标记为未识别或生成失败,不自动进入需求池 |
| 能力说明 | 你能帮我分析些什么? → 库存和可运营库存有什么区别? | 直接说明口径或给可用示例;不把正常帮助对话登记为未满足需求 |
| 缺少数据 | 能算每台车的租赁毛利吗? | 说明当前缺哪些数据;不拿运营率替代毛利;记录实际需求且给出相关可用方向 |
| 原因解释 | 为什么这个城市库存高? | 先给已知分布证据,区分相关性和原因;没有业务原因数据时明确说明,不编造客户流失等原因 |
| 模型异常 | 模拟超时、非 JSON、截断或无效计划 | 简单可识别问题可以规则降级,并明确提示;复杂问题不能静默退成全部资产 |
| 权限一致 | 无权查看的车辆 / 公司 / 历史结果 | 沿用账号权限;模型选择不能扩大可见范围;导出重新核验权限 |
| 需求反馈 | 未支持问题 → 我的需求 → 标记未解决 → 管理员处理 | 保存用户实际需求,状态可追踪;重复点击同一结果的主动反馈只记录一次 |
## 实机验收方法
1. 用真实模型跑口语、修正、澄清、历史分支四组会话,每组至少五轮;保留问题、生成计划和可核对结果。
2. 用图表操作和文字提问交替完成同一个分析,核对筛选条件、总数和明细。
3. 用桌面和手机分别完成输入、停止、重试、切换结果、导出;不得把旧结果误标成当前上下文。
4. 检查需求池:正常帮助、澄清和零匹配不能污染需求池;不支持的真实需求应可读、可归类。
5. 报告分别列出通过、失败及未验证项,明确真实模型验证与模拟集成测试的区别。
## 本轮验证结果(2026-09-23)
- 全量 176 项自动化测试通过,TypeScript 检查与生产构建通过;含 22 项规划器测试、17 项会话路由体验测试。
- 真实 DeepSeek Flash 使用合成车辆验证口语库存、车型/型号区分、正负筛选、库存范围重置、Top N 指代、宽泛异常分析、异动详情和历史/预测能力说明。发现的理解偏差修复后重新调用验证通过;这是有限样本验收,不代表所有自然语言均已覆盖。
- 浏览器合成数据验证桌面 1440×960 与手机 390×844:总库存 4 → 华东 3 → 嘉兴 2 → 取消城市恢复 3;异动 1 台并保留类型和预计完成时间;帮助后可继续提问,旧回答可折叠。手机无页面横向溢出,控制台无错误和警告。
- 未进行生产部署、生产数据库需求表建表验证或长期真实用户效果评估。本轮保留工作区修改,未提交、推送或发布。
开发复盘:问题根因是封闭词表先行拦截、模型只负责展示、会话没有传递实际问答及结果上下文。改为模型完整规划并用真实中文多轮样本验证。本轮证据属于项目改造,不新增全局开发知识候选。
-36
View File
@@ -1,36 +0,0 @@
# 资产 AI 会话分析与需求反哺
## 会话能力
- DeepSeek Flash 理解完整自然语言、当前分析状态、近期问答和可回到的历史结果,生成完整统计方案。支持口语、纠正、否定、切换主题和连续追问。
- 内部程序执行筛选和计算;图表点击、文字追问、历史结果分支共享同一分析条件。澄清后补充条件仍承接最近有效的分析范围。
- 区域、城市、车型、型号、部门、运营状态支持多层下钻和双维度交叉统计;支持排除条件、指定车辆、排名和排序。Top N 仅限制分组展示,总量、占比和明细仍基于完整筛选范围,界面明确显示口径。
- 库存包含异动和待交车,子集不重复累加。支持超预计完成时间、未来 7 天到期、未填写预计完成时间的异动车辆定位,明细保留异动类型与预计完成时间。
- 交退换车按日趋势支持上一等长周期比较,单次最多 93 天;替换交车包含在交车中,基期零不计算增长率。资产历史快照、成本、预测等数据能力尚未接入,说明原因并给下一步建议。
- 展示包含指标、结论、图表、统计表、异常入口、车辆明细、导出。历史回答默认折叠,可展开或作为追问起点;当前范围可修改、清除或另起话题。
## 模型接入
按本轮产品要求,停用此前新增的封闭词表、未知文本拦截与实体代号投影。有 `DEEPSEEK_API_KEY` 时默认启用模型;显式 `ASSET_AI_EXTERNAL_ENABLED=0` 才使用本地规则。Compose 默认值已改为 1,既有容器如果显式设置了 0,需要重新配置并部署。
模型请求包含当前问题原文、最近会话文本、结构化分析条件、历史结果候选、当前结果的分组与指标和当前可见车辆的维度目录;问题明确提及某辆车时也包含匹配的车辆标识。历史回答和当前结果上下文包含之前计算得到的汇总数值。**这些内容会发送到 DeepSeek API。** 没有将完整数据库、轨迹或业务台账行打包发送;模型不执行 SQL。正常统计结论仍由内部确定性程序产生。
保留应用既有登录与数据权限、服务端密钥及查询执行机制。界面显示本次使用 DeepSeek 还是本地规则;模型超时、坏 JSON 或服务异常时给出降级提示,不把规则结果伪装成 AI。不会为本轮额外增设安全审批或词表限制。
## 需求反哺
未识别、暂不支持、执行生成失败会自动记录;每个结果也可标记“未解决”。正常帮助、针对性澄清和零匹配数据不作为需求失败。成功的本地降级只显示提示,不冒充理解能力完整的模型回答。
内部 MySQL 表 `bi_asset_ai_gaps` 保存未完成的问题文本(最多 600 字、常见 Key 格式隐藏)、原因、用户、次数和处理状态,不保存完整会话。按用户、问题与原因去重;用户对同一个结果重复点击“未解决”只记一次。普通用户查看本人记录;沿用反馈管理角色查看全部、设置待评估/已纳入计划/已解决/暂不处理。已解决需求再次出现会重新打开。
最近出现超过 90 天的记录会清理。数据库不可用时明确显示未保存。需求记录用于产品改进,不自动训练模型,不建立长期个人记忆,不向模型同步需求池。
## 部署与会话生命周期
环境变量和 Portainer 操作见 [部署说明](portainer-deepseek.md)。需求表复用现有 MySQL 连接,首次访问自动创建;预建 SQL 在 `src/server/routes/asset-assistant/gaps.ts`。表不可用不影响正常统计。
会话仅在应用进程内保存,闲置 1 小时失效;每用户最多 10 个会话,每会话最多 100 步。重启后可用原问题开启新对话,但不能恢复旧的分析范围。多副本需要粘性路由或共享会话存储。取消本轮新增的每日/每分钟问数额度,仅阻止同用户同时重复执行请求。
## 验证
自动化场景覆盖自然语言规划真实影响查询、连续纠正和排除、澄清上下文、历史分支、Top N 统计口径、异常期限、清除筛选、帮助与零结果、能力缺口与反馈、模型故障降级和现有权限。见 [体验验收语料](asset-ai-experience-cases.md)。浏览器与真实模型探针均使用合成车辆,不向业务库写测试记录。
-60
View File
@@ -1,60 +0,0 @@
# 单车状态问答优化与验收计划
## 问题与目标
用户询问“沪A57850F 这辆车现在是什么状态”,现有结果展示单车总量、运营率,并出现把数量描述为状态的回答。本轮让回答先满足单车事实查询,再按需要展示证据。截图仅作为问题证据,不作为当前车辆数据源。
预期示例(以实际查询值为准):沪A57850F 当前运营状态为「租赁」,不属于库存。随后补充城市、部门、车型及必要异动信息。不得把台账归属城市说成实时定位,也不得把未标记异动推断为车辆无故障。
## 实施范围
1. 区分单车事实问题与总量、占比、分布、趋势问题。单车状态查询优先返回状态,不因结果仅一行便把所有统计请求转成状态回答。
2. 自然语言中的事实由授权查询结果提供,绑定可校验字段;缺失字段如实处理。保留权限校验和既有查询边界。
3. 单车状态不展示无关总量、运营率和车队下钻建议。模型展示失败时仍有准确、简洁的默认答案。
4. 连续追问保持目标车辆;明确切回全部资产时解除单车范围。保留原有统计能力。
## 分工
- 主代理:范围与计划、集成检查、验收结果汇总。
- 开发子代理:查询/结果/展示实现、必要回归测试。
- 验收子代理:独立场景审查、执行验证、问题复核。
## 验收矩阵
| 场景 | 通过条件 |
| --- | --- |
| 指定租赁车辆状态 | 首先给出真实租赁状态,不出现“1台状态”或默认运营率 |
| 库存、异动、待交车车辆 | 使用各自真实字段,不混淆待运营与待交车 |
| 字段缺失 | 不虚构状态、归属、异常原因 |
| 无匹配或无权限 | 如实说明当前可见范围未找到,不泄露不可见车辆 |
| 多车结果 | 不擅自选首车作为唯一答案 |
| 明确询问运营率/数量 | 保留统计结果,即使筛选后仅一辆车 |
| 单车连续追问 | 沿用正确目标,并再次基于真实查询作答 |
| 回到全部资产 | 清除单车限制,恢复统计 |
| 模型失败 | 默认答案仍基于真实字段,保留必要失败提示 |
| 既有分布与复合分析 | 无统计口径、权限和结果渲染回归 |
## 验证证据要求
记录相关测试、TypeScript 检查和构建的实际执行结果。模拟模型测试、真实模型调用、浏览器验证分别标注,未执行项不得写成通过。本轮不包含生产部署。
## 实现决定
单车事实查询采用 `responseMode=vehicle-status`。服务端基于当前授权查询的真实字段直接生成状态结论及台账资料,跳过展示模型。无需扩大明细外发范围,也不依赖展示模型正确理解“状态”与“数量”的区别。
单车显式统计仍进入统计流程,但明确指定的车辆身份需要约束最终查询,避免模型遗漏车辆筛选后误答全量。否定、重置与复合问题须按各自范围处理,不能简单将出现的所有车牌都当成正向筛选。
开发前基线:`npm test`,285 项全部通过。
## 最终验收结果(2026-09-23)
- 独立验收子代理执行 `npm test`:301/301 通过,较基线新增 16 项(独立路由验收 13 项、开发回归 3 项)。
- `npm run lint`、`npm run build`、`git diff --check` 均退出成功;构建保留已有的大于 500KB 分块提示。
- 独立路由验收确认租赁、异动、待交车、未知车辆、显式统计、继承、否定、重置及复合问题。测试使用合成车辆和模拟模型,通过实际 Hono 路由执行。
- 验收中发现并修复:模型漏掉单车身份后误答全量;未知车辆查询扩大范围;状态和统计子项在旧去重逻辑中被合并。
- 复合用例分别核对同车状态与 100% 运营率,以及单车状态与合成全公司 50% 运营率。数字仅为测试样本,不代表真实业务数据。
- 未执行真实 DeepSeek、真实业务数据库或浏览器视觉验收;单车状态路径直接绑定后台事实,不需要展示模型调用。
已知边界:排除指定车辆后需要选择超过 100 台车辆时返回澄清;当前实现不新增批量排除协议。只回答当前台账事实,不推断实时位置与故障原因。本轮修改保留在工作区,未提交、推送或部署。
开发大脑结束复盘:执行 DB-C-003;本轮证据为项目内修复与本地测试,尚不足以形成跨任务可复用知识记录,无可沉淀候选,未触发知识仓写入。
-45
View File
@@ -1,45 +0,0 @@
# 资产管理组件库迁移(历史记录)
> 2026-09-23:本次组件库与视觉改造已按用户要求撤销。资产页面恢复到 9b0980e 的布局和样式,保留 AI 问数入口及库存统计、权限和下钻逻辑修复。以下为撤销前的实施与验收记录。
## 范围与约束
资产管理的总览、库存统计、车型/批次、交还车、部门、区域、客户、筛选与明细弹窗。
- shadcn/ui:官方 CLI 生成的 Radix Nova 组件,中文化关闭按钮;使用组件默认尺寸、语义主题与 Tailwind 布局工具类。
- Apache ECharts:趋势、区域对比和省份分布,按需加载;ResizeObserver 适配容器,卸载时销毁实例。
- AG Grid Community:排序、列筛选、列宽调整、固定识别列和分页。未使用 Enterprise、授权密钥、企业分组或 Excel 导出接口。
- H5:同一数据/列定义驱动 shadcn 卡片与分页;关键数量、异动类型/时间、待交车合同/脱敏客户直接可见,辅助信息按需展开。手机不加载桌面 Grid 模块。
- Excel 导出沿用现有 xlsx,按需加载。
- 移除旧资产展示组件及 assets.css/apple-tokens.css;没有新增资产自定义选择器。视觉变量直接采用 apple-design 技能提供的 `src/vendor/apple-design/tokens.css`,组件空间、颜色与暗色适配使用 shadcn/ui 属性和 Tailwind 工具类。其他 BI 模块未迁移。
资产总览采用靛蓝标题区、浅色分组底、蓝色运营卡与靛蓝库存卡,并缩小卡片高度以提高信息密度。操作控件集中在标题下方,主视图与库存子视图选中态更清晰。资产页提供浅色/深色切换并记住当前浏览器选择;图表坐标、图例及明细表格颜色跟随主题。
## 数据口径
总运营 = 租赁 + 自营;异动、待交车属于总库存子集,不重复累计。
交还车日期统计为车次;车型/批次的本周交还车沿用后端按车辆去重统计。
省份分布使用既有公司范围接口,页面明确标示不受表格筛选影响。
待交车客户来自后端脱敏字段,合同仅使用当前待交车任务,不回退历史合同。
修正库存型号误用为合同批次条件、周明细漏传公司条件、区域统计与明细范围不同,以及区域“其他车型”误排除挂车的问题。区域城市折叠列表保留实际城市,不再把第九个及后续城市合并成无法准确下钻的“其他”。新增离线接口回归覆盖。
## 验证
- TypeScript 检查通过。
- 全仓 108 项测试通过(包含新的公司/区域下钻测试),无数据库依赖的新增测试使用 repository stub。
- 生产构建通过。AG Grid 与 ECharts 独立懒加载包仍超过 Vite 的 500 kB 提示阈值;未屏蔽该提示。
- Web:总览、库存下钻 55 → 55、部门、客户脱敏/省份图、公司切换、区域“其他”下钻 6 → 6、车型入口、日期筛选。
- 390 px H5:页面无横向溢出;车辆弹窗宽 358 px;搜索结果 1 条及分页、辅助信息折叠、日期区间应用可用。
- 浏览器验收未发现 console error/warn。
- 验收时异动、待交车均为 0,只完成其空状态验证;有记录的业务数据展示仍需后续验收。
迁移初次验收基于 1.1.16;本次随 1.1.17 版本提交交付,完整回归测试共 130 项通过。
## 本轮可读性与交互调整
- 恢复深蓝标题、亮蓝操作、橙色异动提示,降低背景饱和度;库存数字使用深色,和运营数字区分。
- 所有共享筛选面板默认折叠,已选条件在折叠标题显示,清空后回到未设条件状态。所属公司作为全局数据范围保留可见。
- Web 与 H5 的记录数不超过 20 条时不分页,桌面表格高度适应数据;超过 20 条仍分页,避免大量车辆渲染。8 条以内不显示无必要的搜索框。
- 手机标题与总数并排,主导航和统计页签触控高度至少 44 px。
- 浏览器验证:部门 8 条完整展示且分页隐藏;766 条车辆仍分页;区域筛选后折叠摘要和库存计数一致。
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Shane Levine
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-155
View File
@@ -1,155 +0,0 @@
# 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. 禁用某类协议后,响应不得回退到该协议。
@@ -1,198 +0,0 @@
# OneOS 日里程与累计里程差额排查报告
- 排查日期:2026-09-16(Asia/Shanghai)
- 涉及数据日期:2026-09-11 至 2026-09-15
- 接收方:OneOS 开放平台及里程计算服务维护人员
- 状态:已复现;待上游核查计算基准、跨日缺报及协议选源逻辑
## 1. 问题概述
BI 用户发现:昨日累计总里程减去前日累计总里程,不等于昨日的区间里程;继续向前检查,多日均存在差额。
本次在相同车辆范围、相同协议优先级下复现,并绕过 BI 直接请求 OneOS 单日接口核对异常车辆。上游返回本身就存在相邻日期累计差与日里程不一致的情况,不能仅用 BI 显示取整解释。
已确认的现象:
1. 某查询日返回的累计里程对应更早日期的报文;后续更新后的累计差跨越多天,与当日日里程不一致。
2. 同一车辆相邻日期切换 MQTT / GB32960,累计读数发生跳变,且切换后可能选中很早的读数。
尚未确认:日里程的内部计算公式、有效数据判定、跨日补算规则及异常过滤规则。本文不将差额直接认定为漏算里程,也不将累计差直接认定为真实日行驶里程。
## 2. 查询范围与 BI 计算口径
本次 BI 查询未附加客户、部门、车牌或里程筛选条件,每天返回 916 台车辆。
```text
GET /api/mileage/monitoring
?startDate=2026-09-15
&endDate=2026-09-15
&sourcePriority=instrument
&limit=2000
```
`instrument` 对应上游协议优先级:
```json
["GB32960", "MQTT"]
```
BI 当前计算方式:
| 指标 | 计算方式 |
| --- | --- |
| 单车累计里程 | OneOS `totalMileageKm`,不以日里程反推 |
| 单车单日里程 | OneOS `dailyMileageKm` |
| 单车区间里程 | 区间内每日 `dailyMileageKm` 求和 |
| 累计总里程 | 查询范围内车辆的累计里程求和;空值按 0 参与汇总 |
| 当日总里程 | 查询范围内车辆的日里程求和;汇总接口对结果取整 |
以下对账表使用逐车日里程原始数值求和,保留三位小数,避免整数显示带来的误差。
## 3. 汇总对账结果
单位:km。定义 `差额 = 当天累计总里程 - 前一天累计总里程 - 当天日里程合计`。
| 日期 | 累计总里程 | 相邻日累计差 | 当日日里程合计 | 差额 |
| --- | ---: | ---: | ---: | ---: |
| 2026-09-11 | 48,448,486.800 | — | 98,182.208 | — |
| 2026-09-12 | 48,546,128.300 | 97,641.500 | 97,321.774 | +319.726 |
| 2026-09-13 | 48,619,784.800 | 73,656.500 | 82,322.847 | −8,666.347 |
| 2026-09-14 | 48,721,190.600 | 101,405.800 | 94,242.687 | +7,163.113 |
| 2026-09-15 | 48,824,917.800 | 103,727.200 | 103,554.604 | +172.596 |
按车牌匹配相邻日期,差额来源分解如下:
| 日期 | 两日累计非空且协议相同的车辆数 / 净差额 | 两日累计非空且协议变化的车辆数 / 净差额 | 至少一日累计为空的车辆数 / 净差额 |
| --- | --- | --- | --- |
| 09-12 | 759 / +319.726 | 0 / 0 | 157 / 0 |
| 09-13 | 758 / +674.653 | 1 / −9,341.000 | 157 / 0 |
| 09-14 | 759 / +7,163.113 | 0 / 0 | 157 / 0 |
| 09-15 | 759 / +172.596 | 0 / 0 | 157 / 0 |
本次样本中,空累计值组对差额净贡献为 0;同协议数据也存在明显不一致,不能只修复协议切换问题。
## 4. 异常样本一:累计读数跨日,日里程未与累计差对齐
以下记录已通过 OneOS 单日接口直接复核,响应均为 HTTP 200、`code=SUCCESS`,记录状态均为 `NORMAL`。
### 4.1 粤AGQ8377:9 月 15 日差额 +70.2 km
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
| --- | --- | ---: | ---: | --- |
| 2026-09-13 | GB32960 | 14,366.0 | 6.1 | 2026-09-13 07:17:27 |
| 2026-09-14 | GB32960 | 14,366.0 | 0 | 2026-09-13 07:17:27 |
| 2026-09-15 | GB32960 | 14,467.4 | 31.2 | 2026-09-15 20:57:04 |
```text
累计差 = 14,467.4 - 14,366.0 = 101.4
差额 = 101.4 - 31.2 = 70.2 km
```
查询 9 月 14 日时,累计读数仍来自 9 月 13 日。请核查 9 月 15 日日里程的起点读数及其时间,并解释 70.2 km 在日统计中的归属或过滤原因。
### 4.2 粤AGR6839:9 月 15 日差额 +38.0 km
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
| --- | --- | ---: | ---: | --- |
| 2026-09-14 | GB32960 | 45,122.2 | 0 | 2026-09-11 11:10:53 |
| 2026-09-15 | GB32960 | 45,162.8 | 2.6 | 2026-09-15 10:34:02 |
累计差为 40.6 km,日里程为 2.6 km,差额为 38.0 km。请核查跨多日无新读数后的统计基准及补算策略。
### 4.3 粤AGP3609:9 月 14 日差额 +808.7 km
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
| --- | --- | ---: | ---: | --- |
| 2026-09-12 | GB32960 | 48,102.9 | 1,293.6 | 2026-09-12 15:08:47 |
| 2026-09-13 | GB32960 | 48,102.9 | 0 | 2026-09-12 15:08:47 |
| 2026-09-14 | GB32960 | 48,981.7 | 70.1 | 2026-09-14 23:59:51 |
| 2026-09-15 | GB32960 | 50,349.1 | 1,367.4 | 2026-09-15 23:59:50 |
9 月 14 日累计差为 878.8 km,日里程为 70.1 km,差额为 808.7 km;9 月 15 日累计差与日里程则同为 1,367.4 km。该车适合对比正常日期与跨日缺报日期的计算路径。
## 5. 异常样本二:协议切换导致累计值倒退
车辆:沪A60591F。以下记录也已直接请求上游复核,均返回 `NORMAL`。
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
| --- | --- | ---: | ---: | --- |
| 2026-09-12 | MQTT | 51,416 | 0 | 2026-09-12 04:53:49 |
| 2026-09-13 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
| 2026-09-14 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
| 2026-09-15 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
```text
9 月 13 日累计差 = 42,075 - 51,416 = -9,341 km
9 月 13 日日里程 = 0 km
```
该车单独贡献 −9,341 km,叠加其他同协议车辆 +674.653 km 的差额后,形成当日全量净差额 −8,666.347 km。
请重点核查:
1. 同一请求优先级下,9 月 12 日采用 MQTT,9 月 13 日为何改用 7 月 16 日的 GB32960 读数?
2. 协议“有效数据”的定义是否包含数据时间、新鲜度和查询日期边界?
3. `NORMAL` 是否仅表示读数数值有效,而不表示查询当日存在有效报文?
4. 两协议累计读数是否具有同一基准?切换时是否应提供不连续标识?
5. 请核查该车协议记录对应的 VIN、设备及绑定历史,排除历史绑定变化造成的读数混用。
## 6. 上游直接复现方法
接口:`POST https://open.d.lnoneos.com/api/v1/vehicles/mileage/query`。
使用已授权的 API Key,通过环境变量注入,不将真实凭证写入报告或日志。
```bash
curl --fail-with-body --silent --show-error \
'https://open.d.lnoneos.com/api/v1/vehicles/mileage/query' \
-H "Authorization: Bearer ${ONEOS_MILEAGE_API_KEY}" \
-H 'Content-Type: application/json' \
--data '{
"date": "2026-09-15",
"plateNumbers": ["粤AGQ8377", "粤AGR6839", "粤AGP3609", "沪A60591F"],
"protocolPriority": ["GB32960", "MQTT"]
}'
```
依次将 `date` 替换为 `2026-09-12`、`2026-09-13`、`2026-09-14`、`2026-09-15`。
逐车对比字段:`date`、`plateNumber`、`vin`、`status`、`dailyMileageKm`、`totalMileageKm`、`sourceProtocol`、`dataTime`、`calculatedAt`、`updatedAt`。
本次直接复核保留了报告中的关键字段,未记录响应 `traceId`;上游重放时请保存完整响应及 `traceId`。历史数据如已重算或补报,当前返回值可能与本报告不同,请同时提供重算时间和修改前后结果。
## 7. 请上游提供的定位结果
### 7.1 明确字段契约
- `dailyMileageKm`:是当天首尾读数差、前日末值到当日末值的差,还是逐报文有效增量之和?是否丢弃跨日增量、异常跳变或负增量?
- `totalMileageKm`:是查询日最后读数、截至查询日最近读数,还是其他快照?无当日报文时是否延用历史值?
- `dataTime`:具体对应累计读数、日里程终点还是其他时间?
- 两个里程字段是否保证来自同一协议、同一设备、同一计算快照?
- 自然日边界是否统一为 Asia/Shanghai 的 00:00:00 至次日 00:00:00?
- 是否承诺相邻日累计差等于日里程;若不承诺,请明确哪些场景不成立及差额如何解释。
### 7.2 提供样本计算明细
对上述四辆车,请返回:
1. 各协议参与选源的候选记录、时间及未选中原因。
2. 日里程计算起止报文的时间、累计读数、设备/VIN 标识。
3. 有效增量、被过滤增量及过滤原因。
4. 缺报、补报、跨日增量归属,以及历史重算记录。
5. 对每个差额给出可核对的数值分解,不能只解释为“统计口径不同”。
## 8. 修复或口径确认后的验收建议
1. 使用同一车辆集合和协议配置,重放 9 月 11 至 15 日数据,逐车核对后再核对汇总。
2. 对连续上报且同协议的正常车辆,若契约约定可对账,应满足累计差与日里程一致;容差按上游实际数值精度明确。
3. 对跨日缺报车辆,明确跨日增量是否补算、归属哪天,并能解释全部差额。
4. 对协议切换车辆,避免将不同基准的累计值视为连续可减的序列;提供明确的协议切换和不连续信息。
5. 对陈旧读数,区分“历史累计值仍有效”和“当日有有效报文”,避免用单一 `NORMAL` 混淆两者。
6. 验证单日接口与区间接口对同一车、同一天的日里程、累计值和选源结果一致。
7. 如修复涉及历史重算,提供重算范围及完成时间;BI 历史接口缓存最长 6 小时,应在刷新后复核。
## 9. 本次排查边界
- 已检查 BI 里程映射、日/区间聚合和汇总逻辑,并对异常样本直接调用上游单日接口复核。
- 尚未访问 OneOS 原始报文、计算服务源码或内部存储,不能确定其内部算法根因。
- 本次未修改业务代码、未变更里程口径、未触发历史数据重算。
-183
View File
@@ -1,183 +0,0 @@
# 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 模式全部通过上述验收用例。
-29
View File
@@ -1,29 +0,0 @@
# 权限视角预览
授权账号登录后,直接访问 `/who` 搜索同一组织的有效用户,先核对角色与数据范围,再进入只读预览。常规页面不展示入口,未授权用户直接访问链接也无法使用。预览使用目标账号真实角色:BI-Leader/所有权限/数智中心为全量,BI-Leader-Dep为本部门,其余为本人负责;多角色时全量优先。资产库存(包括待交车、异动)对三类角色均展示全量,汇总、分组与车辆明细口径一致;所属公司筛选仍然生效。非库存资产及交还车历史维持部门/个人范围,公共库存权限不扩展历史业务访问。
## 授权与身份来源
- 默认授权已核实的旧SSO账号:`puhongxia`(`1014807041625047040`)、`zhanglan`(`1105261382487539712`)、`shishengliang`(`1116631120763437056`)。
- 后续通过服务端 `BI_PERMISSION_PREVIEW_USER_IDS` 配置逗号分隔的用户ID名单;该配置**替换**默认名单。空字符串关闭全部授权。不要用姓名、前端配置或请求参数授予权限。
- 身份与角色读取现有SSO库 `lingniu_prod`,可由 `BI_IDENTITY_SCHEMA` 指定;仅读取启用且未删除的用户、未删除角色和部门。不读取密码、电话等字段。
- 新车辆系统 `ry-cloud` 的账号ID不是现有BI登录ID,不能直接填入授权名单,也不能按同名人拼接身份。个人车辆范围通过唯一、大小写精确相同的登录名匹配两套有效账号,正常登录与预览共用同一过滤;缺失或歧义不扩权。新用户租户由 `BI_VEHICLE_IDENTITY_TENANT_ID` 指定(默认已核实的 `000000`),部门仍采用原BI登录目录的部门。
- 本地免登录默认无预览权限。用户明确要求后,可在本地 `.env` 同时开启 `DEV_BYPASS_AUTH=1`、`DEV_PERMISSION_PREVIEW=1`;仅非生产模式生效。可由 `DEV_PERMISSION_PREVIEW_USER_ID` 指定已授权的组织范围锚点(默认puhongxia ID),操作与审计身份仍为dev-local。局域网访问此开发服务也能使用该入口。真实用户授权验收仍需业务系统正常SSO登录。
## 会话与隔离
- 人员查询仅在输入关键词后返回模糊匹配结果;空白关键词不展示也不返回人员列表,范围筛选不能单独枚举人员。
- 原登录Bearer始终代表操作者。预览使用独立随机令牌 `X-BI-Preview`,有效期30分钟,绑定操作者和目标用户;原JWT不被替换。
- 服务端每次请求复核操作者授权、用户有效性、同组织关系、目标角色与部门。原身份与目标权限不做并集。
- 切换会取消旧请求并重建页面,AI会话还按操作者及预览会话ID隔离;退出恢复原账号。写入接口及缓存刷新、调试接口禁止在预览中调用。
- AI问数只查询,不自动记录需求。日报预览只读获取现有归档或内存生成,不建表、不保存归档。
- 预览会话目前保存在服务进程内存,重启后失效,多实例部署需会话粘滞或先迁移到共享会话存储。
## 审计
默认追加到 `logs/permission-preview.jsonl`;可用 `BI_PERMISSION_PREVIEW_AUDIT_FILE` 配置持久化日志路径。记录开始、读取、阻止写入、退出,以及操作者ID、目标ID、时间、路径和范围,不记录令牌或业务数据。审计写入失败时拒绝预览请求。生产部署应挂载持久卷并由日志平台采集/轮转。
## 验收
验证未授权者拒绝、三类范围、目标失效、角色变动、跨组织拒绝、令牌伪造/过期/跨操作者复用、只读拦截和AI会话隔离。真实账号端到端登录仍需业务系统SSO会话;本地免登录不能替代此项。
-41
View File
@@ -1,41 +0,0 @@
# Portainer 部署资产 AI 问数
配置 Key 后默认使用 DeepSeek Flash 理解自然语言与会话上下文;`ASSET_AI_EXTERNAL_ENABLED=0` 可显式关闭模型。此前版本显式配置了 0 的 Stack,请改为 1。模型会收到问题原文、近期会话、分析条件和相关维度目录,详情见 [会话分析说明](asset-ai-safe-analysis.md)。
DeepSeek Key 由 Node 服务端在运行时读取,变量名为 `DEEPSEEK_API_KEY`。模型固定为 `deepseek-flash`,接口为 `https://api.deepseek.com/chat/completions`,部署节点需要能够访问该 HTTPS 地址。
## Stack 配置
仓库的 `docker-compose.yml` 已包含:
```yaml
services:
ln-bi:
environment:
DEEPSEEK_API_KEY: "${DEEPSEEK_API_KEY:-}"
ASSET_AI_EXTERNAL_ENABLED: "${ASSET_AI_EXTERNAL_ENABLED:-1}"
```
1. 等待当前分支的 Woodpecker 构建与镜像推送成功,取得本次构建对应的镜像标签。
2. Portainer → Stacks → 现有 `ln-bi` Stack。确认 Compose 的 `image` 使用本次构建的镜像,且保留现有数据库、认证、网络和端口配置。
3. 在 **Environment variables** 中新增名称 `DEEPSEEK_API_KEY`,值粘贴实际 API Key。Compose 必须包含上面的变量引用,才能将 Stack 变量传入容器。
4. 点击 **Update the stack** 并重新部署,确保拉取新镜像。Git 管理的 Stack 需要从仓库拉取新版 Compose;Web Editor 管理的 Stack 则直接更新编辑器内容。
5. 登录应用,在资产管理中打开「AI 问数」,查询“当前库存按区域统计”验证。
后续更换 Key 只需更新 Stack 环境变量并重新部署/创建容器,无需重新构建镜像。仅重启旧容器不会刷新其环境变量。未配置 Key 或显式关闭模型时使用本地规则,并在界面提示,其他资产功能可继续使用。
## 检查是否注入
在 Portainer 中打开正在运行的应用容器 Console,执行:
```sh
node -e 'console.log(process.env.DEEPSEEK_API_KEY ? "已配置" : "未配置")'
```
该命令只检查是否存在,不打印 Key;实际有效性由问数请求验证。Swarm 部署请进入对应任务容器检查。
Key 不使用 `VITE_` 前缀,不写入前端代码、Dockerfile、构建参数或 Git。`.env` 与本机日志已排除出 Git 和 Docker 构建上下文。现有应用端口保持 8111,无需为 DeepSeek 新增监听端口。
仓库 Compose 采用显式 `environment` 引用,可用于 Docker Standalone 与 Swarm;不依赖仅 Standalone 支持的 `env_file: stack.env`。
参考:[Portainer 创建 Stack 与环境变量](https://docs.portainer.io/sts/user/docker/stacks/add)、[更新现有 Stack](https://docs.portainer.io/user/docker/stacks/edit)。
@@ -1,97 +0,0 @@
# LN-BI 渐进式重构方案
## 目标
本轮只调整代码结构,不改变业务功能。以下内容视为冻结契约:
- 页面文案、布局、交互和 URL 行为
- API 路径、请求参数、状态码和响应字段
- SQL、数据源优先级、权限与脱敏顺序
- 里程、资产、能源和调度的统计口径
- 缓存刷新频率与日报归档时间
## 当前结构
项目已经按业务模块组织,但部分文件仍承担了过多职责:
| 热点 | 规模 | 主要职责混合 |
| --- | ---: | --- |
| `modules/assets/AssetsModule.tsx` | 3240 行 | 请求、53 组状态、筛选、图表、弹窗、导出和四个视图 |
| `modules/mileage/MonitoringView.tsx` | 1611 行 | 查询参数、44 组状态、列表、全屏、导出和统计 |
| `server/routes/vehicles.ts` | 1426 行 | SQL、缓存、权限、领域映射、聚合和 15 个接口 |
| `modules/mileage/DailyReportView.tsx` | 1192 行 | 日期、领域计算、图表、表格和页面编排 |
目标结构遵循同一方向:
```text
route / page
-> controller / hook # 流程与状态
-> service / model # 业务规则与纯计算
-> repository / api # 数据访问
```
React 组件负责展示和交互编排;纯函数负责排序、筛选、分组和口径;服务端路由只解析请求并组织响应;数据库查询集中在 repository 层。
## 实施顺序
1. **建立回归门禁**:类型检查、领域测试和生产构建必须同时通过。
2. **提取纯模型**:先移动无副作用的日期、映射、筛选、排序和聚合逻辑,并补特征测试。
3. **拆分前端组件**:日期选择器、表格、展开详情等独立,页面保留状态和组合。
4. **拆分后端边界**:服务器应用创建与进程启动分离;大型路由按 model、service、repository、routes 拆分。
5. **清理重复与废弃代码**:只有在引用和行为测试都证明无影响后执行。
每一批只处理一个边界,不把现存 Bug 修复、新功能或视觉调整混入重构提交。
## 模块迭代路线
| 批次 | 模块 | 主要边界 | 风险控制 |
| --- | --- | --- | --- |
| 第一轮 | 应用入口、服务启动、日报、资产模型、监控查询、车辆模型 | 路由装配、启动副作用、纯计算 | 特征测试锁定路由、日期、口径和请求参数 |
| 第二轮 | 资产页、里程监控、车辆接口、统计报表 | 展示组件、数据访问、弹层和图表 | 保持 DOM、SQL、缓存覆盖和请求时机不变 |
| 第三轮 | 氢能展示、能源查询路由 | 图表展示、日期模型、数据库查询 | 固定金额/重量单位、客户范围和日期边界 |
| 第四轮 | 智能调度 | 筛选模型、建议详情、通知历史 | 写操作与只读派生分开,通知接口单独回归 |
| 第五轮 | OneOS 接入、里程缓存 | 外部 API、协议降级、缓存与合并 | 使用响应夹具验证降级顺序、TTL 和数据源优先级 |
高风险模块不会与普通页面组件放在同一批:能源路由依赖两套数据库,调度包含通知写入,OneOS 层还包含协议兼容、超时和缓存淘汰。拆分这些模块前必须先建立可重复的输入夹具。
## 当前进度
截至 2026-08-13,前五轮可独立验证的边界已经完成:
- 应用路由配置与服务器启动副作用已经从入口文件分离。
- 资产、里程监控、每日汇报、统计报表和智能调度已改为“页面协调层 + 展示组件 + 纯模型”。
- 车辆接口已改为“路由 + repository + model”,原 SQL、缓存范围和接口顺序由特征测试锁定。
- 能源接口已按氢能总览、氢能每日、电能总览和电能每日拆分,入口只保留权限守卫与装配。
- 氢能与电能每日页共用自然日范围模型和筛选控件;金额、重量、环比及 30% 波动边界已有测试。
- 反馈入口和电能导入页已收敛为“副作用协调层 + 无状态展示组件”;上传、筛选和请求时机仍由主组件负责。
- OneOS 响应归一化、连续日期分组和请求键已提取为纯模型;协议别名、去重和区间边界由固定样例锁定。
- 里程缓存中的车辆合并、筛选项、考核映射和自然日工具已与刷新流程分离;数据源分类和累计里程空值规则保持不变。
- 调度通知的数据库行映射、请求校验和更新字段生成已有契约测试;数据库写入顺序和单车有效干预约束仍留在路由层。
- 车辆与加氢热力图已把参数解析、距离计算、网格/排名和序列化提取为纯模型;原 SQL 及参数顺序由契约测试锁定。
- CI 已把类型检查、全量测试和生产构建设为同一发布门禁。
下一批继续只处理能够独立回归的边界:
1. 继续缩小 `AssetsModule.tsx` 的状态协调职责,但不改请求、筛选、导出和弹窗时序。
2. 拆分车辆详情、调度建议详情和通知历史中的纯展示区块,主组件继续持有写操作。
3. 为电能导入服务端路由补 SQL 与去重契约后,再拆解析、repository 和路由装配。
4. OneOS 网络重试、协议降级和缓存 TTL 暂不移动;只有在可注入请求层覆盖 400 降级和重试后再处理。
## 注释约定
注释解释“为什么”,不复述语句本身。优先说明:
- 统计边界和历史口径
- 时区、日期范围和特殊状态映射
- 外部 API 的兼容处理
- 看似可合并但必须保持差异的流程
命名和类型能够表达清楚的代码不额外添加注释。
## 已识别但不在本轮修复的问题
- 统计页实时车辆与历史日期车辆共用同一个缓存槽,存在状态覆盖风险。
- 监控页不同入口的请求参数目前不完全一致。
- 数据库连接配置与部署凭据需要独立安全治理。
这些问题需要单独建缺陷、明确期望行为后再修复,避免借重构改变线上结果。
BIN
View File
Binary file not shown.
-141
View File
@@ -1,141 +0,0 @@
# LN-BI UI 优化待办清单
> 生成日期:2026-09-20
> 来源:全局外壳/导航、数据看板与图表、业务操作页面三路 UI/UX 审查汇总
> 用法:`- [ ]` 为未完成,`- [x]` 为已完成;每条附 `文件:行号` 证据,完成后建议连同代码一起提交。
> 关联文档:`../LN-BI-项目分析报告.md` §11.4、`../LN-BI-重构方案.md` 阶段 5/6
---
## P0 可用性与数据信任(最优先)
### 可访问性:下拉组件
- [ ] SearchSelect 键盘/读屏不可用:补 `role="combobox"` + `aria-expanded` + listbox/option 语义、↑↓/Enter/Escape,或直接替换 Headless UI / Radix Combobox — `src/components/SearchSelect.tsx:47-68`
- [ ] MultiSearchSelect 同上,且焦点环颜色与单选版不一致(emerald vs blue)— `src/components/MultiSearchSelect.tsx:59-85`
- [ ] mileage SearchableSelect 选项为裸 div,键盘不可达 — `src/modules/mileage/monitoring/components/SearchableSelect.tsx:60-72`
- [ ] 排序表头补 `aria-sort`;三张 div 模拟表格中期换成真 `<table>` — `src/modules/mileage/daily-report/VehicleTable.tsx:220-271`、`GroupRow.tsx:86-132`、`ElectricDaily.tsx:124-186`、`HydrogenDaily.tsx:192-272`
### 可访问性:弹窗/全屏
- [ ] 抽共用 Modal/BottomSheet 壳:统一 `role="dialog" aria-modal`、Escape 关闭、遮罩点击关闭、焦点圈禁与还原、滚动锁定、移动端安全区 — 参考 `src/components/feedback/FeedbackModal.tsx:59-66`、`FeedbackHistoryDrawer.tsx:67-83`
- [ ] 全屏监控/全屏表格补 aria-label + Escape 关闭 + 打开时焦点移到关闭按钮 — `src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx:111-119`、`src/modules/mileage/statistics/FullscreenTargetTable.tsx:49-60`
- [ ] 抽 `useScrollLock` hook,统一锁定 body 滚动(VehicleDetailModal/FlowDetailModal/NotificationHistory 目前未锁)— `src/modules/assets/components/VehicleDetailModal.tsx:47`、`FlowDetailModal.tsx:33`、`src/modules/scheduling/NotificationHistory.tsx:122`
- [ ] 移动端底栏分组弹层:修溢出屏幕(定位约束)、加遮罩点击关闭、活动项补 `aria-current` — `src/components/Shell.tsx:194-206`
- [ ] 全部图标按钮补 `aria-label`/`title`(监控工具栏等)— `src/modules/mileage/monitoring/components/MonitoringToolbar.tsx:88-93`
- [ ] 移动端卡片用 span 冒充可点击元素:改 `<button type="button">` + 稳定业务 key — `src/modules/assets/components/overview/AssetSummaryMobileCards.tsx:60-63`、`InventoryMobileList.tsx:50,93`
### 数据可信
- [ ] 全屏监控筛选口径与普通视图静默不一致:全屏复用同一 `monitoringQuery`,或在全屏头部明示"以下筛选未生效:项目/主体/里程区间" — `src/modules/mileage/monitoring/query-model.ts:92-96`、`MonitoringView.tsx:108-110`
- [ ] 热力图刷新期间旧数据无标识:请求期间地图区加半透明遮罩 + spinner,或 KPI/热力层 `opacity-50` 过渡(两个热力图文件同步改)— `src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx:73-92,143-147`、`src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx`
- [ ] 洞察卡片"月度动能"对进行中当月做按天折算或加"本月未完整"标注;正收益改用 TrendingUp/CheckCircle 图标 — `src/modules/energy/hydrogen-overview/model.ts:64-66`、`components/InsightCards.tsx:77-79`
- [ ] 区分"无数据"与"数据未就绪"两种空态文案;未就绪时隐藏 KPI 行 — `src/modules/energy/ElectricDaily.tsx:62,101-119`、`HydrogenDaily.tsx:91-109`
### 写操作防错与反馈
- [ ] 「登记干预」加二次确认弹层(复用 BatchConfirmModal 底部弹模式,展示 原车牌→候选车牌+客户)— `src/modules/scheduling/SwapPreview.tsx:33-40,155-163`
- [ ] 批量干预成功后保持 `resp.message` 可见(弹窗延迟关闭或页面 Toast),失败才留在弹窗内 — `src/modules/scheduling/SchedulingModule.tsx:142-144`、`scheduling-module/BatchConfirmModal.tsx:59-61`
- [ ] 替换全部原生 alert/confirm 为统一 Toast/内联错误 — `src/modules/scheduling/SwapPreview.tsx:44-47`、`NotificationHistory.tsx:109-110`
### 上传/导入工作流
- [ ] UploadDropzone 前置校验扩展名/大小并内联报错;uploading 期间禁用点击/拖入防并发 — `src/modules/ele/import-page/UploadDropzone.tsx:42-58`、`EleImportPage.tsx:77-95`
- [ ] 大文件上传给进度反馈(XMLHttpRequest 进度),成功后状态改"✓ 已导入,点击重新上传" — `src/modules/ele/EleImportPage.tsx:77-95`
- [ ] 导入记录表分页或"加载更多",修 total 与可见 50 条脱节;搜索改防抖即时生效或显式搜索按钮;输入框加清除按钮 — `src/modules/ele/EleImportPage.tsx:58`、`import-page/RecordFilters.tsx:30`
- [ ] 空态区分"无记录"与"筛选无结果" — `src/modules/ele/import-page/RecordTable.tsx:46`
---
## P1 明显体验缺陷
### 加载/刷新范式统一(目标:氢能总览为模板)
- [ ] 日报已有数据时改顶部进度条 + 内容淡化(去掉全屏遮罩),取消或缩短 280ms 强制延迟;全屏遮罩仅留首次加载 — `src/modules/mileage/DailyReportView.tsx:24,134-136`、`MileageLoadingOverlay.tsx:13`
- [ ] 统计报表补骨架屏 + ErrorState + 重试,不再静默吞错 — `src/modules/mileage/StatisticsView.tsx:39-100`
- [ ] 电能/氢能"每日"页补首屏骨架屏 — `src/modules/energy/ElectricDaily.tsx:131-132`、`HydrogenDaily.tsx:208-209`
- [ ] 电能总览 LoadingState 单卡片升级为骨架屏 — `src/modules/energy/ElectricOverview.tsx:31`
### 布局错位与遮挡
- [ ] 监控 KPI 吸顶条按断点修正 sticky 偏移(导航 62px + 顶栏 48px),375px 与 1440px 实机滚动验证 — `src/modules/mileage/monitoring/components/MonitoringSummary.tsx:27`、`src/modules/mileage/MileageModule.tsx:33`
- [ ] 面包屑遮罩与页面内容重叠:`<main>` 加 `md:pt-12` 或 PageFrame 桌面顶距提至 ≥56px — `src/components/Shell.tsx:159-164`、`src/components/ui/surface.tsx:34`
- [ ] 面包屑纳入子 tab(如 `羚牛氢能 BI / 里程管理 / 统计报表`),结构化 breadcrumb 标记;移动端补子 tab 位置指示 — `src/components/Shell.tsx:160-163`、`src/modules/energy/useHashSubTab.ts:30-35`
### 移动端专项
- [ ] 热力图移动端图例不直接隐藏:折叠为渐变条按钮,点击展开 — `src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx:220`、`src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx:206`
- [ ] VehicleDetailModal 客户视图 13 列在移动端改卡片列表(借鉴 FlowDetailModal 模式)— `src/modules/assets/components/VehicleDetailModal.tsx:194-236`
- [ ] 库存统计筛选弹层加遮罩点击关闭或改底部弹层 — `src/modules/assets/components/overview/InventoryStatisticsSection.tsx:85`
- [ ] 地图点击查询打临时 Marker 或二次确认,避免误点覆盖概览 — `src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx:94-103`
- [ ] 热力图 ToolBar/Scale 避免与底部详情面板重叠(改左上或随面板状态调整)— `src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx:88-89`
### 视觉一致性
- [ ] 次级文字对比度整改:定义对比度达标的语义色 token(slate-500 起步),全站替换 9–12px 的 `text-slate-400/300` 关键文字 — `src/components/ui/surface.tsx:137,181,269`、`src/components/Shell.tsx:122,154,186`
- [ ] 全站字号下限 10–11px,清掉 7–8px — `src/modules/mileage/monitoring/components/VehicleList.tsx:77-126`、`FullscreenMonitoring.tsx:159-318`、`MonitoringSummary.tsx:30-44`
- [ ] SearchSelect/MultiSearchSelect 统一到 slate 系 + `rounded-xl` + 统一 focus ring,尺寸默认值移入组件内部 — `src/components/SearchSelect.tsx:34,48`、`MultiSearchSelect.tsx:46`
- [ ] 资产域表格数字列统一右对齐 + `tabular-nums`,表头随列对齐 — `src/modules/assets/components/overview/AssetSummaryDesktopTable.tsx:22-34,87-192`、`InventoryDesktopTable.tsx:36,76`(对齐标准:`src/modules/ele/import-page/BatchTable.tsx:27-47`)
- [ ] 分组大表表头 sticky + 容器右缘渐变阴影提示可横滑 — `src/modules/assets/components/overview/AssetSummaryDesktopTable.tsx:16-17`
### 筛选/标签交互
- [ ] ActiveFilterTags 默认日期区间不进"已选条件"标签(区间降级为统计周期信息条)— `src/modules/mileage/monitoring/components/ActiveFilterTags.tsx:90-97`、`MonitoringView.tsx:68-69`
- [ ] `searchTerm` 死筛选:工具栏补搜索框,或删除该状态 — `src/modules/mileage/MonitoringView.tsx:43`、`query-model.ts:44`
- [ ] 筛选统一即时生效(防抖),删除 filterMileageRange/appliedMileageRange 双状态 — `src/modules/mileage/monitoring/components/FilterPanel.tsx:259-267`、`MonitoringView.tsx:64-65`
- [ ] 筛选 tag 清除热区扩至 24px+,tag 整片可点清除 — `src/modules/scheduling/scheduling-module/ActiveFilterTags.tsx:15-20`
- [ ] 多选模式补"全选当前筛选结果";不可选项加 `title="已干预,不可重复登记"` — `src/modules/scheduling/SuggestionList.tsx:85-103`
- [ ] 监控工具栏快速区间补"近7天" — `src/modules/mileage/monitoring/components/MonitoringToolbar.tsx:149-155`
### 小 Bug 集群(改动极小,顺手修)
- [ ] TargetDetailPanel:徽标硬编码"在线"改随 `isOnline`;ChevronDown 条件旋转;非法类名 `bg-slate-50/50/50` — `src/modules/mileage/statistics/TargetDetailPanel.tsx:90-95,213,216-218`
- [ ] 图表车型长名截断加 `title` 悬浮完整名,复用 shortTargetName 缩写规则 — `src/modules/mileage/daily-report/ReportCharts.tsx:111`、`GroupRow.tsx:94`
- [ ] 热力图默认日期兜底改为"近 30 天"由当前日期推导 — `src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx:9-10`、`src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx:9-10`
- [ ] 堆叠柱 radius 接缝缺口 — `src/modules/mileage/daily-report/ReportCharts.tsx:114-115`
- [ ] ReportDatePicker 补点击外部/Esc 关闭;日期加业务下界 — `src/modules/mileage/daily-report/ReportDatePicker.tsx:56-110`、`ReportHeader.tsx:45-53`
- [ ] FullscreenTargetTable 行 key 改业务值、补空态、刷新按钮转动反馈 — `src/modules/mileage/statistics/FullscreenTargetTable.tsx:49-54,86`
- [ ] 电能每日表月份分组行环比列留空问题;氢能每日图 Y 轴 `k` 与 Kg 混淆 — `src/modules/energy/ElectricDaily.tsx:124-153`、`HydrogenDaily.tsx:155`
- [ ] forceLandscape 监听 resize/orientationchange — `src/modules/mileage/MonitoringView.tsx:298-308`
- [ ] ReportInsights key 撞车改 index 或 id — `src/modules/mileage/daily-report/ReportInsights.tsx:26`
- [ ] DailyRangeControls 去掉 onChange+onInput 重复触发、加 max 限制未来日期 — `src/modules/energy/daily-range/DailyRangeControls.tsx:56-72`
- [ ] ETC 占位页里程碑与 meta 文案矛盾统一 — `src/modules/energy/EtcModule.tsx:12`、`ETCView.tsx:50`
- [ ] FeedbackHistoryDrawer 死 prop `onBack` 移除;截图链接补 `aria-label`;状态文案"已忽略"改"暂不处理" — `src/components/FeedbackHistoryDrawer.tsx:44,135-136`、`FeedbackFab.tsx:169`
- [ ] MultiSearchSelect 标签移除按钮补 `aria-label` — `src/components/MultiSearchSelect.tsx:40`
- [ ] SearchSelect 容器 onClick 与 input onFocus 双触发误关问题;清理不可达的 placeholder 分支 — `src/components/SearchSelect.tsx:35,40,45`
- [ ] FeedbackAdminPage 反馈列表 limit=200 静默截断:加分页或溢出说明;tab 计数改后端聚合 — `src/modules/admin/FeedbackAdminPage.tsx:79,124-171`
- [ ] VehicleDetailModal 周明细表补空态行;footer 计数口径区分"本周流转 n 台/共 n 台" — `src/modules/assets/components/VehicleDetailModal.tsx:158-178,267`
- [ ] BatchTable 补空态占位;上传成功后最新批次高亮或加"新"徽标 — `src/modules/ele/import-page/BatchTable.tsx:12`
- [ ] FlowDateRangePicker 超出 370 天日期直接置灰,而非事后报错 — `src/modules/assets/components/overview/FlowDateRangePicker.tsx:18-19,110`
- [ ] NotificationHistory 增加相对时间(x 小时/天前);待执行超 48h 加"滞留"警示徽标;"近7天"过滤改后端参数化 — `src/modules/scheduling/NotificationHistory.tsx:67-72,222-228`
- [ ] SuggestionDetail 当前车摘要 sticky;保留滚动条或滚动阴影 — `src/modules/scheduling/SuggestionDetail.tsx:145-169`
- [ ] FeedbackFab 改走统一 api-client(401 触发全局登出)— `src/components/FeedbackFab.tsx:16-25`(同类:`EleImportPage.tsx:23-33`、`FeedbackAdminPage.tsx:52-53`)
- [ ] 全局补 `:focus-visible` 兜底样式 — 目前仅 4 处模块组件有
- [ ] SegmentedNav 的 `layoutId` 加 `useId()` 前缀防同页撞车 — `src/components/ui/surface.tsx:202-243`
---
## P2 体系化改进(对应重构方案阶段 5/6)
- [ ] **设计令牌化**:`index.css` 用 Tailwind 4 `@theme` 定义语义 token(`--color-ink-secondary`、`--shadow-card`、`--shadow-nav`、`--color-surface-bg`),替换散落 arbitrary value;删除无人引用的 `--panel-bg`/`--hairline` — `src/index.css:3-7`、`src/components/ui/surface.tsx:41`、`src/App.tsx:49`、`src/components/Shell.tsx:103`
- [ ] **统一格式化层**:`fmtKm` 三套实现合并到 shared;TrendBadge 环比小数位统一 — `src/modules/mileage/daily-report/utils.ts:54`、`src/modules/mileage/statistics/model.ts:48`、`src/modules/energy/TrendBadge.tsx:21`
- [ ] **统一"今天"定义与日期区间预设**:日报按上海自然日 / 监控按本地 05:00 / 电能按本地自然日,收敛为一套 — `src/modules/mileage/daily-report/utils.ts:13-24`、`monitoring/date-range.ts:11-15`、`energy/daily-range/model.ts:22-39`
- [ ] **配置化硬编码**:高里程告警规则(写死车型名+800km)挪后端或配置常量 — `src/modules/mileage/MonitoringView.tsx:36-40`;站点横幅硬编码 — `src/modules/energy/ElectricOverview.tsx:50-52`
- [ ] **z-index 分层约定**:定义 `z-modal`/`z-overlay` 等 token,替换 `z-[1000]`/`z-[80]`/`z-[60]` 随意值 — `src/modules/assets/components/FlowDetailModal.tsx:33`、`VehicleDetailModal.tsx:47`、`src/modules/scheduling/SwapPreview.tsx:58`、`NotificationHistory.tsx:122,262`
- [ ] **错误呈现统一**:表单内联错误用统一 `FormError` 组件 + `role="alert"`,对齐 ErrorState — `src/components/feedback/FeedbackFormStep.tsx:145`、`FeedbackHistoryDrawer.tsx:107`
- [ ] **深色模式显式决策**:短期写进设计约定;中期按 令牌化 → Shell/surface → 图表 palette → 地图底图 顺序评估
- [ ] **导航扩容策略**:一级模块超过 5 个时收口为"更多"抽屉;分组首点直达第一个子模块 — `src/components/Shell.tsx:117,173,183`
- [ ] **PageFrame 拆分**:`hideHeader`/`compactInfo` 布尔分叉拆成 PageHeader 与 CompactHeader 两个组合 — `src/components/ui/surface.tsx:46-110`
- [ ] **死代码清理**:移除 69 处 no-op `<Blur>` 包裹与 `DemoModeProvider enabled={false}` — `src/components/Blur.tsx:13-16`、`Shell.tsx:93`
- [ ] **杂项**:`cn.ts` 换 clsx + tailwind-merge — `src/lib/cn.ts`;RotatingFooterHint keyframes 移入 index.css — `src/components/RotatingFooterHint.tsx:43-48`;UnauthorizedPage 英文 eyebrow 中文化 — `src/auth/UnauthorizedPage.tsx:10`;简化 index.css 双层滚动容器 — `src/index.css:9-26`;相对时间 `relativeNow` 5s 整表重渲染优化 — `src/modules/mileage/MonitoringView.tsx:131-134`;Top5 榜 Y 轴刻度与 width 解耦 — `src/modules/energy/hydrogen-overview/components/DistributionCharts.tsx:31-36,62`;饼图 >9 区域颜色循环失真 — 同文件 :15-19,109;水印时间戳随会话刷新 — `src/components/Shell.tsx:86-90`
---
## 已确认的正面实践(保持,勿回退)
- 氢能总览:骨架屏 + 非打断 RefreshOverlay(全站推广模板)
- VehicleTable 可点行:role/tabIndex/aria-pressed/键盘支持、吸顶首列 + 右侧阴影
- GroupRow 的 aria-expanded;日报日期写回 URL 可刷新恢复
- BatchTable 数字右对齐 + tabular-nums(资产域对齐目标)
- FlowDetailModal 移动端卡片模式(VehicleDetailModal 降级参考)
- 动效克制(layoutId 弹簧/FadeIn/shimmer 均为 transform/opacity 级)
---
## 建议实施顺序
| 阶段 | 内容 |
|---|---|
| 第 1 周 | P0 写操作反馈 / 上传工作流 / 数据可信 / 小 Bug 集群 |
| 第 2 周 | P0 可访问性(Modal 壳 + useScrollLock + 下拉组件)+ 加载范式统一 + 布局错位 |
| 第 3 周 | P1 移动端专项 + 视觉一致性 |
| 持续 | P2 令牌化 → 格式化/日期层 → 配置化(重构方案阶段 5/6) |
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>羚牛氢能</title>
<title>羚牛氢能车辆资产</title>
</head>
<body>
<div id="root"></div>
+80 -2048
View File
File diff suppressed because it is too large Load Diff
+2 -11
View File
@@ -1,7 +1,7 @@
{
"name": "ln-bi",
"private": true,
"version": "1.1.19",
"version": "1.1.5",
"type": "module",
"scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
@@ -9,26 +9,18 @@
"dev:client": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"start": "node --import tsx src/server/index.ts",
"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"
"lint": "tsc --noEmit"
},
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0",
"@types/jsonwebtoken": "^9.0.10",
"ali-oss": "^6.23.0",
"class-variance-authority": "^0.7.1",
"cn": "^0.4.0",
"dotenv": "^16.4.0",
"echarts": "^6.1.0",
"hono": "^4.7.0",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"mysql2": "^3.11.0",
"pg": "^8.22.0",
"radix-ui": "^1.6.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
@@ -39,7 +31,6 @@
"@tailwindcss/vite": "^4.1.14",
"@types/ali-oss": "^6.23.3",
"@types/node": "^22.14.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4",
@@ -1,78 +0,0 @@
import type { RowDataPacket } from 'mysql2';
import pool from '../src/server/db.js';
import type {
DailyMileageReport,
MileageReportTrendPoint,
} from '../src/shared/mileage/daily-report.js';
import { fetchOneOsMileageDates } from '../src/server/routes/mileage/oneos-api.js';
import { DEFAULT_ONEOS_PROTOCOL_PRIORITY } from '../src/server/routes/mileage/source-policy.js';
import { roundMileage } from '../src/server/routes/mileage/daily-report-model.js';
import { updateDailyReportSnapshotPayload } from '../src/server/routes/mileage/daily-report-store.js';
interface SnapshotRow extends RowDataPacket {
report_date: string;
source: string;
report_payload: DailyMileageReport | string;
}
function reportDates(reportDate: string): string[] {
const end = new Date(`${reportDate}T00:00:00Z`);
return Array.from({ length: 7 }, (_, index) => {
const date = new Date(end);
date.setUTCDate(end.getUTCDate() - (6 - index));
return date.toISOString().slice(0, 10);
});
}
function parseReport(value: DailyMileageReport | string): DailyMileageReport {
return typeof value === 'string' ? JSON.parse(value) as DailyMileageReport : value;
}
async function trendByPlate(report: DailyMileageReport): Promise<Map<string, MileageReportTrendPoint[]>> {
const dates = reportDates(report.reportDate);
const plates = report.groups.flatMap(group => group.vehicles.map(vehicle => vehicle.plate));
const rowsByDate = await fetchOneOsMileageDates(dates, plates, DEFAULT_ONEOS_PROTOCOL_PRIORITY);
const result = new Map<string, MileageReportTrendPoint[]>();
for (const plate of plates) {
result.set(plate, dates.map(date => {
const mileage = (rowsByDate.get(date) || [])
.filter(row => row.status === 'NORMAL' && row.plateNumber === plate)
.reduce((maximum, row) => Math.max(maximum, Number(row.dailyMileageKm) || 0), 0);
return { date, totalMileage: roundMileage(Math.max(0, mileage)) };
}));
}
return result;
}
const apply = process.argv.includes('--apply');
const [rows] = await pool.query<SnapshotRow[]>(`
SELECT DATE_FORMAT(report_date, '%Y-%m-%d') AS report_date, source, report_payload
FROM lingniu_prod.tab_mileage_daily_report
WHERE source <> 'XLSX_IMPORT'
ORDER BY report_date
`);
try {
for (const row of rows) {
const report = parseReport(row.report_payload);
if (report.groups.every(group => group.vehicles.every(vehicle => vehicle.trend?.length === 7))) {
console.log(`${row.report_date}: already complete`);
continue;
}
const trends = await trendByPlate(report);
const updated: DailyMileageReport = {
...report,
groups: report.groups.map(group => ({
...group,
vehicles: group.vehicles.map(vehicle => ({
...vehicle,
trend: trends.get(vehicle.plate) || [],
})),
})),
};
if (apply) await updateDailyReportSnapshotPayload(updated);
console.log(`${row.report_date}: ${apply ? 'updated' : 'would update'} ${trends.size} vehicles`);
}
} finally {
await pool.end();
}
-223
View File
@@ -1,223 +0,0 @@
import XLSX from 'xlsx';
import type { WorkBook } from 'xlsx';
import type { RowDataPacket } from 'mysql2';
import pool from '../src/server/db.js';
import type {
MileageReportBreakdown,
MileageReportGroup,
MileageReportVehicle,
} from '../src/shared/mileage/daily-report.js';
import {
buildDailyMileageReport,
mileageBand,
roundMileage,
} from '../src/server/routes/mileage/daily-report-model.js';
import { saveDailyReportSnapshot } from '../src/server/routes/mileage/daily-report-store.js';
interface TargetRow extends RowDataPacket { id: number; target_name: string }
interface ImportSpec {
date: string;
file: string;
}
const SHEET_SPECS = [
{ sheet: '40台普货', targetMatch: '交投40', summaryRow: 2, displayName: '40台普货' },
{ sheet: '190台冷藏车', targetMatch: '交投190', summaryRow: 3, displayName: '190台冷链' },
{ sheet: '136台冷藏车', targetMatch: '羚牛136', summaryRow: 4, displayName: '136台冷链' },
{ sheet: '100台双飞翼', targetMatch: '羚牛100', summaryRow: 5, displayName: '100台18T双飞翼' },
{ sheet: '50台普货', targetMatch: '恒运', summaryRow: 6, displayName: '26台恒运普货' },
] as const;
const INVENTORY_REGIONS: Record<string, Record<string, Record<string, number>>> = {
'2026-03-30': {
'40台普货': { 广东: 2 },
'190台冷链': { 嘉兴: 2, 广东: 38 },
'136台冷链': { 新疆: 1, 嘉兴: 13, 广东: 31 },
'100台18T双飞翼': { 嘉兴: 9, 广东: 29 },
'26台恒运普货': { 广东: 11, 嘉兴: 13 },
},
'2026-03-31': {
'40台普货': { 广东: 2 },
'190台冷链': { 嘉兴: 2, 广东: 36 },
'136台冷链': { 新疆: 1, 嘉兴: 13, 广东: 32 },
'100台18T双飞翼': { 嘉兴: 8, 广东: 29 },
'26台恒运普货': { 广东: 11, 嘉兴: 12 },
},
};
function parseArgs(values: string[]): ImportSpec[] {
return values.map(value => {
const split = value.indexOf(':');
if (split < 10) throw new Error(`参数格式应为 YYYY-MM-DD:/path/file.xlsx,收到 ${value}`);
const date = value.slice(0, split);
const file = value.slice(split + 1);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !file) throw new Error(`参数不合法:${value}`);
return { date, file };
});
}
function valueNumber(value: unknown): number {
return Number(value) || 0;
}
function sheetRows(workbook: WorkBook, name: string): unknown[][] {
const sheet = workbook.Sheets[name];
if (!sheet) throw new Error(`工作表不存在:${name}`);
return XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, raw: true, defval: null });
}
function trendDate(reportDate: string, label: unknown): string {
const match = /^(\d{1,2})\.(\d{1,2})日/.exec(String(label || ''));
if (!match) throw new Error(`无法识别趋势日期:${String(label)}`);
let year = Number(reportDate.slice(0, 4));
const candidate = `${year}-${String(Number(match[1])).padStart(2, '0')}-${String(Number(match[2])).padStart(2, '0')}`;
if (candidate > reportDate) year -= 1;
return `${year}-${String(Number(match[1])).padStart(2, '0')}-${String(Number(match[2])).padStart(2, '0')}`;
}
function breakdown(rows: MileageReportVehicle[], key: 'department'): MileageReportBreakdown[] {
const grouped = new Map<string, MileageReportVehicle[]>();
for (const row of rows) {
const name = row[key] || '未标注';
const items = grouped.get(name) || [];
items.push(row);
grouped.set(name, items);
}
return Array.from(grouped.entries()).map(([name, items]) => ({
name,
vehicleCount: items.length,
totalMileage: roundMileage(items.reduce((total, item) => total + item.dailyMileage, 0)),
averageMileage: items.length > 0
? roundMileage(items.reduce((total, item) => total + item.dailyMileage, 0) / items.length)
: 0,
})).sort((a, b) => b.vehicleCount - a.vehicleCount);
}
function inventoryBreakdown(date: string, groupName: string): MileageReportBreakdown[] {
return Object.entries(INVENTORY_REGIONS[date]?.[groupName] || {}).map(([name, count]) => ({
name,
vehicleCount: count,
totalMileage: 0,
averageMileage: 0,
}));
}
function generatedAt(date: string): string {
const next = new Date(`${date}T00:00:00Z`);
next.setUTCDate(next.getUTCDate() + 1);
return `${next.toISOString().slice(0, 10)}T09:30:00+08:00`;
}
async function importWorkbook(spec: ImportSpec, targets: TargetRow[]): Promise<void> {
const workbook = XLSX.readFile(spec.file, { cellDates: true });
const summary = sheetRows(workbook, '汇总');
const groups: MileageReportGroup[] = SHEET_SPECS.map(sheetSpec => {
const rows = sheetRows(workbook, sheetSpec.sheet);
const headerIndex = rows.findIndex(row => row[0] === '序号');
if (headerIndex < 0) throw new Error(`${sheetSpec.sheet} 未找到明细表头`);
const header = rows[headerIndex];
const currentDayIndex = header.findIndex(value => String(value || '').includes(`${Number(spec.date.slice(5, 7))}.${Number(spec.date.slice(8, 10))}日行驶里程`));
if (currentDayIndex < 0) throw new Error(`${sheetSpec.sheet} 未找到 ${spec.date} 日里程列`);
const trendStartIndex = currentDayIndex - 6;
const trendDates = header.slice(trendStartIndex, currentDayIndex + 1).map(label => trendDate(spec.date, label));
const detailRows = rows.slice(headerIndex + 1).filter(row => typeof row[0] === 'number');
const vehicles: MileageReportVehicle[] = detailRows.map(row => {
const rawStatus = String(row[1] || '在库');
const dailyMileage = roundMileage(valueNumber(row[currentDayIndex]));
const status = rawStatus === '租赁' || rawStatus === '自营' ? rawStatus : '在库';
return {
plate: String(row[2] || ''),
status,
customer: row[3] ? String(row[3]) : null,
department: status === '在库' || !row[4] ? null : String(row[4]),
inventoryRegion: null,
dailyMileage,
cumulativeMileage: roundMileage(valueNumber(row[13])),
completionRate: Math.round(valueNumber(row[17]) * 10000) / 100,
mileageBand: mileageBand(status, dailyMileage),
trend: trendDates.map((date, index) => ({
date,
totalMileage: roundMileage(valueNumber(row[trendStartIndex + index])),
})),
};
});
const operating = vehicles.filter(vehicle => vehicle.mileageBand !== 'INVENTORY');
const inventory = vehicles.filter(vehicle => vehicle.mileageBand === 'INVENTORY');
const summaryRow = summary[sheetSpec.summaryRow];
const target = targets.find(item => item.target_name.includes(sheetSpec.targetMatch));
if (!target) throw new Error(`未找到考核目标:${sheetSpec.targetMatch}`);
const periods = String(summaryRow[5] || '').split(/\n+|\s{2,}/).map(value => value.trim()).filter(Boolean);
const trend = trendDates.map((date, index) => ({
date,
totalMileage: roundMileage(detailRows.reduce((total, row) => total + valueNumber(row[trendStartIndex + index]), 0)),
}));
const dailyMileage = roundMileage(vehicles.reduce((total, vehicle) => total + vehicle.dailyMileage, 0));
const yesterdayMileage = trend[trend.length - 2]?.totalMileage || 0;
const currentYearTarget = roundMileage(valueNumber(summaryRow[10]));
const currentYearCompleted = roundMileage(valueNumber(summaryRow[11]));
return {
targetId: target.id,
targetName: target.target_name,
displayName: sheetSpec.displayName,
vehicleCount: vehicles.length,
operatingCount: operating.length,
inventoryCount: inventory.length,
dailyMileage,
operatingMileage: roundMileage(operating.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)),
inventoryMileage: roundMileage(inventory.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)),
yesterdayMileage,
dayOverDayDelta: 0,
dayOverDayRate: null,
lowMileageCount: operating.filter(vehicle => vehicle.mileageBand === 'LOW').length,
highMileageCount: operating.filter(vehicle => vehicle.mileageBand === 'HIGH').length,
cumulativeMileage: roundMileage(valueNumber(summaryRow[3])),
totalMileageTarget: roundMileage(valueNumber(summaryRow[2])),
completionRate: Math.round(valueNumber(summaryRow[4]) * 10000) / 100,
qualifiedCount: valueNumber(summaryRow[7]),
halfQualifiedCount: valueNumber(summaryRow[8]),
currentYearTarget,
currentYearCompleted,
remainingMileage: roundMileage(valueNumber(summaryRow[12])),
dailyRequiredMileage: roundMileage(valueNumber(summaryRow[14])),
assessmentPeriods: periods,
trend,
inventoryRegions: inventoryBreakdown(spec.date, sheetSpec.displayName),
departments: breakdown(operating, 'department'),
vehicles: vehicles.sort((a, b) => b.dailyMileage - a.dailyMileage),
};
});
const qualityNotes = [
{ level: 'info' as const, message: '历史日报由已审核人工台账导入,车辆规模与当日台账保持一致。' },
];
if (spec.date === '2026-03-30') {
qualityNotes.push({ level: 'info', message: '136台冷链库存按车辆明细修正为45台;100km车辆按低里程口径统计。' });
}
qualityNotes.push({ level: 'info', message: '190台冷链累计里程采用190辆明细求和及汇总表值,忽略明细页失效的手工合计单元格。' });
const report = buildDailyMileageReport({
reportDate: spec.date,
status: 'ARCHIVED',
source: 'XLSX_IMPORT',
generatedAt: generatedAt(spec.date),
sourceUpdatedAt: null,
groups,
qualityNotes,
});
const inserted = await saveDailyReportSnapshot(report, 'XLSX_IMPORT', { replace });
console.log(`${spec.date}: ${inserted ? (replace ? 'replaced' : 'imported') : 'already exists'}; ${report.totals.vehicleCount} vehicles; ${report.totals.dailyMileage} km`);
}
const args = process.argv.slice(2);
const replace = args.includes('--replace');
const specs = parseArgs(args.filter(value => value !== '--replace'));
if (specs.length === 0) throw new Error('请提供至少一个 YYYY-MM-DD:/path/file.xlsx 参数');
const [targets] = await pool.query<TargetRow[]>(
'SELECT id, target_name FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
);
try {
for (const spec of specs) await importWorkbook(spec, targets);
} finally {
await pool.end();
}
+55 -78
View File
@@ -1,65 +1,67 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { Shell } from "./components/Shell";
import AuthProvider from "./auth/AuthProvider";
import PermissionPreview, { PermissionPreviewPage } from "./auth/PermissionPreview";
import { useAuth } from "./auth/useAuth";
import UnauthorizedPage from "./auth/UnauthorizedPage";
import { canAccessEnergy } from "./shared/auth/roles";
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
import { buildModules } from "./app/modules";
import {
normalizeBrowserPath,
readBrowserRoute,
} from "./app/routing";
import { useEffect, useMemo, useState } from 'react';
import { Truck, Route, Activity, Zap } from 'lucide-react';
import { Shell, type ModuleConfig } from './components/Shell';
import AssetsModule from './modules/assets/AssetsModule';
import MileageModule from './modules/mileage/MileageModule';
import SchedulingModule from './modules/scheduling/SchedulingModule';
import EnergyModule from './modules/energy/EnergyModule';
import EleImportPage from './modules/ele/EleImportPage';
import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
import AuthProvider from './auth/AuthProvider';
import { useAuth } from './auth/useAuth';
import UnauthorizedPage from './auth/UnauthorizedPage';
import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles';
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
normalizeBrowserPath();
const BASE_MODULES: ModuleConfig[] = [
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
];
const ENERGY_MODULE: ModuleConfig = {
id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule,
};
const SCHEDULING_MODULE: ModuleConfig = {
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
};
function getRouteKey(): string {
if (typeof window === 'undefined') return '';
const path = window.location.pathname;
const hash = window.location.hash;
if (path === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') return 'ele/import';
if (path === '/admin/feedback' || hash === '#/admin/feedback' || hash === '#admin/feedback') return 'admin/feedback';
return '';
}
function AuthGate() {
const { isLoading, isAuthenticated, error, user } = useAuth();
const [route, setRoute] = useState(readBrowserRoute);
const { routeKey, pathSet } = route;
const [routeKey, setRouteKey] = useState(getRouteKey);
// 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效
// 监听 hashchange / popstate,让 a href="#/..." 跳转能即时生效
useEffect(() => {
const update = () => {
setRoute(readBrowserRoute());
};
window.addEventListener("hashchange", update);
window.addEventListener("popstate", update);
const update = () => setRouteKey(getRouteKey());
window.addEventListener('hashchange', update);
window.addEventListener('popstate', update);
return () => {
window.removeEventListener("hashchange", update);
window.removeEventListener("popstate", update);
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],
);
const modules = useMemo(() => {
const result = [...BASE_MODULES];
if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE);
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
return result;
}, [user?.roles]);
if (isLoading) {
return (
<div className="min-h-screen bg-[var(--app-bg)] p-4 text-slate-800 md:p-8">
<div className="mx-auto flex max-w-5xl flex-col gap-4">
<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">
<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 className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
<div className="text-center">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-xs text-slate-400 font-bold">正在验证身份...</p>
</div>
</div>
);
@@ -69,42 +71,17 @@ function AuthGate() {
return <UnauthorizedPage message={error || undefined} />;
}
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>
);
}
if (routeKey === 'ele/import') return <EleImportPage />;
if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
// /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>;
return <Shell modules={modules} />;
}
export default function App() {
return (
<AuthProvider>
<AuthenticatedWorkspace />
<AuthGate />
</AuthProvider>
);
}
-38
View File
@@ -1,38 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildModules } from './modules.js';
function moduleIds(roles?: string[]): string[] {
return buildModules('asset', roles).map(module => module.id);
}
function heatmapChildIds(roles?: string[]): string[] {
const heatmap = buildModules('asset', roles).find(module => module.id === 'heatmap');
return heatmap?.children?.map(module => module.id) ?? [];
}
test('keeps the base asset navigation and role-gated scheduling module', () => {
assert.deepEqual(moduleIds(), ['assets', 'mileage', 'heatmap']);
assert.deepEqual(moduleIds(['BI-SCHEDULE-OPT']), [
'assets',
'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'],
);
});
-72
View File
@@ -1,72 +0,0 @@
import { lazy } from 'react';
import {
Activity,
BatteryCharging,
Fuel,
MapPinned,
Receipt,
Route,
Truck,
} 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 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: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
];
const VEHICLE_HEATMAP_MODULE: ModuleConfig = {
id: 'vehicle-heatmap',
label: '车辆',
icon: MapPinned,
component: VehicleHeatmapModule,
};
const HYDROGEN_HEATMAP_MODULE: ModuleConfig = {
id: 'hydrogen-heatmap',
label: '加氢',
icon: MapPinned,
component: HydrogenHeatmapModule,
};
const SCHEDULING_MODULE: ModuleConfig = {
id: 'scheduling',
label: '智能调度',
icon: Activity,
component: SchedulingModule,
};
const ENERGY_MODULES: ModuleConfig[] = [
{ id: 'hydrogen', label: '氢能', icon: Fuel, component: HydrogenModule },
{ id: 'electric', label: '电能', icon: BatteryCharging, component: ElectricModule },
{ id: 'etc', label: 'ETC', icon: Receipt, component: EtcModule },
];
/** 根据主路径和角色生成导航;返回新数组,避免调用方修改静态注册表。 */
export function buildModules(
pathSet: PathSet,
roles: readonly string[] | null | undefined,
): ModuleConfig[] {
if (pathSet === 'energy') return [...ENERGY_MODULES];
const heatmapChildren = canAccessEnergy(roles)
? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE]
: [VEHICLE_HEATMAP_MODULE];
const modules: ModuleConfig[] = [
...ASSET_MODULES,
{ id: 'heatmap', label: '热力图', icon: MapPinned, children: heatmapChildren },
];
if (canAccessScheduling(roles)) modules.push(SCHEDULING_MODULE);
return modules;
}
-22
View File
@@ -1,22 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getPathSet, getRouteKey, resolveCanonicalUrl } from './routing.js';
test('normalizes legacy asset paths without losing search parameters', () => {
assert.equal(resolveCanonicalUrl({ pathname: '/mileage', search: '?date=2026-08-12', hash: '' }), '/asset?date=2026-08-12#mileage');
assert.equal(resolveCanonicalUrl({ pathname: '/unknown', search: '?x=1', hash: '#mileage' }), '/asset?x=1#mileage');
});
test('keeps canonical and hidden administration paths unchanged', () => {
assert.equal(resolveCanonicalUrl({ pathname: '/asset', search: '', hash: '#assets' }), null);
assert.equal(resolveCanonicalUrl({ pathname: '/admin/feedback', search: '', hash: '' }), null);
assert.equal(resolveCanonicalUrl({ pathname: '/who', search: '', hash: '' }), null);
});
test('derives path groups and hidden routes from path or hash', () => {
assert.equal(getPathSet('/energy'), 'energy');
assert.equal(getPathSet('/asset'), 'asset');
assert.equal(getRouteKey('/asset', '#/ele/import'), 'ele/import');
assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback');
assert.equal(getRouteKey('/who', ''), 'who');
assert.equal(getRouteKey('/asset', '#mileage'), '');
});
-57
View File
@@ -1,57 +0,0 @@
export type PathSet = 'asset' | 'energy';
interface BrowserLocation {
pathname: string;
search: string;
hash: string;
}
const ROOT_PATHS = new Set(['/asset', '/energy', '/ele/import', '/admin/feedback', '/who']);
const LEGACY_PATHS: Record<string, { path: string; hash?: string }> = {
'/': { path: '/asset' },
'/vehicle': { path: '/asset', hash: 'assets' },
'/assets': { path: '/asset', hash: 'assets' },
'/mileage': { path: '/asset', hash: 'mileage' },
'/scheduling': { path: '/asset', hash: 'scheduling' },
};
/** 返回旧地址应替换成的规范地址;主路径返回 null。 */
export function resolveCanonicalUrl(location: BrowserLocation): string | null {
if (ROOT_PATHS.has(location.pathname)) return null;
const target = LEGACY_PATHS[location.pathname] ?? { path: '/asset' };
const finalHash = target.hash ? `#${target.hash}` : location.hash;
return `${target.path}${location.search}${finalHash}`;
}
export function normalizeBrowserPath(): void {
if (typeof window === 'undefined') return;
const target = resolveCanonicalUrl(window.location);
if (target) window.history.replaceState(null, '', target);
}
export function getPathSet(pathname: string): PathSet {
return pathname === '/energy' ? 'energy' : 'asset';
}
export function getRouteKey(pathname: string, hash: string): string {
if (pathname === '/who') return 'who';
if (pathname === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') {
return 'ele/import';
}
if (
pathname === '/admin/feedback'
|| hash === '#/admin/feedback'
|| hash === '#admin/feedback'
) {
return 'admin/feedback';
}
return '';
}
export function readBrowserRoute(): { routeKey: string; pathSet: PathSet } {
return {
routeKey: getRouteKey(window.location.pathname, window.location.hash),
pathSet: getPathSet(window.location.pathname),
};
}
+49 -136
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, type ReactNode } from 'react';
import { AuthContext, type AuthState, type AuthUser } from './useAuth';
import { setTokenGetter, setPreviewTokenGetter, invalidateAuthenticatedRequests, fetchPreviewControl } from './api-client';
import { AuthContext, type AuthState } from './useAuth';
import { setTokenGetter } from './api-client';
const AUTH_API = '/api/auth';
@@ -13,147 +13,43 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
});
const tokenRef = useRef<string | null>(null);
const authStarted = useRef(false);
const previewTokenRef = useRef<string | null>(null);
const actorRef = useRef<AuthUser | null>(null);
const transitionRef = useRef(0);
const [identityVersion, setIdentityVersion] = useState(0);
const [preview, setPreview] = useState<{ user: AuthUser; expiresAt: string } | null>(null);
const [canPreview, setCanPreview] = useState(false);
const [previewNotice, setPreviewNotice] = useState<string | null>(null);
function resetPreview(message: string | null = null) {
transitionRef.current += 1;
previewTokenRef.current = null;
sessionStorage.removeItem('bi_preview_token');
invalidateAuthenticatedRequests();
setIdentityVersion(v => v + 1);
setPreview(null);
setPreviewNotice(message);
}
async function loadPreviewContext(user: AuthUser | null) {
actorRef.current = user;
try {
const context = await fetchPreviewControl<{ canPreview: boolean; actor: AuthUser }>('context');
actorRef.current = context.actor;
setCanPreview(context.canPreview);
if (context.canPreview) {
const savedPreview = sessionStorage.getItem('bi_preview_token');
if (savedPreview) {
previewTokenRef.current = savedPreview;
try {
const current = await fetchPreviewControl<{ user: AuthUser; expiresAt: string }>('current', undefined, true);
setPreview(current);
} catch {
resetPreview('上次权限视角已失效,已返回本人视角。');
}
}
} else {
resetPreview();
}
} catch {
resetPreview();
setCanPreview(false);
}
}
async function finishAuthentication(user: AuthUser | null) {
await loadPreviewContext(user);
if (!tokenRef.current) return;
setState({ isLoading: false, isAuthenticated: true, user: actorRef.current, error: null });
}
async function startPreview(userId: string) {
const transition = ++transitionRef.current;
const next = await fetchPreviewControl<{ previewToken: string; user: AuthUser; expiresAt: string }>('start', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }),
});
if (transition !== transitionRef.current) return;
invalidateAuthenticatedRequests();
previewTokenRef.current = next.previewToken;
sessionStorage.setItem('bi_preview_token', next.previewToken);
setPreview({ user: next.user, expiresAt: next.expiresAt });
setIdentityVersion(v => v + 1);
setPreviewNotice(null);
}
async function stopPreview() {
const stopping = fetchPreviewControl('stop', { method: 'POST' }, true);
resetPreview();
await stopping.catch(() => { setPreviewNotice('已退出本地预览;服务端凭证将自动到期。'); });
}
useEffect(() => {
if (!preview) return;
const token = previewTokenRef.current;
const validate = async () => {
try {
const current = await fetchPreviewControl<{ user: AuthUser; expiresAt: string }>('current', undefined, true);
if (token !== previewTokenRef.current) return;
if (JSON.stringify(current.user) !== JSON.stringify(preview.user)) {
invalidateAuthenticatedRequests();
setPreview(current);
setIdentityVersion(v => v + 1);
}
} catch {
if (token === previewTokenRef.current) resetPreview('权限视角验证失败,已返回本人视角,请重新选择。');
}
};
const timer = window.setInterval(validate, 60_000);
const expiry = window.setTimeout(() => resetPreview('权限视角已到期,已返回本人视角。'), Math.max(0, Date.parse(preview.expiresAt) - Date.now()));
window.addEventListener('focus', validate);
return () => { clearInterval(timer); clearTimeout(expiry); window.removeEventListener('focus', validate); };
}, [preview]);
useEffect(() => {
// 设置全局 token getter
setTokenGetter(() => tokenRef.current);
setPreviewTokenGetter(() => previewTokenRef.current);
// 防止 StrictMode 双重调用(jumpToken 一次性使用)
if (authStarted.current) return;
authStarted.current = true;
// 监听 401 事件
const onUnauthorized = () => {
resetPreview();
setCanPreview(false);
tokenRef.current = null;
sessionStorage.removeItem('bi_jwt');
sessionStorage.removeItem('bi_user');
actorRef.current = null;
setState({ isLoading: false, isAuthenticated: false, user: null, error: '会话已过期' });
};
window.addEventListener('auth:unauthorized', onUnauthorized);
const onPreviewEnded = (event: Event) => {
if ((event as CustomEvent).detail === 'PREVIEW_REVOKED') setCanPreview(false);
resetPreview('预览权限已失效,已返回本人视角。');
};
const onReadOnly = () => setPreviewNotice('权限视角仅供查看数据,请退出预览后进行修改操作。');
window.addEventListener('auth:preview-ended', onPreviewEnded);
window.addEventListener('auth:preview-readonly', onReadOnly);
// StrictMode 重新安装监听,但一次性 jumpToken 仅交换一次。
if (!authStarted.current) { authStarted.current = true; void authenticate(); }
return () => {
window.removeEventListener('auth:unauthorized', onUnauthorized);
window.removeEventListener('auth:preview-ended', onPreviewEnded);
window.removeEventListener('auth:preview-readonly', onReadOnly);
};
authenticate();
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
}, []);
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 });
setState({
isLoading: false,
isAuthenticated: true,
user: {
userId: 'dev-local',
userName: '本地开发',
permissionLevel: 'full',
depName: '',
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
},
error: null,
});
return;
}
@@ -161,20 +57,22 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
const savedToken = sessionStorage.getItem('bi_jwt');
if (savedToken) {
tokenRef.current = savedToken;
// 从受保护的身份接口验证 JWT;不使用本地缓存用户决定权限。
// 验证 token 是否仍然有效(尝试请求 health)
try {
const res = await fetch('/api/auth/me', {
const res = await fetch('/api/health', {
headers: { Authorization: `Bearer ${savedToken}` },
});
if (res.ok) {
const verifiedUser: AuthUser = await res.json();
if (!verifiedUser.userId) throw new Error('无效的用户身份');
await finishAuthentication(verifiedUser);
const savedUser = sessionStorage.getItem('bi_user');
setState({
isLoading: false,
isAuthenticated: true,
user: savedUser ? JSON.parse(savedUser) : null,
error: null,
});
return;
}
} catch { /* token 无效,继续流程 */ }
tokenRef.current = null;
resetPreview();
sessionStorage.removeItem('bi_jwt');
sessionStorage.removeItem('bi_user');
}
@@ -184,7 +82,19 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
const jumpToken = params.get('jumpToken');
if (!jumpToken) {
setState({ isLoading: false, isAuthenticated: false, user: null, error: '请从业务系统跳转访问' });
// 临时:本地开发免登录,含智能调度权限
setState({
isLoading: false,
isAuthenticated: true,
user: {
userId: '1105261382487539712',
userName: '本地调试',
permissionLevel: 'full',
depName: '',
roles: ['BI-SCHEDULE-OPT'],
},
error: null,
});
return;
}
@@ -210,16 +120,19 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
: `${window.location.pathname}${window.location.hash}`;
window.history.replaceState({}, '', cleanUrl);
await finishAuthentication(data.user);
setState({
isLoading: false,
isAuthenticated: true,
user: data.user,
error: null,
});
} catch (e) {
setState({ isLoading: false, isAuthenticated: false, user: null, error: '认证过程出错' });
}
}
return (
<AuthContext.Provider value={{ ...state, actor: actorRef.current || state.user,
user: preview?.user || state.user, preview, canPreview, identityVersion, previewNotice,
startPreview, stopPreview, clearPreviewNotice: () => setPreviewNotice(null) }}>
<AuthContext.Provider value={state}>
{children}
</AuthContext.Provider>
);
-31
View File
@@ -1,31 +0,0 @@
.permission-preview-banner{position:fixed;inset:0 0 auto;z-index:200;display:flex;align-items:center;gap:12px;min-height:56px;padding:9px 20px;background:#eff6ff;border-bottom:1px solid #adc8f4;color:#1e40af;font-size:12px;box-shadow:0 2px 8px #1d4ed10a}
.permission-preview-identity{flex:1;min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px}.permission-preview-identity strong{font-size:13px}.permission-preview-identity small{margin-left:14px;color:#64748b}
.permission-preview-banner button{border:1px solid #b9cef1;border-radius:6px;background:#fff;padding:6px 10px;white-space:nowrap}.permission-preview-banner button:hover{background:#dbeafe}
.permission-preview-active{padding-top:56px}.permission-preview-active .app-shell>nav.fixed.top-0{top:56px;height:calc(100% - 56px)}.permission-preview-active .app-shell-content>.fixed.top-0{top:56px}
.permission-preview-notice{position:fixed;top:64px;left:50%;transform:translateX(-50%);z-index:210;display:flex;align-items:center;gap:14px;padding:10px 14px;border:1px solid #f5d79e;border-radius:8px;background:#fff9ed;color:#92400e;font-size:12px;box-shadow:0 3px 12px #0f172a15;max-width:90vw}.permission-preview-notice button{flex-shrink:0}
.permission-preview-dialog{position:fixed;inset:0;margin:auto;width:min(700px,calc(100vw - 32px));max-height:calc(100dvh - 48px);padding:0;border:1px solid #d7e1ee;border-radius:14px;background:#fff;color:#334155;box-shadow:0 24px 90px #0f172a35;font-size:12px}
.permission-preview-dialog header{display:flex;justify-content:space-between;align-items:flex-start;padding:20px 22px 16px;border-bottom:1px solid #e1e8f0}.permission-preview-dialog h2{display:flex;align-items:center;gap:8px;margin:0;font-size:16px;font-weight:700;color:#1e293b}.permission-preview-dialog header p{margin:6px 0 0;color:#64748b}.permission-preview-dialog header button{padding:3px;color:#64748b}
.permission-preview-body{padding:18px 22px;overflow:auto;min-height:0}.permission-preview-guide{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.permission-preview-guide>div{padding:12px;border:1px solid #dce6f3;border-radius:8px;background:#f8fafc}.permission-preview-guide strong{display:block;color:#1e40af;font-size:12px}.permission-preview-guide code{display:block;margin-top:5px;font:11px ui-monospace,monospace;color:#64748b}.permission-preview-guide p{margin:7px 0 0;line-height:1.6;color:#64748b}.permission-preview-hint{margin:12px 0 16px;color:#64748b;font-size:11px;line-height:1.7}
.permission-preview-search{display:flex;gap:10px;margin-bottom:12px}.permission-preview-search label{display:flex;align-items:center;gap:8px;flex:1;border:1px solid #ced9e8;border-radius:7px;padding:0 10px;color:#94a3b8}.permission-preview-search input{width:100%;min-width:0;border:0;outline:0;padding:10px 0;color:#334155;background:transparent}.permission-preview-search label:focus-within{outline:2px solid #93b5ff;outline-offset:1px}.permission-preview-search select{border:1px solid #ced9e8;border-radius:7px;background:white;padding:8px;color:#475569}
.permission-preview-users{min-height:140px;border:1px solid #dce4ef;border-radius:8px;overflow:hidden}.permission-preview-users>label{display:flex;align-items:center;gap:10px;padding:11px 13px;border-bottom:1px solid #e5ebf3;cursor:pointer}.permission-preview-users>label:last-child{border-bottom:0}.permission-preview-users>label:hover{background:#f8faff}.permission-preview-users>label.is-selected{background:#eff6ff}.permission-preview-users input{accent-color:#2563eb}.permission-preview-users label>div{min-width:0;flex:1}.permission-preview-users strong{display:block;font-size:13px;color:#334155}.permission-preview-users span{display:block;font-size:11px;color:#64748b;margin-top:3px;overflow-wrap:anywhere}.permission-preview-users small{font-weight:400;font-size:10px;color:#2563eb}.permission-preview-users em{font-style:normal;padding:4px 7px;border-radius:5px;background:#f1f5f9;color:#475569;white-space:nowrap;font-size:11px}.permission-preview-empty{padding:35px 10px;text-align:center;color:#94a3b8}
.permission-preview-pages{display:flex;align-items:center;justify-content:flex-end;gap:12px;margin-top:12px;color:#64748b}.permission-preview-pages button{border:1px solid #dce4ef;padding:5px 9px;border-radius:5px}.permission-preview-dialog button:disabled{opacity:.45;cursor:not-allowed}.permission-preview-error{padding:9px 11px;margin:10px 0;background:#fff1f2;color:#be123c;border-radius:6px;display:flex;justify-content:space-between;gap:10px}.permission-preview-error button{text-decoration:underline}
.permission-preview-dialog footer{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 22px;border-top:1px solid #dce4ef;background:#f8fafc}.permission-preview-dialog footer strong{display:block;color:#334155;font-size:12px}.permission-preview-dialog footer span{display:block;color:#64748b;font-size:11px;margin-top:3px}.permission-preview-primary{padding:9px 13px;border-radius:7px;background:#2563eb;color:#fff;white-space:nowrap}.permission-preview-dialog button:focus-visible,.permission-preview-banner button:focus-visible{outline:2px solid #2563eb;outline-offset:3px}
@media(max-width:640px){.permission-preview-banner{gap:7px;padding:8px 10px;min-height:76px}.permission-preview-identity{display:block}.permission-preview-identity>span{display:block;margin-top:4px;font-size:10px}.permission-preview-identity small{display:block;margin:2px 0 0}.permission-preview-banner button{padding:6px;font-size:11px}.permission-preview-active{padding-top:76px}.permission-preview-notice{top:84px}.permission-preview-dialog{width:calc(100vw - 16px);max-height:calc(100dvh - 20px)}.permission-preview-body{padding:14px}.permission-preview-dialog header,.permission-preview-dialog footer{padding:14px}.permission-preview-guide{gap:6px}.permission-preview-guide>div{padding:8px}.permission-preview-guide code{font-size:9px}.permission-preview-guide p{font-size:10px}.permission-preview-search{gap:6px}.permission-preview-search select{max-width:116px}}
.permission-preview-active .asset-analysis-overlay,.permission-preview-active .bi-flow-modal-backdrop{top:56px}
@media(max-width:640px){.permission-preview-active .asset-analysis-overlay,.permission-preview-active .bi-flow-modal-backdrop{top:76px}}
.permission-preview-active .asset-analysis-dialog{min-height:0;max-height:calc(100dvh - 104px)}
.permission-preview-active .bi-flow-modal{max-height:calc(100dvh - 88px)}
@media(max-width:640px){.permission-preview-active .asset-analysis-dialog{max-height:calc(100dvh - 124px)}.permission-preview-active .bi-flow-modal{max-height:calc(100dvh - 108px)}}
.permission-preview-page{min-height:100dvh;padding:32px 20px;background:#f4f7fb;display:flex;align-items:flex-start;justify-content:center}
.permission-preview-active .permission-preview-page{min-height:calc(100dvh - 56px)}
.permission-preview-page .permission-preview-page-panel{position:static;inset:auto;display:flex;flex-direction:column;width:min(820px,100%);max-height:none;margin:0;border-color:#dce5f0;box-shadow:0 10px 35px #33415508}
.permission-preview-page-panel .permission-preview-body{overflow:visible}
.permission-preview-dialog header .permission-preview-back{display:inline-flex;align-items:center;justify-content:center;gap:7px;flex-shrink:0;min-height:44px;padding:10px 16px;border:1px solid #ced9e8;border-radius:8px;background:#f8fafc;font-size:14px;font-weight:600;color:#475569;white-space:nowrap;cursor:pointer;transition:background .15s,border-color .15s}.permission-preview-dialog header .permission-preview-back:hover:not(:disabled){background:#eff6ff;border-color:#adc8f4;color:#1d4ed8}
.permission-preview-denied{width:min(500px,100%);padding:36px;background:white;border:1px solid #dce5f0;border-radius:12px;text-align:center;color:#64748b}.permission-preview-denied>svg{margin:auto;color:#94a3b8}.permission-preview-denied h1{font-size:18px;color:#334155;margin:16px 0 8px}.permission-preview-denied p{font-size:13px}.permission-preview-denied a{display:inline-block;margin-top:20px;font-size:12px;color:#2563eb}
@media(max-width:640px){.permission-preview-page{padding:16px 10px}.permission-preview-active .permission-preview-page{min-height:calc(100dvh - 76px)}}
.permission-preview-page .permission-preview-page-panel{max-height:calc(100dvh - 64px)}
.permission-preview-page-panel .permission-preview-body{overflow:auto}
.permission-preview-page-panel>header,.permission-preview-page-panel>footer{flex-shrink:0}
.permission-preview-active .permission-preview-page-panel{max-height:calc(100dvh - 120px)}
@media(max-width:640px){.permission-preview-page .permission-preview-page-panel{max-height:calc(100dvh - 32px)}.permission-preview-active .permission-preview-page-panel{max-height:calc(100dvh - 108px)}}
-85
View File
@@ -1,85 +0,0 @@
import { useEffect, useState, type ReactNode } from 'react';
import { ArrowLeft, Eye, Search, ShieldCheck, X } from 'lucide-react';
import { fetchPreviewControl } from './api-client';
import { useAuth, type AuthUser } from './useAuth';
import './PermissionPreview.css';
const scopes = [
{ level: 'full', title: '全量数据', role: 'BI-Leader', detail: '查看全量业务数据。' },
{ level: 'department', title: '部门数据', role: 'BI-Leader-Dep', detail: '运营数据限本部门,库存查看全量。' },
{ level: 'personal', title: '本人数据', role: '其他角色默认', detail: '运营数据限本人负责,库存查看全量。' },
];
export function permissionScope(user: AuthUser) {
return scopes.find(s => s.level === user.permissionLevel)?.title || '本人数据';
}
export default function PermissionPreview({ children }: { children: ReactNode }) {
const { isAuthenticated, actor, preview, previewNotice, clearPreviewNotice, stopPreview } = useAuth();
return <div className={preview ? 'permission-preview-active' : undefined}>
{isAuthenticated && preview && <aside className="permission-preview-banner" aria-label="当前权限视角">
<Eye size={17} /><div className="permission-preview-identity"><strong>只读预览:{preview.user.userName}</strong><span>{preview.user.depName || '未分配部门'} · {permissionScope(preview.user)}<small>登录人:{actor?.userName}</small></span></div>
<button onClick={() => window.location.assign('/who')}>切换人员</button><button onClick={() => void stopPreview?.()}>退出预览</button>
</aside>}
{previewNotice && <div className="permission-preview-notice" role="status">{previewNotice}<button onClick={clearPreviewNotice} aria-label="关闭权限提示"><X size={14} /></button></div>}
{children}
</div>;
}
export function PermissionPreviewPage() {
const { canPreview } = useAuth();
return <main className="permission-preview-page">
{canPreview ? <PreviewPicker onClose={() => window.location.assign('/asset#assets')} /> : <section className="permission-preview-denied"><ShieldCheck size={28} /><h1>无权限访问</h1><p>当前账号未获授权使用权限视角。</p><a href="/asset#assets">返回 BI</a></section>}
</main>;
}
function PreviewPicker({ onClose }: { onClose: () => void }) {
const { startPreview, preview } = useAuth();
const [query, setQuery] = useState('');
const [level, setLevel] = useState('');
const [page, setPage] = useState(1);
const [users, setUsers] = useState<AuthUser[]>([]);
const [hasMore, setHasMore] = useState(false);
const [selected, setSelected] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [retry, setRetry] = useState(0);
const hasQuery = query.trim().length > 0;
useEffect(() => {
const controller = new AbortController();
setError(''); setUsers([]); setSelected(null); setHasMore(false);
if (!query.trim()) { setLoading(false); return; }
setLoading(true);
const timer = window.setTimeout(async () => {
try {
const params = new URLSearchParams({ q: query.trim(), page: String(page) });
if (level) params.set('level', level);
const result = await fetchPreviewControl<{ users: AuthUser[]; page: number; hasMore: boolean }>(`users?${params}`, { signal: controller.signal });
if (!controller.signal.aborted) { setUsers(result.users); setHasMore(result.hasMore); }
} catch (e) {
if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败');
} finally { if (!controller.signal.aborted) setLoading(false); }
}, 250);
return () => { clearTimeout(timer); controller.abort(); };
}, [query, level, page, retry]);
async function enter() {
if (!selected || busy) return;
setBusy(true); setError('');
try { await startPreview?.(selected.userId); onClose(); }
catch (e) { setError(e instanceof Error ? e.message : '切换失败,请重试'); setBusy(false); }
}
return <section className="permission-preview-dialog permission-preview-page-panel" aria-labelledby="permission-preview-title">
<header><div><h2 id="permission-preview-title"><ShieldCheck size={19} />权限视角</h2><p>选择人员,查看其实际页面权限与数据范围。</p></div><button disabled={busy} className="permission-preview-back" onClick={onClose}><ArrowLeft size={18} aria-hidden="true" />返回 BI</button></header>
<div className="permission-preview-body">
<div className="permission-preview-guide" aria-label="三类数据权限说明">{scopes.map(scope => <div key={scope.level}><strong>{scope.title}</strong><code>{scope.role}</code><p>{scope.detail}</p></div>)}</div>
<p className="permission-preview-hint">菜单和运营数据随目标用户权限变化;库存(含待交车、异动)统一展示全量,仍可按所属公司筛选。预览期间仅可查看,不能修改业务数据。</p>
<div className="permission-preview-search"><label><Search size={16} /><input autoFocus type="search" value={query} disabled={busy} onChange={e => { setQuery(e.target.value); setPage(1); }} placeholder="搜索中文姓名、用户名或部门" aria-label="搜索人员" /></label><select aria-label="数据范围筛选" value={level} disabled={busy} onChange={e => { setLevel(e.target.value); setPage(1); }}><option value="">全部数据范围</option>{scopes.map(s => <option key={s.level} value={s.level}>{s.title}</option>)}</select></div>
{error && <div className="permission-preview-error" role="alert">{error}<button disabled={busy} onClick={() => setRetry(v => v + 1)}>重试</button></div>}
{!hasQuery ? <p className="permission-preview-empty">输入中文姓名、用户名或部门关键词,模糊搜索人员。</p> : <div className="permission-preview-users" role="radiogroup" aria-label="选择预览用户" aria-busy={loading}>
{loading ? <p className="permission-preview-empty">正在加载人员…</p> : !users.length ? <p className="permission-preview-empty">未找到匹配人员,请调整搜索条件。</p> : users.map(user => <label key={user.userId} className={selected?.userId === user.userId ? 'is-selected' : ''}>
<input type="radio" name="preview-user" value={user.userId} checked={selected?.userId === user.userId} disabled={busy} onChange={() => setSelected(user)} />
<div><strong>{user.userName} {preview?.user.userId === user.userId && <small>当前视角</small>}</strong><span>{user.loginName || user.userId}</span></div><em>{permissionScope(user)}</em>
</label>)}
</div>}
{hasQuery && (hasMore || page > 1) && <div className="permission-preview-pages"><button disabled={page === 1 || loading || busy} onClick={() => setPage(v => v - 1)}>上一页</button><span>第 {page} 页</span><button disabled={!hasMore || loading || busy} onClick={() => setPage(v => v + 1)}>下一页</button></div>}
</div>
<footer><div>{selected ? <><strong>即将进入:{selected.userName}</strong><span>{permissionScope(selected)}</span></> : <span>请选择一位用户开始预览</span>}</div><button className="permission-preview-primary" disabled={!selected || busy || loading} onClick={() => void enter()}>{busy ? '正在切换…' : '进入该用户视角'}</button></footer>
</section>;
}
+13 -18
View File
@@ -1,24 +1,19 @@
import { ShieldX, Monitor, Smartphone } from 'lucide-react';
import { PageFrame, SurfaceCard } from '../components/ui/surface';
export default function UnauthorizedPage({ message }: { message?: string }) {
return (
<PageFrame
title="未授权访问"
subtitle={message || '获取用户认证信息失败,可能是跳转令牌已过期或无效。'}
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>
<p className="mb-4 text-[10px] font-bold uppercase tracking-wider text-slate-400">请通过以下方式进入</p>
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
<div className="text-center max-w-sm">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-slate-100 flex items-center justify-center">
<ShieldX size={36} className="text-slate-400" />
</div>
<div className="space-y-3">
<h1 className="text-lg font-black text-slate-800 mb-2">未授权访问</h1>
<p className="text-xs text-slate-400 mb-4">
{message || '获取用户认证信息失败,可能是跳转令牌已过期或无效'}
</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">
<Monitor size={16} className="text-blue-500 flex-shrink-0 mt-0.5" />
@@ -36,7 +31,7 @@ export default function UnauthorizedPage({ message }: { message?: string }) {
</div>
</div>
</div>
</SurfaceCard>
</PageFrame>
</div>
</div>
);
}
+18 -55
View File
@@ -1,61 +1,24 @@
/** 登录身份和预览身份分别传递;切换视角会取消旧视角的在途请求。 */
let tokenGetter: () => string | null = () => null;
let previewTokenGetter: () => string | null = () => null;
let identityAbort = new AbortController();
let identityVersion = 0;
export function setTokenGetter(fn: () => string | null) { tokenGetter = fn; }
export function setPreviewTokenGetter(fn: () => string | null) { previewTokenGetter = fn; }
export function invalidateAuthenticatedRequests() {
identityVersion += 1;
identityAbort.abort();
identityAbort = new AbortController();
}
const expiredPreviewCodes = new Set(['PREVIEW_EXPIRED', 'PREVIEW_REVOKED', 'PREVIEW_INVALID', 'PREVIEW_TARGET_UNAVAILABLE']);
/** 全局认证 fetch 客户端 */
async function authenticatedRequest(url: string, options: RequestInit | undefined, preview: boolean, business: boolean): Promise<Response> {
const version = identityVersion;
const headers = new Headers(options?.headers);
const token = tokenGetter();
if (token) headers.set('Authorization', `Bearer ${token}`);
headers.delete('X-BI-Preview');
const previewToken = preview ? previewTokenGetter() : null;
if (previewToken) headers.set('X-BI-Preview', previewToken);
const signal = business
? options?.signal ? AbortSignal.any([options.signal, identityAbort.signal]) : identityAbort.signal
: options?.signal;
const res = await fetch(url, { ...options, headers, signal });
if (business && version !== identityVersion) throw new DOMException('权限视角已切换', 'AbortError');
if (res.status === 401 || res.status === 403 || res.status === 410) {
const body = await res.clone().json().catch(() => ({}));
if (business && version !== identityVersion) throw new DOMException('权限视角已切换', 'AbortError');
if (expiredPreviewCodes.has(body.code)) {
// 已切换到其他人的旧响应不能结束新预览。
if (previewToken && previewToken === previewTokenGetter()) window.dispatchEvent(new CustomEvent('auth:preview-ended', { detail: body.code }));
throw new Error('权限视角已失效,请重新选择用户');
}
if (res.status === 401) {
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
throw new Error('Unauthorized');
}
if (body.code === 'PREVIEW_READ_ONLY') {
window.dispatchEvent(new CustomEvent('auth:preview-readonly'));
throw new Error('权限视角为只读,请退出预览后操作');
}
}
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;
let tokenGetter: () => string | null = () => null;
export function setTokenGetter(fn: () => string | null) {
tokenGetter = fn;
}
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetchAuthenticated(url, options);
const token = tokenGetter();
const res = await fetch(url, {
...options,
headers: {
...options?.headers,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (res.status === 401) {
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
throw new Error('Unauthorized');
}
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
return res.json();
}
+18 -20
View File
@@ -1,27 +1,25 @@
import { createContext, useContext } from 'react';
export interface AuthUser {
userId: string;
userName: string;
loginName?: string;
permissionLevel: string;
depCode?: string;
depName: string;
roles?: string[];
}
export interface AuthState {
isLoading: boolean;
isAuthenticated: boolean;
user: AuthUser | null;
user: {
userId: string;
userName: string;
permissionLevel: string;
depName: string;
roles?: string[];
} | null;
error: string | null;
actor?: AuthUser | null;
canPreview?: boolean;
preview?: { user: AuthUser; expiresAt: string } | null;
identityVersion?: number;
previewNotice?: string | null;
startPreview?: (userId: string) => Promise<void>;
stopPreview?: () => Promise<void>;
clearPreviewNotice?: () => void;
}
export const AuthContext = createContext<AuthState>({ isLoading: true, isAuthenticated: false, user: null, error: null });
export function useAuth() { return useContext(AuthContext); }
export const AuthContext = createContext<AuthState>({
isLoading: true,
isAuthenticated: false,
user: null,
error: null,
});
export function useAuth() {
return useContext(AuthContext);
}
+356 -40
View File
@@ -1,20 +1,31 @@
import { useEffect, useRef, useState } from 'react';
import { fetchJson, fetchAuthenticated } from '../auth/api-client';
import { motion, AnimatePresence } from 'motion/react';
import {
MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles,
ImagePlus, Loader2, Inbox, Lightbulb, Bug, Palette, NotebookPen, Settings2,
type LucideIcon,
} from 'lucide-react';
import { fetchJson } from '../auth/api-client';
import { useAuth } from '../auth/useAuth';
import { 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';
import RotatingFooterHint from './RotatingFooterHint';
const MAX_SCREENSHOTS = 6;
const MAX_IMG_SIZE_MB = 5;
interface UploadedImg {
url: string;
thumbDataUrl: string;
}
async function uploadImage(file: File): Promise<string> {
const fd = new FormData();
fd.append('file', file);
const res = await fetchAuthenticated('/api/feedback/upload', {
const token = sessionStorage.getItem('bi_jwt');
const res = await fetch('/api/feedback/upload', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
});
const json = await res.json();
@@ -31,6 +42,38 @@ function readAsDataUrl(file: File): Promise<string> {
});
}
type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
interface TypeOption {
key: FeedbackType;
icon: LucideIcon;
label: string;
sub: string;
iconBg: string;
iconFg: string;
ring: string;
}
const TYPE_OPTIONS: TypeOption[] = [
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
];
const MODULE_LABELS: Record<string, string> = {
assets: '资产管理',
mileage: '里程管理',
energy: '能源管理',
scheduling: '智能调度',
ele: '充电导入',
'': '通用',
};
function detectModule(): string {
const hash = (window.location.hash || '').slice(1);
const path = window.location.pathname;
@@ -51,7 +94,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
const [open, setOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [step, setStep] = useState<FeedbackStep>(1);
const [step, setStep] = useState<1 | 2 | 3>(1); // 1=选类型, 2=写内容, 3=成功页
const [type, setType] = useState<FeedbackType | null>(null);
const [mod, setMod] = useState<string>('');
const [content, setContent] = useState('');
@@ -85,12 +128,14 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
}
};
// Open: detect current module
useEffect(() => {
if (open && step === 1) {
setMod(moduleProp ?? detectModule());
}
}, [open, step, moduleProp]);
// Lock scroll when open
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
@@ -108,7 +153,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
const close = () => {
setOpen(false);
setTimeout(reset, 300);
setTimeout(reset, 300); // 等动画完
};
const submit = async () => {
@@ -148,48 +193,319 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
}
};
const back = () => setStep((Math.max(1, step - 1)) as FeedbackStep);
const back = () => setStep((Math.max(1, step - 1)) as typeof step);
const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true;
const 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)}
/>
{/* Floating Action Button */}
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.92 }}
onClick={() => setMenuOpen(m => !m)}
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
aria-label="反馈"
title="提建议 / 我的反馈"
>
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
</motion.button>
<AnimatePresence>
{menuOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 8 }}
transition={{ duration: 0.15 }}
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
>
<button
onClick={() => { setMenuOpen(false); setOpen(true); }}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
>
<Sparkles size={14} className="text-blue-500" /> 提个建议
</button>
<button
onClick={() => { setMenuOpen(false); setHistoryOpen(true); }}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
>
<Inbox size={14} className="text-emerald-500" /> 我的反馈
</button>
{isAdmin && (
<>
<div className="h-px bg-slate-100 my-0.5" />
<a
href="#/admin/feedback"
onClick={() => setMenuOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
>
<Settings2 size={14} className="text-violet-500" /> 反馈管理
</a>
</>
)}
</motion.div>
)}
</AnimatePresence>
{/* 点击外面关菜单 */}
{menuOpen && (
<div
className="fixed inset-0 z-[-1]"
onClick={() => setMenuOpen(false)}
/>
)}
</div>
<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); }}
/>
{/* Modal */}
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
// 不允许点击背景关闭:避免用户输入到一半误触遮罩丢失内容
>
<motion.div
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Drag handle */}
<div className="flex justify-center pt-2.5 pb-1">
<div className="w-10 h-1 rounded-full bg-slate-300" />
</div>
{/* Header + progress */}
<div className="px-4 pb-3 border-b border-slate-100">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
<Sparkles size={14} className="text-white" />
</div>
<div>
<div className="text-sm font-black text-slate-800 leading-tight">
{step === 3 ? '收到啦~' : '提个建议'}
</div>
<div className="text-[10px] text-slate-400 font-bold">
{step === 1 && '第一步 / 共 2 步'}
{step === 2 && '第二步 / 共 2 步'}
{step === 3 && '感谢你的反馈'}
</div>
</div>
</div>
<button onClick={close} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
<X size={18} />
</button>
</div>
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={false}
animate={{ width: `${progress}%` }}
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
/>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-4 py-4">
<AnimatePresence mode="wait">
{step === 1 && (
<motion.div key="s1" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }}>
<p className="text-[13px] font-bold text-slate-700 mb-1.5">想反馈点什么?</p>
<RotatingFooterHint className="justify-start mb-4" />
<div className="grid grid-cols-1 gap-2">
{TYPE_OPTIONS.map((opt, i) => {
const Icon = opt.icon;
const selected = type === opt.key;
return (
<motion.button
key={opt.key}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04, duration: 0.2 }}
whileHover={{ y: -1 }}
whileTap={{ scale: 0.99 }}
onClick={() => { setType(opt.key); setStep(2); }}
className={`text-left p-3.5 rounded-2xl border bg-white transition-all flex items-center gap-3 group ${selected ? `ring-2 ${opt.ring} border-transparent shadow-sm` : 'border-slate-100 hover:border-slate-200 hover:shadow-sm'}`}
>
<div className={`w-10 h-10 rounded-xl ${opt.iconBg} flex items-center justify-center flex-shrink-0`}>
<Icon size={18} className={opt.iconFg} strokeWidth={2.2} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-bold text-slate-800 leading-tight">{opt.label}</div>
<div className="text-[11px] text-slate-400 font-medium mt-0.5">{opt.sub}</div>
</div>
<ChevronRight size={15} className="text-slate-300 flex-shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
</motion.button>
);
})}
</div>
</motion.div>
)}
{step === 2 && (
<motion.div key="s2" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }} className="space-y-3">
<div>
<p className="text-[12px] font-bold text-slate-600 mb-2">说说具体内容</p>
<textarea
ref={taRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
const imgs: File[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === 'file' && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) imgs.push(f);
}
}
if (imgs.length > 0) {
e.preventDefault();
addFiles(imgs);
}
}}
autoFocus
rows={5}
maxLength={1000}
placeholder={
type === 'dimension' ? '比如:希望按客户/区域/日期范围 等等切片看里程数据…'
: type === 'bug' ? '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)'
: type === 'ux' ? '比如:能不能把外部 tab 默认收起,加载快一点…'
: '随便聊聊你的想法'
}
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
/>
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
</div>
{/* 截图上传 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-bold text-slate-400 uppercase">截图(可选)</p>
<span className="text-[10px] text-slate-300 font-bold">{shots.length}/{MAX_SCREENSHOTS},可粘贴</span>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
/>
<div className="flex flex-wrap gap-1.5">
{shots.map((s, i) => (
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
<button
onClick={() => setShots(prev => prev.filter((_, idx) => idx !== i))}
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
>
<X size={10} />
</button>
</div>
))}
{shots.length < MAX_SCREENSHOTS && (
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
>
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
</button>
)}
</div>
</div>
<div>
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">所在板块</p>
<div className="flex gap-1 flex-wrap">
{Object.entries(MODULE_LABELS).map(([k, label]) => (
<button
key={k}
onClick={() => setMod(k)}
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${mod === k ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
>
{label}
</button>
))}
</div>
</div>
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
</motion.div>
)}
{step === 3 && (
<motion.div key="s3" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.3 }} className="text-center py-6">
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
>
<Check size={28} strokeWidth={3} className="text-white" />
</motion.div>
<div className="text-base font-black text-slate-800 mb-1">谢谢你的反馈 ❤️</div>
<div className="text-[12px] text-slate-500 font-bold leading-relaxed">产品同学会认真看每一条<br />有进展可以在「我的反馈」里查看</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Footer */}
{step < 3 && (
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
{step > 1 && (
<button
onClick={back}
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
>
<ChevronLeft size={14} /> 上一步
</button>
)}
<div className="flex-1" />
<button
onClick={next}
disabled={!canNext || submitting}
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
>
{submitting ? '提交中…' : step === 2 ? '提交' : '下一步'}
{!submitting && step !== 2 && <ChevronRight size={14} />}
</button>
</div>
)}
{step === 3 && (
<div className="px-4 py-3 border-t border-slate-100">
<button
onClick={close}
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
>
完成
</button>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}
+61 -142
View File
@@ -1,87 +1,71 @@
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
import { motion } from 'motion/react';
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
import { useState, useEffect, useMemo, type ComponentType } from 'react';
import { useAuth } from '../auth/useAuth';
import { DemoModeProvider } from './Blur';
import { cn } from '../lib/cn';
import { LoadingState } from './ui/surface';
import FeedbackFab from './FeedbackFab';
export interface ModuleConfig {
id: string;
label: string;
icon: ComponentType<{ size?: number; className?: string }>;
component?: ElementType;
children?: ModuleConfig[];
component: ComponentType;
}
function flattenModules(modules: ModuleConfig[]): ModuleConfig[] {
return modules.flatMap((module) => module.children ?? [module]);
}
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id) */
function getHashHead(): string {
return window.location.hash.slice(1).split('/')[0];
}
/** path 到模块 id 的映射 */
const PATH_MAP: Record<string, string> = {
'/vehicle': 'assets',
'/assets': 'assets',
'/mileage': 'mileage',
'/scheduling': 'scheduling',
'/energy': 'energy',
};
function getInitialModule(modules: ModuleConfig[]): string {
const head = getHashHead();
if (modules.some((m) => m.id === head)) return head;
// 优先看 hash
const hash = window.location.hash.slice(1);
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 ?? '';
}
function getHashModule(modules: ModuleConfig[]): string {
const head = getHashHead();
return modules.some((m) => m.id === head) ? head : '';
const hash = window.location.hash.slice(1);
return modules.some((m) => m.id === hash) ? hash : '';
}
export function Shell({ modules }: { modules: ModuleConfig[] }) {
const flatModules = useMemo(() => flattenModules(modules), [modules]);
const [activeModule, setActiveModule] = useState(() => getInitialModule(flattenModules(modules)));
const [openGroupId, setOpenGroupId] = useState<string | null>(null);
const [activeModule, setActiveModule] = useState(() => getInitialModule(modules));
useEffect(() => {
const onHashChange = () => {
const h = getHashModule(flatModules);
const h = getHashModule(modules);
if (h) setActiveModule(h);
};
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, [flatModules]);
}, [modules]);
useEffect(() => {
if (!openGroupId) return undefined;
const handleKeyDown = (event: KeyboardEvent) => {
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) {
// 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
if (window.location.hash.slice(1) !== activeModule) {
const { pathname, search } = window.location;
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
}
}, [activeModule]);
const switchModule = (id: string) => {
setOpenGroupId(null);
if (getHashHead() === id) {
setActiveModule(id);
return;
}
if (window.location.hash.slice(1) === id) return;
const { pathname, search } = window.location;
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
setActiveModule(id);
};
const ActiveComponent = flatModules.find((module) => module.id === activeModule)?.component ?? flatModules[0]?.component;
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
const { user } = useAuth();
const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
const watermarkText = useMemo(() => {
const name = user?.userName || '未登录';
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
@@ -90,7 +74,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
return (
<DemoModeProvider enabled={false}>
<div className="app-shell enterprise-grid-bg flex min-h-screen">
<div className="flex min-h-screen">
{/* 全局水印 */}
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
<div className="absolute inset-0" style={{
@@ -99,114 +83,49 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
}} />
</div>
{/* Web 侧边栏 (md 及以上) */}
<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) => {
const Icon = m.icon;
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 (
<div key={m.id} className="relative">
<button
type="button"
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
aria-expanded={isGroup ? isOpen : undefined}
aria-haspopup={isGroup ? 'menu' : undefined}
className={cn(
'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}
>
{isActive ? (
<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>
);
})}
</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 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">
{modules.map((m) => {
const Icon = m.icon;
const isActive = m.id === activeModule;
return (
<button
key={m.id}
onClick={() => switchModule(m.id)}
className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
isActive
? 'bg-blue-50 text-blue-600'
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
}`}
>
<Icon size={22} />
<span className="text-[10px] mt-1 leading-tight">{m.label}</span>
</button>
);
})}
</nav>
{/* 内容区 */}
<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 />}
</Suspense>
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
{ActiveComponent && <ActiveComponent />}
<FeedbackFab module={activeModule} />
</main>
{/* 移动端底部导航 (md 以下) */}
<nav className="app-mobile-nav fixed inset-x-0 bottom-0 z-50 border-t border-white/70 bg-white/92 px-3 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-18px_40px_rgba(15,23,42,0.08)] backdrop-blur-xl md:hidden">
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}>
<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">
{modules.map((m) => {
const Icon = m.icon;
const isGroup = Boolean(m.children?.length);
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
const isOpen = openGroupId === m.id;
const isActive = m.id === activeModule;
return (
<div key={m.id} className="relative flex justify-center">
<button
type="button"
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
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')}
>
{isActive ? (
<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>
);
})}
</div>
) : null}
</div>
<button
key={m.id}
onClick={() => switchModule(m.id)}
className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
>
<Icon size={20} />
<span className="text-[10px] mt-1">{m.label}</span>
</button>
);
})}
</div>
</nav>
</div>
</DemoModeProvider>
@@ -1,148 +0,0 @@
import type { RefObject } from 'react';
import { motion } from 'motion/react';
import { ImagePlus, Loader2, X } from 'lucide-react';
import type { FeedbackType, UploadedImg } from './types';
const MODULE_LABELS: Record<string, string> = {
assets: '资产管理',
mileage: '里程管理',
energy: '能源管理',
scheduling: '智能调度',
ele: '充电导入',
'': '通用',
};
function getPlaceholder(type: FeedbackType | null): string {
if (type === 'dimension') return '比如:希望按客户/区域/日期范围 等等切片看里程数据…';
if (type === 'bug') return '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)';
if (type === 'ux') return '比如:能不能把外部 tab 默认收起,加载快一点…';
return '随便聊聊你的想法';
}
interface Props {
content: string;
error: string | null;
fileRef: RefObject<HTMLInputElement | null>;
maxScreenshots: number;
module: string;
screenshots: UploadedImg[];
textareaRef: RefObject<HTMLTextAreaElement | null>;
type: FeedbackType | null;
uploading: boolean;
onAddFiles: (files: FileList | File[]) => void | Promise<void>;
onChangeContent: (content: string) => void;
onChangeModule: (module: string) => void;
onRemoveScreenshot: (index: number) => void;
}
export default function FeedbackFormStep({
content,
error,
fileRef,
maxScreenshots,
module,
screenshots,
textareaRef,
type,
uploading,
onAddFiles,
onChangeContent,
onChangeModule,
onRemoveScreenshot,
}: Props) {
return (
<motion.div
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -12 }}
transition={{ duration: 0.2 }}
className="space-y-3"
>
<div>
<p className="text-[12px] font-bold text-slate-600 mb-2">说说具体内容</p>
<textarea
ref={textareaRef}
value={content}
onChange={(e) => onChangeContent(e.target.value)}
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
const imgs: File[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === 'file' && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) imgs.push(f);
}
}
if (imgs.length > 0) {
e.preventDefault();
onAddFiles(imgs);
}
}}
autoFocus
rows={5}
maxLength={1000}
placeholder={getPlaceholder(type)}
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
/>
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-bold text-slate-400 uppercase">截图(可选)</p>
<span className="text-[10px] text-slate-300 font-bold">{screenshots.length}/{maxScreenshots},可粘贴</span>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => { if (e.target.files) onAddFiles(e.target.files); e.target.value = ''; }}
/>
<div className="flex flex-wrap gap-1.5">
{screenshots.map((s, i) => (
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
<button
onClick={() => onRemoveScreenshot(i)}
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
>
<X size={10} />
</button>
</div>
))}
{screenshots.length < maxScreenshots && (
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
>
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
</button>
)}
</div>
</div>
<div>
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">所在板块</p>
<div className="flex gap-1 flex-wrap">
{Object.entries(MODULE_LABELS).map(([key, label]) => (
<button
key={key}
onClick={() => onChangeModule(key)}
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${module === key ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
>
{label}
</button>
))}
</div>
</div>
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
</motion.div>
);
}
@@ -1,88 +0,0 @@
import { AnimatePresence, motion } from 'motion/react';
import {
Inbox,
MessageCircleHeart,
Settings2,
Sparkles,
} from 'lucide-react';
interface Props {
isAdmin: boolean;
menuOpen: boolean;
onCloseMenu: () => void;
onOpenFeedback: () => void;
onOpenHistory: () => void;
onToggleMenu: () => void;
}
export default function FeedbackLauncher({
isAdmin,
menuOpen,
onCloseMenu,
onOpenFeedback,
onOpenHistory,
onToggleMenu,
}: Props) {
return (
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
<motion.button
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.92 }}
onClick={onToggleMenu}
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
aria-label="反馈"
title="提建议 / 我的反馈"
>
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
</motion.button>
<AnimatePresence>
{menuOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 8 }}
transition={{ duration: 0.15 }}
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
>
<button
onClick={onOpenFeedback}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
>
<Sparkles size={14} className="text-blue-500" /> 提个建议
</button>
<button
onClick={onOpenHistory}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
>
<Inbox size={14} className="text-emerald-500" /> 我的反馈
</button>
{isAdmin && (
<>
<div className="h-px bg-slate-100 my-0.5" />
<a
href="#/admin/feedback"
onClick={onCloseMenu}
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
>
<Settings2 size={14} className="text-violet-500" /> 反馈管理
</a>
</>
)}
</motion.div>
)}
</AnimatePresence>
{menuOpen && (
<div
className="fixed inset-0 z-[-1]"
onClick={onCloseMenu}
/>
)}
</div>
);
}
-177
View File
@@ -1,177 +0,0 @@
import type { RefObject } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { ChevronLeft, ChevronRight, Sparkles, X } from 'lucide-react';
import FeedbackFormStep from './FeedbackFormStep';
import FeedbackSuccessStep from './FeedbackSuccessStep';
import FeedbackTypeStep from './FeedbackTypeStep';
import type { FeedbackStep, FeedbackType, UploadedImg } from './types';
interface Props {
canNext: boolean;
content: string;
error: string | null;
fileRef: RefObject<HTMLInputElement | null>;
maxScreenshots: number;
module: string;
open: boolean;
progress: number;
screenshots: UploadedImg[];
step: FeedbackStep;
submitting: boolean;
textareaRef: RefObject<HTMLTextAreaElement | null>;
type: FeedbackType | null;
uploading: boolean;
onAddFiles: (files: FileList | File[]) => void | Promise<void>;
onBack: () => void;
onChangeContent: (content: string) => void;
onChangeModule: (module: string) => void;
onClose: () => void;
onNext: () => void;
onRemoveScreenshot: (index: number) => void;
onSelectType: (type: FeedbackType) => void;
}
export default function FeedbackModal({
canNext,
content,
error,
fileRef,
maxScreenshots,
module,
open,
progress,
screenshots,
step,
submitting,
textareaRef,
type,
uploading,
onAddFiles,
onBack,
onChangeContent,
onChangeModule,
onClose,
onNext,
onRemoveScreenshot,
onSelectType,
}: Props) {
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
>
<motion.div
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-center pt-2.5 pb-1">
<div className="w-10 h-1 rounded-full bg-slate-300" />
</div>
<div className="px-4 pb-3 border-b border-slate-100">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
<Sparkles size={14} className="text-white" />
</div>
<div>
<div className="text-sm font-black text-slate-800 leading-tight">
{step === 3 ? '收到啦~' : '提个建议'}
</div>
<div className="text-[10px] text-slate-400 font-bold">
{step === 1 && '第一步 / 共 2 步'}
{step === 2 && '第二步 / 共 2 步'}
{step === 3 && '感谢你的反馈'}
</div>
</div>
</div>
<button onClick={onClose} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
<X size={18} />
</button>
</div>
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={false}
animate={{ width: `${progress}%` }}
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
<AnimatePresence mode="wait">
{step === 1 && (
<FeedbackTypeStep
key="s1"
selectedType={type}
onSelect={onSelectType}
/>
)}
{step === 2 && (
<FeedbackFormStep
key="s2"
content={content}
error={error}
fileRef={fileRef}
maxScreenshots={maxScreenshots}
module={module}
screenshots={screenshots}
textareaRef={textareaRef}
type={type}
uploading={uploading}
onAddFiles={onAddFiles}
onChangeContent={onChangeContent}
onChangeModule={onChangeModule}
onRemoveScreenshot={onRemoveScreenshot}
/>
)}
{step === 3 && <FeedbackSuccessStep key="s3" />}
</AnimatePresence>
</div>
{step < 3 && (
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
{step > 1 && (
<button
onClick={onBack}
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
>
<ChevronLeft size={14} /> 上一步
</button>
)}
<div className="flex-1" />
<button
onClick={onNext}
disabled={!canNext || submitting}
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
>
{submitting ? '提交中…' : step === 2 ? '提交' : '下一步'}
{!submitting && step !== 2 && <ChevronRight size={14} />}
</button>
</div>
)}
{step === 3 && (
<div className="px-4 py-3 border-t border-slate-100">
<button
onClick={onClose}
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
>
完成
</button>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -1,24 +0,0 @@
import { motion } from 'motion/react';
import { Check } from 'lucide-react';
export default function FeedbackSuccessStep() {
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
className="text-center py-6"
>
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
>
<Check size={28} strokeWidth={3} className="text-white" />
</motion.div>
<div className="text-base font-black text-slate-800 mb-1">谢谢你的反馈 ❤️</div>
<div className="text-[12px] text-slate-500 font-bold leading-relaxed">产品同学会认真看每一条<br />有进展可以在「我的反馈」里查看</div>
</motion.div>
);
}
@@ -1,78 +0,0 @@
import { motion } from 'motion/react';
import {
Bug,
ChevronRight,
Lightbulb,
NotebookPen,
Palette,
type LucideIcon,
} from 'lucide-react';
import RotatingFooterHint from '../RotatingFooterHint';
import type { FeedbackType } from './types';
interface TypeOption {
key: FeedbackType;
icon: LucideIcon;
label: string;
sub: string;
iconBg: string;
iconFg: string;
ring: string;
}
const TYPE_OPTIONS: TypeOption[] = [
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
];
interface Props {
selectedType: FeedbackType | null;
onSelect: (type: FeedbackType) => void;
}
export default function FeedbackTypeStep({ selectedType, onSelect }: Props) {
return (
<motion.div
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -12 }}
transition={{ duration: 0.2 }}
>
<p className="text-[13px] font-bold text-slate-700 mb-1.5">想反馈点什么?</p>
<RotatingFooterHint className="justify-start mb-4" />
<div className="grid grid-cols-1 gap-2">
{TYPE_OPTIONS.map((opt, i) => {
const Icon = opt.icon;
const selected = selectedType === opt.key;
return (
<motion.button
key={opt.key}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04, duration: 0.2 }}
whileHover={{ y: -1 }}
whileTap={{ scale: 0.99 }}
onClick={() => onSelect(opt.key)}
className={`text-left p-3.5 rounded-2xl border bg-white transition-all flex items-center gap-3 group ${selected ? `ring-2 ${opt.ring} border-transparent shadow-sm` : 'border-slate-100 hover:border-slate-200 hover:shadow-sm'}`}
>
<div className={`w-10 h-10 rounded-xl ${opt.iconBg} flex items-center justify-center flex-shrink-0`}>
<Icon size={18} className={opt.iconFg} strokeWidth={2.2} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-bold text-slate-800 leading-tight">{opt.label}</div>
<div className="text-[11px] text-slate-400 font-medium mt-0.5">{opt.sub}</div>
</div>
<ChevronRight size={15} className="text-slate-300 flex-shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
</motion.button>
);
})}
</div>
</motion.div>
);
}
-8
View File
@@ -1,8 +0,0 @@
export type FeedbackStep = 1 | 2 | 3;
export type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
export interface UploadedImg {
url: string;
thumbDataUrl: string;
}
-66
View File
@@ -1,66 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
-168
View File
@@ -1,168 +0,0 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Dialog as DialogPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">关闭</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">关闭</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
-13
View File
@@ -1,13 +0,0 @@
import { cn } from "cn"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
-300
View File
@@ -1,300 +0,0 @@
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>
);
}
-82
View File
@@ -1,15 +1,8 @@
@import "tailwindcss";
:root {
--app-bg: #f4f7fb;
--panel-bg: rgba(255, 255, 255, 0.9);
--hairline: rgba(148, 163, 184, 0.18);
}
html, body {
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
background: var(--app-bg);
}
html {
@@ -20,79 +13,4 @@ html {
body {
overflow: auto;
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;
}
-3
View File
@@ -1,3 +0,0 @@
export function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ');
}
+30 -29
View File
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
import { fetchJson, fetchAuthenticated } from '../../auth/api-client';
import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface';
import { fetchJson } from '../../auth/api-client';
interface FeedbackItem {
id: number;
@@ -21,10 +20,10 @@ interface FeedbackItem {
}
const TYPE_LABEL: Record<string, string> = {
dimension: '新维度',
bug: 'Bug',
ux: '体验',
other: '其他',
dimension: '💡 新维度',
bug: '🐛 Bug',
ux: '🎨 体验',
other: '📝 其他',
};
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
@@ -49,9 +48,10 @@ function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
}
async function patchItem(id: number, data: { status?: string; reply?: string }): Promise<void> {
const res = await fetchAuthenticated(`/api/feedback/${id}`, {
const token = sessionStorage.getItem('bi_jwt');
const res = await fetch(`/api/feedback/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(data),
});
const json = await res.json();
@@ -126,36 +126,36 @@ export default function FeedbackAdminPage() {
}, {});
return (
<PageFrame
title="用户反馈管理"
subtitle="查看、回复、跟进用户提交的建议,形成从问题发现到处理闭环的运营后台。"
icon={Inbox}
eyebrow="FEEDBACK OPS"
meta={`当前 ${items.length} 条反馈`}
actions={(
<div className="flex items-center gap-2">
<div className="min-h-screen bg-[#F8F9FB] p-4 md:p-8">
<div className="max-w-5xl mx-auto space-y-4">
<header className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0">
<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"
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
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="刷新">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center flex-shrink-0">
<Inbox size={18} className="text-white" />
</div>
<div className="min-w-0">
<h1 className="text-lg font-black text-slate-900 leading-tight">用户反馈管理</h1>
<p className="text-[11px] font-bold text-slate-400">查看、回复、跟进用户提交的建议</p>
</div>
</div>
<button onClick={reload} className="p-2 text-slate-400 hover:text-blue-500 flex-shrink-0" title="刷新">
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
</button>
</div>
)}
maxWidth="max-w-5xl"
>
</header>
{/* 状态过滤 */}
<SurfaceCard className="p-2">
<div className="flex items-center gap-1 overflow-x-auto">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-2 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'}`}
@@ -170,7 +170,6 @@ export default function FeedbackAdminPage() {
</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">
@@ -193,9 +192,9 @@ export default function FeedbackAdminPage() {
{/* 列表 */}
<div className="space-y-2">
{loading && items.length === 0 ? (
<LoadingState label="正在加载用户反馈" />
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">加载中…</div>
) : items.length === 0 ? (
<EmptyState title="还没有反馈" description="新的用户反馈会出现在这里" />
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">还没有反馈</div>
) : items.map(it => {
const shots = parseScreenshots(it.screenshots);
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
@@ -219,7 +218,7 @@ export default function FeedbackAdminPage() {
{(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>}
{it.contact && <span>📞 {it.contact}</span>}
</div>
)}
{it.reply_content && (
@@ -237,6 +236,8 @@ export default function FeedbackAdminPage() {
);
})}
</div>
</div>
{/* 详情 / 回复弹窗 */}
<AnimatePresence>
{active && (
@@ -335,6 +336,6 @@ export default function FeedbackAdminPage() {
</motion.div>
)}
</AnimatePresence>
</PageFrame>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,53 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { statisticsExportRows, vehicleExportRows } from './analysis-export';
import type { VehicleListItem } from './types';
test('statistics export retains all ordered groups and repeats parent labels for spreadsheet filtering', () => {
const groups = Array.from({ length: 105 }, (_, i) => ({ values: ['华东', `城市${i}`], count: 1, extra: { 待交车: 0, 异动: Number(i === 104) } }));
const rows = statisticsExportRows(['区域', '城市'], groups, 105);
assert.equal(rows.length, 105);
assert.deepEqual(rows[104], { 区域: '华东', 城市: '城市104', '数量(台)': 1, 待交车: 0, 异动: 1, '占比(%)': 1 });
assert.equal(rows.reduce((n, row) => n + Number(row['数量(台)']), 0), 105);
assert.deepEqual(statisticsExportRows([], [{ values: [], count: 3 }], 3, '车次'), [{ 统计范围: '当前筛选结果', '数量(车次)': 3, '占比(%)': 100 }]);
});
test('vehicle exports preserve full records, server masking and pending/movement fields without treating missing time as zero', () => {
const vehicles = Array.from({ length: 45 }, (_, id) => ({ id, plateNumber: `车-${id}`, vin: '001234', status: 'Inventory', operationStatus: '3', customerName: '测试***公司' } as VehicleListItem));
vehicles[0].pendingDelivery = { contractNo: '001-CONTRACT', customerName: '待交***公司' };
vehicles[44].abnormalMove = { id: 'move', type: '维修', status: '待结束', work: '更换配件', elapsedDays: null } as NonNullable<VehicleListItem['abnormalMove']>;
const rows = vehicleExportRows(vehicles);
assert.equal(rows.length, 45);
assert.equal(rows[0].VIN, '001234');
assert.equal(rows[0].客户, '测试***公司');
assert.equal(rows[0].待交车合同, '001-CONTRACT');
assert.equal(rows[44].异动类型, '维修');
assert.equal(rows[44]['已异动时长(天)'], null);
assert.equal(rows[44].实际完成时间, '未完成');
assert.equal(rows[0].状态, '在库');
assert.equal(rows[0].运营状态, '可运营');
});
test('one Excel workbook contains statistics and all details in two named sheets', async () => {
const { createAnalysisWorkbook } = await import('./analysis-export');
const XLSX = await import('xlsx');
const statistics = statisticsExportRows(['区域'], [{ values: ['华东'], count: 105 }], 105);
const details = Array.from({ length: 105 }, (_, i) => ({ VIN: `000${i}`, 合同: i === 104 ? 'LAST-CONTRACT' : 'CONTRACT', 项目名称: '项目甲' }));
const workbook = await createAnalysisWorkbook(statistics, details);
const saved = XLSX.read(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }), { type: 'buffer' });
assert.deepEqual(saved.SheetNames, ['统计表', '详情表']);
assert.deepEqual(XLSX.utils.sheet_to_json(saved.Sheets['统计表']), statistics);
assert.deepEqual(XLSX.utils.sheet_to_json(saved.Sheets['详情表']), details);
const empty = await createAnalysisWorkbook([], []);
assert.deepEqual(empty.SheetNames, ['统计表', '详情表']);
});
test('inventory export uses assigned operations managers and a dash when unset', () => {
const vehicles = ['张三、李四', null, ' '].map(opsManagers => ({
opsManagers, customerManager: '业务负责人', customerName: '客户', status: 'Inventory',
} as VehicleListItem));
const rows = vehicleExportRows(vehicles, true);
assert.deepEqual(rows.map(row => row.运维负责人), ['张三、李四', '-', '-']);
assert.ok(rows.every(row => !('业务负责人' in row) && !('客户' in row)));
assert.equal(vehicleExportRows(vehicles)[0].业务负责人, '业务负责人');
});
-54
View File
@@ -1,54 +0,0 @@
import type { VehicleListItem } from './types';
export type ExportRow = Record<string, string | number | null | undefined>;
/** Use complete filtered groups, never the progressively rendered slice. Repeat parent labels for Excel filtering. */
export function statisticsExportRows(labels: string[], groups: { values: string[]; count: number; extra?: ExportRow }[], total: number, unit = '台'): ExportRow[] {
return groups.map(group => ({
...Object.fromEntries(labels.length ? labels.map((label, index) => [label, group.values[index]]) : [['统计范围', '当前筛选结果']]),
[`数量(${unit})`]: group.count,
...group.extra,
'占比(%)': total ? Number((group.count / total * 100).toFixed(1)) : 0,
}));
}
export function vehicleExportRows(vehicles: VehicleListItem[], inventory = false): ExportRow[] {
const status: Record<string, string> = { Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' };
const operation: Record<string, string> = { '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' };
const hasPending = vehicles.some(v => v.pendingDelivery);
const hasMovement = vehicles.some(v => v.abnormalMove);
return vehicles.map(v => ({
车牌: v.plateNumber, VIN: v.vin, 品牌: v.brandLabel, 车型: v.type, 型号: v.model,
省份: v.province, 城市: v.city || v.location,
状态: status[v.status] || v.status, 运营状态: operation[v.operationStatus || ''] || v.operationStatus,
...(inventory ? { 运维负责人: v.opsManagers?.trim() || '-' } : { 客户: v.customerName, 签约公司: v.orgName, 部门: v.departmentName, 业务负责人: v.customerManager }),
所属公司: v.subjectOrg, 合同: v.contractNo,
...(hasPending ? { 待交车合同: v.pendingDelivery?.contractNo, 待交车客户: v.pendingDelivery?.customerName } : {}),
...(hasMovement ? {
异动类型: v.abnormalMove?.type, 审批通过时间: v.abnormalMove?.approvalPassedAt,
'已异动时长(天)': v.abnormalMove?.elapsedDays == null ? null : Number(v.abnormalMove.elapsedDays.toFixed(1)),
具体工作: v.abnormalMove?.work, 申请人: v.abnormalMove?.applicant,
预计完成时间: v.abnormalMove?.targetCompletionTime,
实际完成时间: v.abnormalMove?.actualCompletionTime || (v.abnormalMove?.status === '待结束' ? '未完成' : null),
} : {}),
}));
}
export async function createAnalysisWorkbook(statistics: ExportRow[], details: ExportRow[]) {
const XLSX = await import('xlsx');
const workbook = XLSX.utils.book_new();
for (const [name, rows] of [['统计表', statistics], ['详情表', details]] as const) {
const sheet = XLSX.utils.json_to_sheet(rows.map(row => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, value ?? '—']))));
sheet['!cols'] = Object.keys(rows[0] || {}).map(label => ({ wch: /时间|合同|项目|工作/.test(label) ? 28 : 18 }));
if (sheet['!ref']) sheet['!autofilter'] = { ref: sheet['!ref'] };
XLSX.utils.book_append_sheet(workbook, sheet, name);
}
return workbook;
}
export async function downloadAnalysisWorkbook(statistics: ExportRow[], details: ExportRow[], title: string) {
const workbook = await createAnalysisWorkbook(statistics, details);
const XLSX = await import('xlsx');
const safeTitle = title.replace(/[\\/:*?"<>|\x00-\x1f]/g, '-').slice(0, 120);
XLSX.writeFile(workbook, `${safeTitle}.xlsx`, { compression: true });
}
-208
View File
@@ -1,208 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AssetAnalysisCell } from '../../shared/assets/analytics';
import {
aggregateAnalysisRows,
filterAnalysisCells,
filterCustomCells,
getAnalysisDimensions,
getAnalysisSlice,
getAvailableDimensions,
getCustomAnalysis,
getDefaultDimensions,
summarizeAnalyticsCells,
} from './analytics-model';
function cell(overrides: Partial<AssetAnalysisCell>): AssetAnalysisCell {
return {
type: '18T', model: '车型甲', region: '华东', city: '嘉兴市',
assetStatus: 'inventory', assetDisposition: '在库', operationKind: '可运营',
inventoryState: 'available', abnormalType: '不适用', abnormalAge: 'unknown',
department: '业务一部', manager: '负责人甲', customer: '客户甲', quantity: 1,
...overrides,
};
}
const cells = [
cell({ quantity: 5 }),
cell({ inventoryState: 'abnormal', abnormalType: '维修', abnormalAge: '30d+', quantity: 3 }),
cell({ region: '华南', city: '广州市', inventoryState: 'pending', quantity: 2 }),
cell({ assetStatus: 'operating', assetDisposition: '租赁', operationKind: '租赁', inventoryState: 'not-applicable', quantity: 10 }),
cell({ type: '49T', region: '华南', city: '深圳市', assetStatus: 'operating', assetDisposition: '自营', operationKind: '自营', inventoryState: 'not-applicable', quantity: 4 }),
cell({ assetStatus: 'other', assetDisposition: '其他', operationKind: '未分类', inventoryState: 'not-applicable', quantity: 1 }),
];
test('inventory subset counts reconcile at group and filtered-total levels without adding to inventory', () => {
assert.ok(!getAvailableDimensions('inventory').includes('manager'));
assert.ok(!getAvailableDimensions('inventory').includes('customer'));
assert.ok(getAvailableDimensions('operating').includes('manager'));
assert.ok(getAvailableDimensions('operating').includes('customer'));
const result = getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region', 'type'] });
assert.equal(result.total, 10);
assert.equal(result.summary.pending, 2);
assert.equal(result.summary.abnormal, 3);
assert.equal(result.rows.reduce((sum, row) => sum + row.totals.pending, 0), 2);
assert.equal(result.rows.reduce((sum, row) => sum + row.totals.abnormal, 0), 3);
const filtered = getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region', 'type'], filterValues: { region: ['华东'] } });
assert.equal(filtered.total, 8);
assert.equal(filtered.summary.pending, 0);
assert.equal(filtered.summary.abnormal, 3);
assert.equal(filtered.rows[0].totals.pending, 0);
assert.equal(filtered.rows[0].totals.abnormal, 3);
});
test('management totals reconcile without double-counting inventory subsets', () => {
const summary = summarizeAnalyticsCells(cells);
assert.equal(summary.assets, 25);
assert.equal(summary.operating, 14);
assert.equal(summary.inventory, 10);
assert.equal(summary.other, 1);
assert.equal(summary.abnormal, 3);
assert.equal(summary.pending, 2);
assert.equal(summary.abnormalRate, 30);
assert.equal(summary.pendingRate, 20);
assert.ok(Math.abs(summary.operatingRate - 56) < 1e-10);
});
test('operating drill keeps region, city, type and operation kind as aggregate levels', () => {
let slice = getAnalysisSlice(cells, { view: 'operating' });
assert.equal(slice.dimension, 'region');
assert.equal(slice.total, 14);
assert.deepEqual(slice.rows.map(row => row.count), [10, 4]);
assert.equal(slice.rows.reduce((sum, row) => sum + row.share, 0), 100);
for (const expectedDimension of ['city', 'type', 'operationKind']) {
assert.equal(slice.isLeaf, false);
slice = getAnalysisSlice(cells, { view: 'operating', path: slice.rows[0].nextPath });
assert.equal(slice.dimension, expectedDimension);
assert.equal(slice.total, 10);
}
slice = getAnalysisSlice(cells, { view: 'operating', path: slice.rows[0].nextPath });
assert.equal(slice.isLeaf, true);
assert.equal(slice.dimension, null);
assert.deepEqual(slice.rows, []);
assert.equal(summarizeAnalyticsCells(filterAnalysisCells(cells, slice.filters, slice.metric)).assets, slice.total);
});
test('asset uses mutually exclusive stock, lease and self-operated dispositions', () => {
const slice = getAnalysisSlice(cells, { view: 'assets', filters: { type: '18T' } });
assert.equal(slice.dimension, 'assetDisposition');
assert.equal(slice.rows.reduce((sum, row) => sum + row.count, 0), slice.total);
assert.deepEqual(slice.rows.map(row => row.value).sort(), ['其他', '在库', '租赁'].sort());
});
test('fixed global dimensions are skipped and conflicting stale paths cannot widen results', () => {
const slice = getAnalysisSlice(cells, {
view: 'operating', filters: { region: '华南', type: '49T' },
path: [{ dimension: 'region', value: '华南' }, { dimension: 'city', value: '深圳市' }],
});
assert.equal(slice.dimension, 'operationKind');
assert.equal(slice.total, 4);
assert.deepEqual(slice.path, [{ dimension: 'city', value: '深圳市' }]);
const conflict = getAnalysisSlice(cells, {
view: 'operating', filters: { region: '华南' }, path: [{ dimension: 'region', value: '华东' }],
});
assert.equal(conflict.total, 0);
assert.deepEqual(conflict.rows, []);
});
test('abnormal pivot retains all three dimensions once and ends at vehicle type', () => {
for (const start of ['abnormalType', 'region', 'abnormalAge'] as const) {
const dimensions = getAnalysisDimensions('abnormal', start);
assert.equal(dimensions[0], start);
assert.equal(dimensions.at(-1), 'type');
assert.equal(new Set(dimensions).size, dimensions.length);
}
const missingTime = cell({ inventoryState: 'abnormal', abnormalType: '维修', abnormalAge: 'unknown', quantity: 2 });
const slice = getAnalysisSlice([...cells, missingTime], { view: 'abnormal', abnormalStart: 'abnormalAge' });
assert.equal(slice.total, 5);
assert.equal(slice.rows.find(row => row.value === 'unknown')?.label, '审批时间缺失');
assert.equal(slice.rows.find(row => row.value === 'unknown')?.count, 2);
});
test('chronological label sort preserves all duration buckets without rounding totals', () => {
const durationCells = ['unknown', '30d+', '0-3d', '15-30d', '7-15d', '3-7d'].map(abnormalAge => cell({
inventoryState: 'abnormal', abnormalAge: abnormalAge as AssetAnalysisCell['abnormalAge'],
}));
const rows = aggregateAnalysisRows(durationCells, 'abnormalAge', 'abnormal', 'label');
assert.deepEqual(rows.map(row => row.value), ['0-3d', '3-7d', '7-15d', '15-30d', '30d+', 'unknown']);
assert.equal(rows.reduce((sum, row) => sum + row.count, 0), 6);
assert.equal(summarizeAnalyticsCells([]).operatingRate, 0);
});
test('custom analysis combines independent dimensions and conserves the selected metric', () => {
const analysis = getCustomAnalysis(cells, { view: 'assets', dimensions: ['region', 'type'] });
assert.deepEqual(analysis.dimensions, ['region', 'type']);
assert.equal(analysis.total, 25);
assert.equal(analysis.rows.length, 3);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), analysis.total);
assert.equal(analysis.rows.find(row => row.values.region === '华东')?.count, 19);
assert.equal(analysis.rows.find(row => row.values.region === '华南' && row.values.type === '18T')?.count, 2);
assert.equal(analysis.rows.find(row => row.values.region === '华南' && row.values.type === '49T')?.count, 4);
assert.ok(Math.abs(analysis.rows.reduce((sum, row) => sum + row.share, 0) - 100) < 1e-10);
});
test('custom composite keys cannot collide when dimension values contain delimiters', () => {
const collisionCells = [
cell({ region: 'A · B', city: 'C', quantity: 2 }),
cell({ region: 'A', city: 'B · C', quantity: 3 }),
cell({ region: '["A","B"]', city: '"C"', quantity: 4 }),
];
const analysis = getCustomAnalysis(collisionCells, { view: 'assets', dimensions: ['region', 'city'] });
assert.equal(analysis.rows.length, 3);
assert.equal(new Set(analysis.rows.map(row => row.key)).size, 3);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), 9);
});
test('custom filters OR values within a dimension and AND across dimensions', () => {
const filterValues = { region: ['华东', '华南'], type: ['18T'], inventoryState: ['available', 'pending'] };
const filtered = filterCustomCells(cells, filterValues, 'inventory');
assert.equal(summarizeAnalyticsCells(filtered).inventory, 7);
const analysis = getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region'], filterValues });
assert.equal(analysis.total, 7);
assert.deepEqual(analysis.rows.map(row => row.count), [5, 2]);
assert.equal(analysis.rows[0].share, 5 / 7 * 100);
assert.equal(getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region'], filterValues: { region: ['不存在区域'] } }).total, 0);
});
test('empty filter selections impose no restriction and filter-only analysis gives one total', () => {
const analysis = getCustomAnalysis(cells, { view: 'operating', dimensions: [], filterValues: { region: [] } });
assert.equal(analysis.total, 14);
assert.equal(analysis.rows.length, 1);
assert.equal(analysis.rows[0].label, '合计');
assert.equal(analysis.rows[0].count, 14);
assert.equal(analysis.rows[0].share, 100);
assert.deepEqual(analysis.rows[0].values, {});
assert.equal(analysis.summary.assets, 25);
assert.equal(getCustomAnalysis([], { view: 'operating', dimensions: [] }).rows.length, 0);
assert.equal(getCustomAnalysis([cell({})], { view: 'operating', dimensions: [] }).rows.length, 0);
});
test('custom shares use the selected metric rather than all assets in the scope', () => {
const analysis = getCustomAnalysis(cells, { view: 'operating', dimensions: ['region'] });
assert.equal(analysis.total, 14);
assert.equal(analysis.summary.assets, 25);
assert.equal(analysis.rows.find(row => row.values.region === '华东')?.share, 10 / 14 * 100);
assert.equal(analysis.rows.find(row => row.values.region === '华南')?.share, 4 / 14 * 100);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), analysis.total);
});
test('custom dimensions remove unknown, duplicate and inapplicable values without losing counts', () => {
const analysis = getCustomAnalysis(cells, {
view: 'operating',
dimensions: ['region', 'bogus', 'region', 'abnormalAge', 'type'] as Parameters<typeof getCustomAnalysis>[1]['dimensions'],
});
assert.deepEqual(analysis.dimensions, ['region', 'type']);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), 14);
for (const view of ['assets', 'operating', 'inventory', 'abnormal', 'pending', 'department', 'customer'] as const) {
assert.equal(getDefaultDimensions(view).length, 1);
assert.ok(getAvailableDimensions(view).includes(getDefaultDimensions(view)[0]));
}
});
test('custom sorting supports count order and semantic duration order', () => {
const ascending = getCustomAnalysis(cells, { view: 'assets', dimensions: ['region', 'type'], sort: 'count-asc' });
assert.deepEqual(ascending.rows.map(row => row.count), [2, 4, 19]);
const durationCells = ['unknown', '30d+', '0-3d'].map(abnormalAge => cell({ inventoryState: 'abnormal', abnormalAge: abnormalAge as AssetAnalysisCell['abnormalAge'] }));
const byLabel = getCustomAnalysis(durationCells, { view: 'abnormal', dimensions: ['abnormalAge'], sort: 'label' });
assert.deepEqual(byLabel.rows.map(row => row.values.abnormalAge), ['0-3d', '30d+', 'unknown']);
});
-365
View File
@@ -1,365 +0,0 @@
import type {
AssetAnalysisCell,
AssetAnalysisDimension,
AssetAnalysisFilters,
AssetAnalysisMetric,
} from '../../shared/assets/analytics';
import { ASSET_ANALYSIS_DIMENSIONS } from '../../shared/assets/analytics';
export type AnalysisView = AssetAnalysisMetric | 'department' | 'customer';
export type AnalysisSort = 'count-desc' | 'count-asc' | 'label';
export type AbnormalStartDimension = 'abnormalType' | 'region' | 'abnormalAge';
export interface AnalysisPathStep {
dimension: AssetAnalysisDimension;
value: string;
}
export interface AnalysisTotals {
assets: number;
operating: number;
inventory: number;
abnormal: number;
pending: number;
other: number;
/** All rates are percentages, not fractions. */
operatingRate: number;
inventoryRate: number;
/** Abnormal and pending are inventory subsets; their denominator is inventory. */
abnormalRate: number;
pendingRate: number;
}
export interface AnalysisAggregateRow {
value: string;
label: string;
count: number;
/** Share of the current metric total, as a percentage. */
share: number;
totals: AnalysisTotals;
}
export interface AnalysisRow extends AnalysisAggregateRow {
nextPath: AnalysisPathStep[];
isLeaf: boolean;
}
export interface AnalysisOptions {
view: AnalysisView;
filters?: AssetAnalysisFilters;
path?: readonly AnalysisPathStep[];
abnormalStart?: AbnormalStartDimension;
sort?: AnalysisSort;
}
export interface AnalysisSlice {
metric: AssetAnalysisMetric;
dimension: AssetAnalysisDimension | null;
dimensions: AssetAnalysisDimension[];
path: AnalysisPathStep[];
filters: AssetAnalysisFilters;
rows: AnalysisRow[];
total: number;
summary: AnalysisTotals;
isLeaf: boolean;
breadcrumbs: Array<AnalysisPathStep & { label: string }>;
}
export const ANALYSIS_META: Record<AnalysisView, {
title: string;
description: string;
metric: AssetAnalysisMetric;
unit: '台';
}> = {
assets: { title: '资产结构', description: '从车型看在库、租赁与自营结构,再定位区域和城市。', metric: 'assets', unit: '台' },
operating: { title: '运营分析', description: '按区域、城市、车型和运营状态逐层比较运营分布。', metric: 'operating', unit: '台' },
inventory: { title: '库存分析', description: '按区域、城市和车型定位库存。', metric: 'inventory', unit: '台' },
abnormal: { title: '异动分析', description: '交叉分析异动类型、区域与已异动时长,识别集中与积压。', metric: 'abnormal', unit: '台' },
pending: { title: '待交车分析', description: '按大区、城市和车型查看待交车分布。', metric: 'pending', unit: '台' },
department: { title: '部门分析', description: '从部门到业务负责人和车型,查看运营资产配置。', metric: 'operating', unit: '台' },
customer: { title: '客户分析', description: '从客户到车型和区域,查看运营资产集中度。', metric: 'operating', unit: '台' },
};
export const DIMENSION_LABELS: Record<AssetAnalysisDimension, string> = {
type: '车型', model: '型号', region: '区域', city: '城市',
assetStatus: '资产状态', assetDisposition: '资产用途', operationKind: '运营状态',
inventoryState: '库存状态', abnormalType: '异动类型', abnormalAge: '已异动时长',
department: '部门', manager: '业务负责人', customer: '客户',
};
const VALUE_LABELS: Partial<Record<AssetAnalysisDimension, Record<string, string>>> = {
assetStatus: { operating: '运营中', inventory: '在库', other: '其他状态' },
inventoryState: {
abnormal: '异动', pending: '待交车', available: '可运营', awaiting: '待运营',
unclassified: '未分类库存', 'not-applicable': '不适用',
},
abnormalAge: {
'0-3d': '不足 3 天', '3-7d': '3–7 天', '7-15d': '7–15 天',
'15-30d': '15–30 天', '30d+': '30 天及以上', unknown: '审批时间缺失',
},
};
const DIMENSION_ORDER: Partial<Record<AssetAnalysisDimension, readonly string[]>> = {
region: ['华东', '华南', '华北', '华中', '西南', '西北', '其他'],
abnormalAge: ['0-3d', '3-7d', '7-15d', '15-30d', '30d+', 'unknown'],
assetStatus: ['inventory', 'operating', 'other'],
assetDisposition: ['在库', '租赁', '自营', '其他运营', '其他'],
inventoryState: ['available', 'awaiting', 'pending', 'abnormal', 'unclassified', 'not-applicable'],
};
const PATHS: Record<Exclude<AnalysisView, 'abnormal'>, readonly AssetAnalysisDimension[]> = {
assets: ['type', 'assetDisposition', 'region', 'city'],
operating: ['region', 'city', 'type', 'operationKind'],
inventory: ['region', 'city', 'type', 'inventoryState'],
pending: ['region', 'city', 'type'],
department: ['department', 'manager', 'type'],
customer: ['customer', 'type', 'region'],
};
export function getAnalysisDimensions(
view: AnalysisView,
abnormalStart: AbnormalStartDimension = 'abnormalType',
): AssetAnalysisDimension[] {
if (view !== 'abnormal') return [...PATHS[view]];
const comparisons: AbnormalStartDimension[] = ['abnormalType', 'region', 'abnormalAge'];
return [abnormalStart, ...comparisons.filter(dimension => dimension !== abnormalStart), 'type'];
}
export function getDimensionValueLabel(dimension: AssetAnalysisDimension, value: string): string {
return VALUE_LABELS[dimension]?.[value] ?? (value || '未标注');
}
/** Counts derive from the same mutually exclusive state dimensions as the server. */
export function cellMatchesMetric(cell: AssetAnalysisCell, metric: AssetAnalysisMetric): boolean {
switch (metric) {
case 'assets': return true;
case 'operating': return cell.assetStatus === 'operating';
case 'inventory': return cell.assetStatus === 'inventory';
case 'abnormal': return cell.assetStatus === 'inventory' && cell.inventoryState === 'abnormal';
case 'pending': return cell.assetStatus === 'inventory' && cell.inventoryState === 'pending';
}
}
export function filterAnalysisCells(
cells: readonly AssetAnalysisCell[],
filters: AssetAnalysisFilters = {},
metric: AssetAnalysisMetric = 'assets',
): AssetAnalysisCell[] {
const entries = Object.entries(filters).filter(([, value]) => value !== undefined && value !== '') as Array<[AssetAnalysisDimension, string]>;
return cells.filter(cell => cellMatchesMetric(cell, metric) && entries.every(([dimension, value]) => cell[dimension] === value));
}
const percent = (count: number, total: number): number => total > 0 ? count / total * 100 : 0;
export function summarizeAnalyticsCells(cells: readonly AssetAnalysisCell[]): AnalysisTotals {
const totals: AnalysisTotals = {
assets: 0, operating: 0, inventory: 0, abnormal: 0, pending: 0, other: 0,
operatingRate: 0, inventoryRate: 0, abnormalRate: 0, pendingRate: 0,
};
for (const cell of cells) {
totals.assets += cell.quantity;
if (cell.assetStatus === 'operating') totals.operating += cell.quantity;
else if (cell.assetStatus === 'inventory') {
totals.inventory += cell.quantity;
if (cell.inventoryState === 'abnormal') totals.abnormal += cell.quantity;
if (cell.inventoryState === 'pending') totals.pending += cell.quantity;
} else totals.other += cell.quantity;
}
totals.operatingRate = percent(totals.operating, totals.assets);
totals.inventoryRate = percent(totals.inventory, totals.assets);
totals.abnormalRate = percent(totals.abnormal, totals.inventory);
totals.pendingRate = percent(totals.pending, totals.inventory);
return totals;
}
export function aggregateAnalysisRows(
cells: readonly AssetAnalysisCell[],
dimension: AssetAnalysisDimension,
metric: AssetAnalysisMetric = 'assets',
sort: AnalysisSort = 'count-desc',
): AnalysisAggregateRow[] {
const groups = new Map<string, AssetAnalysisCell[]>();
for (const cell of cells) {
const value = cell[dimension];
const group = groups.get(value);
if (group) group.push(cell);
else groups.set(value, [cell]);
}
const total = summarizeAnalyticsCells(cells)[metric];
const rows: AnalysisAggregateRow[] = [];
for (const [value, group] of groups) {
const totals = summarizeAnalyticsCells(group);
const count = totals[metric];
if (count > 0) rows.push({ value, label: getDimensionValueLabel(dimension, value), count, share: percent(count, total), totals });
}
const compareLabels = (a: AnalysisAggregateRow, b: AnalysisAggregateRow) => {
const order = DIMENSION_ORDER[dimension];
if (order) {
const aIndex = order.indexOf(a.value);
const bIndex = order.indexOf(b.value);
const result = (aIndex < 0 ? order.length : aIndex) - (bIndex < 0 ? order.length : bIndex);
if (result !== 0) return result;
}
return a.label.localeCompare(b.label, 'zh-CN', { numeric: true });
};
return rows.sort((a, b) => {
const difference = sort === 'count-desc' ? b.count - a.count : sort === 'count-asc' ? a.count - b.count : 0;
return difference || compareLabels(a, b);
});
}
/** Keep global filters and drill selections intersected; a stale path cannot widen scope. */
export function getAnalysisSlice(cells: readonly AssetAnalysisCell[], options: AnalysisOptions): AnalysisSlice {
const { view, abnormalStart = 'abnormalType', sort = 'count-desc' } = options;
const metric = ANALYSIS_META[view].metric;
const filters: AssetAnalysisFilters = {};
for (const [key, value] of Object.entries(options.filters ?? {})) {
if (value !== undefined && value !== '') filters[key as AssetAnalysisDimension] = value;
}
const dimensions = getAnalysisDimensions(view, abnormalStart).filter(dimension => filters[dimension] === undefined);
const path: AnalysisPathStep[] = [];
let conflictingPath = false;
for (const step of options.path ?? []) {
const globalValue = filters[step.dimension];
if (globalValue !== undefined) {
if (globalValue !== step.value) conflictingPath = true;
continue;
}
// Only a prefix of this analysis schema is valid. Unrelated old paths are discarded.
if (dimensions[path.length] !== step.dimension) break;
path.push({ dimension: step.dimension, value: step.value });
}
for (const step of path) filters[step.dimension] = step.value;
const scopedCells = conflictingPath ? [] : filterAnalysisCells(cells, filters);
const summary = summarizeAnalyticsCells(scopedCells);
const dimension = dimensions[path.length] ?? null;
const isLeaf = dimension === null;
const rows: AnalysisRow[] = dimension === null ? [] : aggregateAnalysisRows(scopedCells, dimension, metric, sort).map(row => ({
...row,
nextPath: [...path, { dimension, value: row.value }],
isLeaf: path.length + 1 === dimensions.length,
}));
return {
metric, dimension, dimensions, path, filters, rows, total: summary[metric], summary, isLeaf,
breadcrumbs: path.map(step => ({ ...step, label: getDimensionValueLabel(step.dimension, step.value) })),
};
}
export type AnalysisFilterValues = Partial<Record<AssetAnalysisDimension, string[]>>;
export interface CustomAnalysisRow {
key: string;
values: Partial<Record<AssetAnalysisDimension, string>>;
label: string;
count: number;
share: number;
totals: AnalysisTotals;
}
export interface CustomAnalysisOptions {
view: AnalysisView;
dimensions: readonly AssetAnalysisDimension[];
filterValues?: AnalysisFilterValues;
sort?: AnalysisSort;
}
export interface CustomAnalysis {
rows: CustomAnalysisRow[];
total: number;
summary: AnalysisTotals;
dimensions: AssetAnalysisDimension[];
metric: AssetAnalysisMetric;
}
const COMMON_DIMENSIONS: readonly AssetAnalysisDimension[] = [
'type', 'model', 'region', 'city', 'department', 'manager', 'customer',
];
const BUSINESS_DIMENSIONS: Record<AnalysisView, readonly AssetAnalysisDimension[]> = {
assets: ['assetDisposition', 'operationKind'],
operating: ['operationKind'],
inventory: ['operationKind'],
abnormal: ['abnormalType', 'abnormalAge'],
pending: [],
department: ['operationKind'],
customer: ['operationKind'],
};
/** Dimensions are choices, not a required drill sequence. */
export function getAvailableDimensions(view: AnalysisView): AssetAnalysisDimension[] {
const common = ['inventory', 'pending', 'abnormal'].includes(view)
? COMMON_DIMENSIONS.filter(dimension => dimension !== 'department' && dimension !== 'manager' && dimension !== 'customer')
: COMMON_DIMENSIONS;
return [...common, ...BUSINESS_DIMENSIONS[view]];
}
export function getDefaultDimensions(view: AnalysisView): AssetAnalysisDimension[] {
if (view === 'assets') return ['type'];
if (view === 'abnormal') return ['abnormalType'];
if (view === 'department') return ['department'];
if (view === 'customer') return ['customer'];
return ['region'];
}
/** Multiple values of one dimension are OR; selections across dimensions are AND. */
export function filterCustomCells(
cells: readonly AssetAnalysisCell[],
filterValues: AnalysisFilterValues = {},
metric: AssetAnalysisMetric = 'assets',
): AssetAnalysisCell[] {
const constraints: Array<[AssetAnalysisDimension, Set<string>]> = [];
for (const dimension of ASSET_ANALYSIS_DIMENSIONS) {
const selected = filterValues[dimension];
if (!Array.isArray(selected) || selected.length === 0) continue;
constraints.push([dimension, new Set(selected)]);
}
return cells.filter(cell => cellMatchesMetric(cell, metric)
&& constraints.every(([dimension, selected]) => selected.has(cell[dimension])));
}
export function compareCustomLabels(a: CustomAnalysisRow, b: CustomAnalysisRow, dimensions: readonly AssetAnalysisDimension[]): number {
for (const dimension of dimensions) {
const aValue = a.values[dimension]!;
const bValue = b.values[dimension]!;
const order = DIMENSION_ORDER[dimension];
if (order) {
const aIndex = order.indexOf(aValue);
const bIndex = order.indexOf(bValue);
const difference = (aIndex < 0 ? order.length : aIndex) - (bIndex < 0 ? order.length : bIndex);
if (difference !== 0) return difference;
}
const difference = getDimensionValueLabel(dimension, aValue).localeCompare(getDimensionValueLabel(dimension, bValue), 'zh-CN', { numeric: true });
if (difference !== 0) return difference;
}
return a.key.localeCompare(b.key);
}
export function getCustomAnalysis(cells: readonly AssetAnalysisCell[], options: CustomAnalysisOptions): CustomAnalysis {
const metric = ANALYSIS_META[options.view].metric;
const available = new Set(getAvailableDimensions(options.view));
const dimensions = [...new Set(options.dimensions.filter(dimension => available.has(dimension)))];
const scopedCells = filterCustomCells(cells, options.filterValues);
const summary = summarizeAnalyticsCells(scopedCells);
const total = summary[metric];
const groups = new Map<string, { values: CustomAnalysisRow['values']; cells: AssetAnalysisCell[] }>();
for (const cell of scopedCells) {
// Structured JSON preserves values containing separators or quotes.
const entries = dimensions.map(dimension => [dimension, cell[dimension]] as const);
const key = JSON.stringify(entries);
const group = groups.get(key);
if (group) group.cells.push(cell);
else groups.set(key, { values: Object.fromEntries(entries), cells: [cell] });
}
const rows: CustomAnalysisRow[] = [];
for (const [key, group] of groups) {
const totals = summarizeAnalyticsCells(group.cells);
const count = totals[metric];
if (count <= 0) continue;
const label = dimensions.length === 0 ? '合计' : dimensions.map(dimension => getDimensionValueLabel(dimension, group.values[dimension]!)).join(' · ');
rows.push({ key, values: group.values, label, count, share: percent(count, total), totals });
}
const sort = options.sort ?? 'count-desc';
rows.sort((a, b) => {
const difference = sort === 'count-desc' ? b.count - a.count : sort === 'count-asc' ? a.count - b.count : 0;
return difference || compareCustomLabels(a, b, dimensions);
});
return { rows, total, summary, dimensions, metric };
}
+2 -59
View File
@@ -37,8 +37,6 @@ export async function fetchByType(subject?: string | null): Promise<TypeSummary[
}
export async function fetchVehicleList(params: {
inventoryKeys?: string[];
brand?: string;
batch?: string;
model?: string;
location?: string;
@@ -55,7 +53,6 @@ export async function fetchVehicleList(params: {
source?: string;
}): Promise<VehicleListItem[]> {
const query = new URLSearchParams();
if (params.brand) query.set('brand', params.brand);
if (params.batch) query.set('batch', params.batch);
if (params.model) query.set('model', params.model);
if (params.location) query.set('location', params.location);
@@ -70,11 +67,7 @@ export async function fetchVehicleList(params: {
if (params.attendance) query.set('attendance', params.attendance);
if (params.subject) query.set('subject', params.subject);
if (params.source) query.set('source', params.source);
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`, params.inventoryKeys ? {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inventoryKeys: params.inventoryKeys }),
} : undefined);
return fetchJson<VehicleListItem[]>(`${BASE}/list?${query.toString()}`);
}
export interface WeeklyDetailItem {
@@ -85,45 +78,6 @@ export interface WeeklyDetailItem {
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[]> {
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
}
@@ -162,23 +116,12 @@ export async function fetchRegionChart(
export async function fetchWeeklyDetail(
type: string,
filters?: { model?: string; batch?: string; location?: string; source?: string; subject?: string | null },
filters?: { model?: string; batch?: string; location?: string; source?: string },
): Promise<WeeklyDetailItem[]> {
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()}`);
}
@@ -1,943 +0,0 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
ArrowUp,
ArrowUpRight,
BarChart3,
Building2,
CircleHelp,
ChevronDown,
RotateCcw,
Database,
History,
MessageSquare,
Moon,
Plus,
Sparkles,
Square,
Sun,
Truck,
X,
ArrowLeft,
} from "lucide-react";
import type {
AssistantResult,
AssistantAction,
Dimension,
} from "../../../shared/assets/assistant";
import {
CATEGORY_LABELS,
GROUP_LABELS,
} from "../../../shared/assets/assistant";
import { askAssetAssistant, AssistantRequestError } from "./client";
import { fetchAuthenticated } from "../../../auth/api-client";
import { useAuth } from "../../../auth/useAuth";
import { ThinkingState } from "./ThinkingState";
import { CompoundResultView } from "./CompoundResultView";
import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation";
import "./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>
);
}
-11
View File
@@ -1,11 +0,0 @@
import { lazy, Suspense } from "react";
import type { EChartsCoreOption } from "echarts/core";
import { Skeleton } from "@/components/ui/skeleton";
const EChart = lazy(() => import("./EChartImpl"));
export function Chart(props: { option: EChartsCoreOption; label: string }) {
return (
<Suspense fallback={<Skeleton className="h-64" />}>
<EChart {...props} />
</Suspense>
);
}
@@ -1,302 +0,0 @@
import { useState } from "react";
import { ArrowUpRight, Check, Copy, RotateCcw } from "lucide-react";
import type {
AssistantAction,
AssistantResult,
} from "../../../shared/assets/assistant";
import { ResultView, type ResultViewProps } from "./ResultView";
import { GenerativeResultView } from "./GenerativeResultView";
import { 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>
);
}
-128
View File
@@ -1,128 +0,0 @@
import { useEffect, useState } from "react";
import { fetchAuthenticated } from "../../../auth/api-client";
import {
GAP_REASONS,
GAP_STATUSES,
type DemandGap,
type GapStatus,
} from "../../../shared/assets/assistant";
export function DemandGaps({ close }: { close: () => void }) {
const [items, setItems] = useState<DemandGap[]>([]),
[all, setAll] = useState(false),
[canManage, setCanManage] = useState(false);
const [error, setError] = useState(""),
[loading, setLoading] = useState(true),
[revision, setRevision] = useState(0);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError("");
void fetchAuthenticated(
`/api/asset-assistant/gaps?scope=${all ? "all" : "mine"}`,
{ signal: controller.signal },
)
.then(async (r) => {
const data = await r.json();
if (!r.ok) throw new Error(data.error);
return data;
})
.then((data) => {
setItems(data.items);
setCanManage(data.canManage);
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message || "读取失败。");
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [all, revision]);
const update = async (id: number, status: GapStatus) => {
setError("");
try {
const r = await fetchAuthenticated(`/api/asset-assistant/gaps/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
if (!r.ok) throw new Error("状态保存失败。");
setRevision((x) => x + 1);
} catch {
setError("状态保存失败,请重试。");
}
};
return (
<section className="aq-gap-panel" aria-label="待完善需求">
<div className="aq-card-top">
<div>
<h3>待完善需求</h3>
<p>保留90天 · 记录未完成的问题与反馈,用于完善分析能力</p>
</div>
<button onClick={close}>返回问数</button>
</div>
<div className="aq-result-toolbar">
{canManage && (
<label>
<input
type="checkbox"
checked={all}
onChange={(e) => setAll(e.target.checked)}
/>{" "}
查看全部用户需求
</label>
)}
<button onClick={() => setRevision((x) => x + 1)} disabled={loading}>
刷新
</button>
</div>
{error && (
<p role="alert" className="aq-error">
{error}
</p>
)}
{loading ? (
<p role="status">正在读取需求记录…</p>
) : (
<>
<p>
{items.length}项需求 · 累计
{items.reduce((n, v) => n + v.occurrences, 0)}次(最近200项)
</p>
{items.length === 0 && !error && (
<p className="aq-no-data">暂无未满足需求</p>
)}
{items.map((item) => (
<article key={item.id} className="aq-gap-item">
<strong>{item.demand}</strong>
<p>
{GAP_REASONS[item.reason]} · {item.occurrences}次 · 最近{" "}
{new Date(item.lastSeen).toLocaleString("zh-CN")}
</p>
{canManage ? (
<label>
处理状态{" "}
<select
value={item.status}
onChange={(e) =>
void update(item.id, e.target.value as GapStatus)
}
>
{Object.entries(GAP_STATUSES).map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</select>
</label>
) : (
<span>{GAP_STATUSES[item.status]}</span>
)}
</article>
))}
</>
)}
</section>
);
}
@@ -1,51 +0,0 @@
import { useEffect, useRef } from "react";
import * as echarts from "echarts/core";
import { BarChart, LineChart, PieChart } from "echarts/charts";
import {
GridComponent,
TooltipComponent,
LegendComponent,
AriaComponent,
DataZoomComponent,
} from "echarts/components";
import { SVGRenderer } from "echarts/renderers";
import type { EChartsCoreOption } from "echarts/core";
echarts.use([
BarChart,
LineChart,
PieChart,
GridComponent,
TooltipComponent,
LegendComponent,
AriaComponent,
DataZoomComponent,
SVGRenderer,
]);
export default function Chart({
option,
label,
}: {
option: EChartsCoreOption;
label: string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const chart = echarts.init(ref.current, undefined, { renderer: "svg" });
chart.setOption({ ...option, aria: { enabled: true } });
const observer = new ResizeObserver(() => chart.resize());
observer.observe(ref.current);
return () => {
observer.disconnect();
chart.dispose();
};
}, [option]);
return (
<div
ref={ref}
role="img"
aria-label={label}
className="h-64 min-w-0 w-full"
/>
);
}
@@ -1,682 +0,0 @@
import { memo, useState } from "react";
import {
ArrowDownToLine,
ArrowUpRight,
ChevronLeft,
ChevronRight,
Copy,
} from "lucide-react";
import {
ResponsiveContainer,
BarChart,
Bar,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
Legend,
PieChart,
Pie,
Cell,
LineChart,
Line,
} from "recharts";
import type {
AssistantResult,
PresentationBlock,
PresentationInteraction,
} from "../../../shared/assets/assistant";
import { fetchAuthenticated } from "../../../auth/api-client";
import type { ResultViewProps } from "./ResultView";
import {
compactAnalysisPath,
copyResultText,
displayTime,
queryPath,
resultScope,
} from "./result-presentation";
import {
presentationAction,
presentationData,
presentationFields,
presentationMetrics,
presentationSeries,
presentationValue,
type PresentationDatum,
} from "./generative-presentation";
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,
);
-950
View File
@@ -1,950 +0,0 @@
import { memo, useMemo, useState } from "react";
import {
ArrowDownToLine,
ArrowUpRight,
ChevronLeft,
ChevronRight,
Copy,
} from "lucide-react";
import {
DIMENSIONS,
GROUP_LABELS,
type AssistantAction,
type AssistantResult,
type AssistantRow,
type Dimension,
type QueryPlan,
} from "../../../shared/assets/assistant";
import { fetchAuthenticated } from "../../../auth/api-client";
import { Chart } from "./Chart";
import {
copyResultText,
displayTime,
resultScope,
resultDisplay,
queryPath,
compactAnalysisPath,
} from "./result-presentation";
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,
);
@@ -1,109 +0,0 @@
// Adapted from Beautiful UI's MIT-licensed ThinkingState (Steps).
// Uses real server progress rather than the reference's timed demo sequence.
import { useEffect, useId, useState } from "react";
import { Check, ChevronDown, CircleAlert, LoaderCircle, Pause, Sparkles } from "lucide-react";
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
-64
View File
@@ -1,64 +0,0 @@
import { fetchAuthenticated } from "../../../auth/api-client";
import type {
AssistantEvent,
AssistantRequest,
} from "../../../shared/assets/assistant";
export class AssistantRequestError extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
this.name = "AssistantRequestError";
}
}
export async function askAssetAssistant(
input: AssistantRequest,
signal: AbortSignal,
onEvent: (event: AssistantEvent) => void,
) {
const response = await fetchAuthenticated("/api/asset-assistant/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
signal,
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new AssistantRequestError(
body?.error || "查询服务暂时不可用。",
response.status,
);
}
if (!response.body) throw new Error("当前浏览器无法读取查询进度。");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "",
completed = false;
try {
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const frames = buffer.split(/\r?\n\r?\n/);
buffer = frames.pop() || "";
for (const frame of frames) {
const data = frame
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim())
.join("\n");
if (!data) continue;
const event = JSON.parse(data) as AssistantEvent;
if (event.type === "result" || event.type === "error") completed = true;
onEvent(event);
}
if (done) break;
}
if (!completed && !signal.aborted)
throw new Error("连接中断,请重新提问。");
} finally {
reader.releaseLock();
}
}
@@ -1,24 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ResultView } from "./ResultView";
import type { AssistantResult } from "../../../shared/assets/assistant";
test("count answer renders prose without report cards and only offers records for positive counts", () => {
for (const count of [0, 7]) {
const result: AssistantResult = {
plan: {kind:"assets",category:"abnormal",groupBy:"none",view:"chart",filters:{}},
conciseCount:true,outcome:"complete",summary:`当前异动车辆为 ${count} 台。`,
title:"异动车辆概览",metrics:[{label:"异动车辆",value:count,unit:"台"}],
rows:[],groups:[],source:"测试台账",fetchedAt:"2026-09-23T00:00:00Z",suggestions:["查看全部车辆"],
};
const noop = () => {};
const html = renderToStaticMarkup(createElement(ResultView,{result,ask:noop,operate:noop,restore:noop,active:true,activate:noop,subject:null,disabled:false,showCorrection:noop}));
assert.match(html,new RegExp(`当前异动车辆为 ${count} 台`));
assert.doesNotMatch(html,/aq-primary-metrics|aq-result-card|aq-followups|aq-query-heading/);
assert.equal(html.includes("查看车辆明细"),count > 0);
assert.equal(html.includes("导出明细"),count > 0);
assert.match(html,/统计口径、权限与数据时间/);
}
});
@@ -1,60 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { BUSINESS_QUESTIONS, conversationTitle } from "./conversation-presentation.js";
import { localPlan } from "../../../server/routes/asset-assistant/planner.js";
import { projectQuestion } from "../../../server/routes/asset-assistant/privacy.js";
import type { AssistantResult } from "../../../shared/assets/assistant.js";
test("business starters produce supported queries matching their promises even without a language model",()=>{
const plans=BUSINESS_QUESTIONS.map(question=>localPlan(projectQuestion(question,[])).plan);
assert.equal(plans[0].analysis,"briefing");assert.equal(plans[0].category,"all");
assert.equal(plans[1].category,"inventory");
assert.deepEqual(new Set([plans[1].groupBy,plans[1].secondaryGroupBy]),new Set(["type","region"]));
assert.equal(plans[2].kind,"flow");assert.equal(Date.parse(plans[2].end!)-Date.parse(plans[2].start!),6*86400000);
assert.equal(plans[3].category,"abnormal");assert.equal(plans[3].view,"list");
});
test("history titles describe the actual result and keep long questions readable",()=>{
const base={plan:{kind:"assets",category:"inventory",groupBy:"type",filters:{},view:"chart"}} as AssistantResult;
assert.equal(conversationTitle("a long question",base),"车型库存");
assert.equal(conversationTitle("a long question",{...base,plan:{...base.plan,category:"pending",view:"list"}}),"待交车清单");
assert.ok(conversationTitle("无法识别的很长很长很长很长很长的问题内容").length<=14);
});
test("in-place drilldown keeps its question and previous results available for returning to a parent",async()=>{
const {findConversationResult,replaceAnalysisResult}=await import("./conversation-presentation.js");
const base={resultId:"root",plan:{kind:"assets",category:"inventory",groupBy:"region",filters:{},view:"chart"}} as AssistantResult;
const drilled={...base,resultId:"east",plan:{...base.plan,groupBy:"city" as const,filters:{region:["华东"]}}};
const turn={id:1,question:"库存在哪里?",result:base};
const next=replaceAnalysisResult(turn,drilled);
assert.equal(next.id,1);assert.equal(next.question,turn.question);
assert.equal(next.result,drilled);assert.equal(findConversationResult([next],"root"),base);
const restored=replaceAnalysisResult(next,{...base,resultId:"restored"});
assert.equal(findConversationResult([restored],"east"),drilled);
assert.equal(restored.resultHistory?.length,2);
assert.equal(findConversationResult([restored],"missing"),undefined);
});
test("compound drilldown replaces only its section and can return to older child scopes", async () => {
const {findConversationResult,replaceAnalysisResult}=await import("./conversation-presentation.js");
const plan={kind:"assets",category:"inventory",groupBy:"region",view:"chart",filters:{}} as const;
const stock={resultId:"stock",plan} as AssistantResult;
const pending={resultId:"pending",plan:{...plan,category:"pending"}} as AssistantResult;
const root={...stock,resultId:"bundle",sections:[
{id:"a",title:"库存",question:"库存多少",display:"chart" as const,result:stock},
{id:"b",title:"待交车",question:"待交车多少",display:"metrics" as const,result:pending},
]};
const drill={...stock,resultId:"east",plan:{...stock.plan,groupBy:"city" as const,filters:{region:["华东"]}}};
const next=replaceAnalysisResult({id:1,result:root},drill,"stock");
assert.equal(next.result?.resultId,"bundle");
assert.equal(next.result?.sections?.[0].result,drill);
assert.equal(next.result?.sections?.[1].result,pending);
assert.equal(findConversationResult([next],"stock"),stock);
const deeper={...drill,resultId:"city",plan:{...drill.plan,view:"list" as const}};
const deep=replaceAnalysisResult(next,deeper,"east");
assert.equal(deep.result?.sections?.[0].display,"list");
assert.equal(findConversationResult([deep],"east"),drill);
const returned=replaceAnalysisResult(deep,{...stock,resultId:"restored"},"stock");
assert.equal(returned.result?.sections?.[0].result?.resultId,"restored");
assert.equal(returned.result?.sections?.[1].result,pending);
assert.equal(returned.id,1);
});
@@ -1,55 +0,0 @@
import { CATEGORY_LABELS, GROUP_LABELS, type AssistantResult } from "../../../shared/assets/assistant";
export const BUSINESS_QUESTIONS = [
"现在有多少车在运营、多少车在库存?整体运营率是多少?",
"库存主要集中在哪些车型和区域?按数量从多到少统计。",
"最近7天交车、还车分别多少?其中替换交车多少?",
"查看当前异动车辆,显示异动类型和预计完成时间。",
] as const;
export function conversationTitle(question: string, result?: AssistantResult): string {
if (result?.sections?.length) return `${result.sections.length} 项关联分析`;
if (result?.presentation?.title) return result.presentation.title.slice(0, 24);
if (!result || result.plan.kind === "clarify") {
if (/历史|上个?月月底|损失|成本/.test(question)) return "历史与成本分析";
return question.replace(/[\s,。?!;:、]/g, "").slice(0, 14) || "资产分析";
}
const p = result.plan;
if (p.analysis === "briefing") return "资产总览";
if (p.kind === "flow") return p.comparePrevious ? "交还车周期对比" : "交还车统计";
if (p.view === "list") return `${CATEGORY_LABELS[p.category].replace("车辆", "")}清单`;
const category = {all:"资产",inventory:"库存",available:"可运营库存",awaiting:"待运营库存",operating:"运营",pending:"待交车",abnormal:"异动"}[p.category];
return `${p.groupBy === "none" ? "" : GROUP_LABELS[p.groupBy]}${category}`;
}
type ResultTurn = { result?: AssistantResult; resultHistory?: AssistantResult[] };
export function findConversationResult(turns: ResultTurn[], resultId: string) {
for (const turn of turns) {
if (turn.result?.resultId === resultId) return turn.result;
const current = turn.result?.sections?.find(section => section.result?.resultId === resultId)?.result;
if (current) return current;
for (const historical of [...(turn.resultHistory || [])].reverse()) {
if (historical.resultId === resultId) return historical;
const child = historical.sections?.find(section => section.result?.resultId === resultId)?.result;
if (child) return child;
}
}
}
export function replaceAnalysisResult<T extends ResultTurn>(turn: T, result: AssistantResult, baseResultId?: string): T & ResultTurn {
const history = [...(turn.resultHistory || [])];
const sectionId = baseResultId ? [turn.result, ...history.slice().reverse()]
.flatMap(item => item?.sections || [])
.find(section => section.result?.resultId === baseResultId)?.id : undefined;
if (turn.result?.resultId && (turn.result.sections || !history.some(item => item.resultId === turn.result!.resultId))) history.push(turn.result);
if (sectionId && turn.result?.sections) {
return { ...turn, result: {
...turn.result,
sections: turn.result.sections.map(section => section.id === sectionId ? {
...section, result, error: undefined,
display: result.plan.groupBy === "none" && result.plan.view === "chart" && result.plan.kind === "assets" ? "metrics" : result.plan.view,
} : section),
}, resultHistory: history };
}
return { ...turn, result, resultHistory: history };
}
@@ -1,253 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { GenerativeResultView } from "./GenerativeResultView";
import { CompoundResultView } from "./CompoundResultView";
import {
presentationAction,
presentationData,
presentationFields,
presentationMetrics,
} from "./generative-presentation";
import type {
AssistantResult,
PresentationInteraction,
} from "../../../shared/assets/assistant";
const result: AssistantResult = {
resultId: "generated-result",
sessionId: "demo",
plan: {
kind: "assets",
category: "inventory",
groupBy: "region",
view: "chart",
filters: {},
},
title: "库存分布",
summary: "华东库存12台,华南8台。",
source: "演示台账",
fetchedAt: "2026-09-23T08:00:00Z",
rows: [],
metrics: [
{ label: "总库存", value: 20, unit: "台" },
{ label: "异动", value: 2, unit: "台" },
],
groups: [
{
name: "华东",
count: 12,
operating: 0,
rate: 0,
keys: { region: "华东" },
},
{ name: "华南", count: 8, operating: 0, rate: 0, keys: { region: "华南" } },
],
daily: [
{ date: "2026-09-23", delivered: 1, returned: 2, replaced: 0, total: 3 },
],
presentation: {
title: "库存情况",
blocks: [
{ id: "conclusion", kind: "summary" },
{ id: "selected", kind: "metrics", metrics: ["异动", "总库存"] },
{ id: "narrative", kind: "narrative", text: "<script>不能执行</script>" },
{
id: "regions",
kind: "table",
dataset: "groups",
columns: [
{ field: "count", label: "库存数量" },
{ field: "name", label: "所属区域" },
],
interactionId: "cities",
},
],
interactions: [
{
id: "cities",
label: "查看城市分布",
kind: "group",
plan: {
kind: "assets",
category: "inventory",
groupBy: "city",
view: "chart",
filters: {},
},
},
],
},
};
const callbacks = {
ask: () => {},
operate: () => {},
restore: () => {},
activate: () => {},
showCorrection: () => {},
active: true,
subject: "演示公司",
disabled: false,
};
test("model-selected metrics and columns preserve their order and never invent values", () => {
assert.deepEqual(
presentationMetrics(result, ["异动", "不存在", "总库存"]).map(
(item) => item.value,
),
[2, 20],
);
assert.deepEqual(
presentationFields({
id: "table",
kind: "table",
dataset: "groups",
columns: [
{ field: "count", label: "数量" },
{ field: "secret", label: "无此字段" },
],
}),
[{ field: "count", label: "数量" }],
);
assert.deepEqual(presentationData(result, "groups")[0].keys, {
region: "华东",
});
assert.deepEqual(presentationData(result, "comparison"), []);
});
test("exploration uses stored interaction ID and actual clicked keys or date", () => {
const group = result.presentation!.interactions[0];
assert.deepEqual(
presentationAction(group, presentationData(result, "groups")[1]),
{ type: "explore", interactionId: "cities", keys: { region: "华南" } },
);
assert.equal(presentationAction(group, { name: "华东" }), undefined);
const point: PresentationInteraction = { ...group, id: "day", kind: "point" };
assert.deepEqual(
presentationAction(point, presentationData(result, "daily")[0]),
{ type: "explore", interactionId: "day", date: "2026-09-23" },
);
assert.equal(presentationAction(point, { name: "昨天" }), undefined);
assert.deepEqual(presentationAction({ ...group, kind: "followup" }), {
type: "explore",
interactionId: "cities",
});
});
test("semantic render respects block order, real table fields, plain text, scope and answer anchors", () => {
const html = renderToStaticMarkup(
createElement(GenerativeResultView, { ...callbacks, result }),
);
assert.ok(
html.indexOf('data-block-id="selected"') <
html.indexOf('data-block-id="regions"'),
);
assert.ok(html.indexOf(">异动</span>") < html.indexOf(">总库存</span>"));
assert.ok(html.indexOf(">库存数量</th>") < html.indexOf(">所属区域</th>"));
assert.match(html, /&lt;script&gt;不能执行&lt;\/script&gt;/);
assert.doesNotMatch(html, /<script>/);
for (const expected of [
"aq-query-generated-result",
"aq-answer-generated-result",
"查看城市分布",
"源数据更新时间:",
"未提供",
"演示公司",
"华东",
"复制完整结果",
])
assert.ok(html.includes(expected), expected);
});
test("stack layout renders each real section while tabs expand only the active section", () => {
const compound: AssistantResult = {
...result,
resultId: "parent",
layout: "stack",
presentation: undefined,
sections: [
{
id: "first",
title: "库存分析",
question: "库存",
display: "chart",
result,
},
{
id: "second",
title: "异动分析",
question: "异动",
display: "table",
result: { ...result, resultId: "second-child", summary: "异动2台。" },
},
{
id: "failed",
title: "成本分析",
question: "成本",
display: "metrics",
error: "缺少成本数据",
},
],
};
const stacked = renderToStaticMarkup(
createElement(CompoundResultView, {
...callbacks,
result: compound,
activeResultId: "second-child",
}),
);
assert.ok(stacked.includes('id="aq-query-generated-result"'));
assert.ok(stacked.includes('id="aq-query-second-child"'));
assert.ok(stacked.includes('data-section-id="first"'));
assert.match(stacked, /缺少成本数据/);
assert.doesNotMatch(stacked, /role="tablist"/);
const tabbed = renderToStaticMarkup(
createElement(CompoundResultView, {
...callbacks,
result: { ...compound, layout: "tabs" },
activeResultId: "second-child",
}),
);
assert.ok(!tabbed.includes('id="aq-query-generated-result"'));
assert.ok(tabbed.includes('id="aq-query-second-child"'));
assert.match(tabbed, /role="tablist"/);
});
test("AI summary text is honored and metrics-only layouts do not receive an unsolicited visible summary", () => {
const summary = renderToStaticMarkup(
createElement(GenerativeResultView, {
...callbacks,
result: {
...result,
presentation: {
blocks: [{ id: "s", kind: "summary", text: "异动2台,应先处理。" }],
interactions: [],
},
},
}),
);
assert.match(summary, /异动2台,应先处理。/);
const metricsOnly = renderToStaticMarkup(
createElement(GenerativeResultView, {
...callbacks,
result: {
...result,
presentation: {
blocks: [{ id: "m", kind: "metrics", metrics: ["总库存"] }],
interactions: [],
},
},
}),
);
assert.ok(
metricsOnly.indexOf(result.summary) >
metricsOnly.indexOf('<details class="aq-result-definitions">'),
);
assert.ok(!metricsOnly.includes('class="aq-answer"'));
assert.ok(metricsOnly.includes('id="aq-query-generated-result"'));
});
test("generated exploration does not add unrelated fixed followup suggestions", () => {
const markup=renderToStaticMarkup(createElement(GenerativeResultView,{
...callbacks,result:{...result,suggestions:["不应插入的固定城市路径"],presentation:{blocks:[{id:"m",kind:"metrics",metrics:["总库存"]}],interactions:[]}},
}));
assert.doesNotMatch(markup,/不应插入的固定城市路径/);
});
@@ -1,129 +0,0 @@
import type {
AssistantAction,
AssistantResult,
Dimension,
PresentationBlock,
PresentationInteraction,
} from "../../../shared/assets/assistant";
export type PresentationDatum = Record<string, unknown>;
const fields: Record<NonNullable<PresentationBlock["dataset"]>, string[]> = {
groups: [
"name",
"count",
"operating",
"rate",
"abnormal",
"pending",
"share",
],
daily: ["date", "delivered", "returned", "replaced", "total"],
comparison: ["label", "current", "previous", "delta", "rate"],
rows: [
"id",
"plate",
"vehicleClass",
"type",
"model",
"region",
"city",
"department",
"status",
"inventory",
"abnormal",
"pending",
"abnormalType",
"targetCompletionTime",
"actualCompletionTime",
"date",
"event",
],
};
export function presentationData(
result: AssistantResult,
dataset: PresentationBlock["dataset"],
): PresentationDatum[] {
return dataset ? (result[dataset] || []).map((row) => ({ ...row })) : [];
}
export function presentationFields(block: PresentationBlock) {
const allowed = block.dataset ? fields[block.dataset] : [];
const defaults =
block.dataset === "daily"
? [
{ field: "date", label: "日期" },
{ field: "total", label: "车次" },
]
: block.dataset === "comparison"
? [
{ field: "label", label: "指标" },
{ field: "current", label: "本期" },
{ field: "previous", label: "上期" },
]
: block.dataset === "rows"
? [
{ field: "plate", label: "车牌" },
{ field: "status", label: "状态" },
]
: [
{ field: "name", label: "分组" },
{ field: "count", label: "数量" },
];
return (block.columns?.length ? block.columns : defaults).filter((column) =>
allowed.includes(column.field),
);
}
export function presentationSeries(block: PresentationBlock) {
const allowed = block.dataset ? fields[block.dataset] : [];
const fallback =
block.dataset === "daily"
? { field: "total", label: "车次" }
: block.dataset === "comparison"
? { field: "current", label: "本期" }
: { field: "count", label: "数量" };
return (block.series?.length ? block.series : [fallback]).filter(
(series) =>
allowed.includes(series.field) &&
!["name", "date", "label"].includes(series.field),
);
}
export function presentationMetrics(
result: AssistantResult,
labels?: string[],
) {
return labels?.length
? labels.flatMap((label) => {
const metric = result.metrics.find((item) => item.label === label);
return metric ? [metric] : [];
})
: result.metrics;
}
export function presentationValue(value: unknown, field: string) {
if (value === undefined || value === null || value === "") return "—";
if (typeof value === "number")
return Number.isFinite(value)
? `${value.toLocaleString("zh-CN")}${["rate", "share"].includes(field) ? "%" : ""}`
: "—";
if (typeof value === "boolean") return value ? "是" : "否";
return typeof value === "string" ? value : "—";
}
export function presentationAction(
interaction: PresentationInteraction | undefined,
datum?: PresentationDatum,
): AssistantAction | undefined {
if (!interaction) return;
if (interaction.kind === "followup")
return { type: "explore", interactionId: interaction.id };
if (
interaction.kind === "group" &&
datum?.keys &&
typeof datum.keys === "object" &&
Object.keys(datum.keys).length
)
return {
type: "explore",
interactionId: interaction.id,
keys: datum.keys as Partial<Record<Dimension, string>>,
};
if (interaction.kind === "point" && typeof datum?.date === "string")
return { type: "explore", interactionId: interaction.id, date: datum.date };
}
@@ -1,225 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
copyResultText,
resultScope,
resultDisplay,
queryPath,
compactAnalysisPath,
} from "./result-presentation.js";
import type { AssistantResult } from "../../../shared/assets/assistant.js";
const result: AssistantResult = {
plan: {
kind: "assets",
category: "available",
groupBy: "region",
view: "table",
filters: { type: ["4.5T"] },
},
title: "可运营库存 · 按区域统计",
summary: "当前范围共有135台可运营库存。",
source: "资产台账",
fetchedAt: "2026-09-23T08:00:00Z",
groups: [],
rows: [],
metrics: [{ label: "可运营库存", value: 135, unit: "台" }],
scope: {
company: "演示公司",
permission: "当前账号可见车辆",
filters: ["4.5T", "可运营库存"],
grouping: "区域",
period: "当前资产台账",
definitions: ["车型按吨位汇总,4.5T包含普货与冷链"],
sourceUpdatedAt: null,
},
};
test("copy preserves facts, scope, definitions and distinguishes unknown source update from query time", () => {
const text = copyResultText(result, null);
for (const expected of [
"135台",
"公司:演示公司",
"权限范围:当前账号可见车辆",
"筛选条件:4.5T;可运营库存",
"分组:区域",
"4.5T包含普货与冷链",
"源数据更新时间:未提供",
"查询时间:",
])
assert.ok(text.includes(expected), expected);
});
test("partial briefing copies missing answers and inventory evidence without inventing a source timestamp", () => {
const partial: AssistantResult = {
...result,
outcome: "partial",
unanswered: [
{ question: "闲置损失多少钱", reason: "未接入成本数据,不估算金额。" },
],
inventoryBreakdown: {
title: "库存集中情况",
summary: "4.5T冷链库存最多。",
groups: [
{ name: "4.5T冷链", count: 155, operating: 0, rate: 0, share: 53.3 },
],
},
};
const text = copyResultText(partial, null);
assert.match(text, /4.5T冷链:155台,占库存53.3%/);
assert.match(text, /尚未回答:[\s\S]*未接入成本数据,不估算金额/);
assert.match(text, /源数据更新时间:未提供/);
});
test("copying a revoked result keeps the revocation explicit", () => {
const text = copyResultText(
result,
null,
"上一条回答的是待运营,不能用于判断待交车。",
);
assert.match(
text,
/【此回答已撤回】上一条回答的是待运营,不能用于判断待交车/,
);
});
test("fallback scope keeps exclusion and selected vehicle filters", () => {
const fallback = resultScope(
{
...result,
scope: undefined,
plan: {
...result.plan,
excludeFilters: { city: ["嘉兴市"] },
vehicleIds: ["example-1"],
exception: "overdue",
},
},
"演示公司",
);
assert.deepEqual(fallback.filters, [
"可运营库存",
"车型:4.5T",
"排除城市:嘉兴市",
"指定车辆:1台",
"已超过预计完成时间",
]);
assert.equal(fallback.sourceUpdatedAt, null);
});
test("copy includes regional facts and daily flow without duplicating groups or copying vehicle details", () => {
const regional: AssistantResult = {
...result,
groups: [
{
name: "华东",
count: 99,
operating: 0,
rate: 0,
share: 73.3,
abnormal: 1,
pending: 0,
},
],
insights: ["华东占当前可运营库存73.3%。"],
rows: [
{
id: "private-vehicle",
plate: "测试车牌不应复制",
vehicleClass: "4.5T普货",
type: "4.5T",
model: "演示型号",
region: "华东",
city: "嘉兴市",
department: "演示部门",
status: "可运营",
},
],
};
const regionalText = copyResultText(regional, null);
assert.match(regionalText, /华东:99台,占比73.3%/);
assert.match(regionalText, /异动1台,待交车0台/);
assert.match(regionalText, /分析发现:[\s\S]*华东占当前可运营库存73.3%/);
assert.ok(!regionalText.includes("测试车牌不应复制"));
const flowText = copyResultText(
{
...regional,
plan: {
...regional.plan,
kind: "flow",
start: "2026-09-23",
end: "2026-09-23",
},
daily: [
{
date: "2026-09-23",
delivered: 5,
returned: 2,
replaced: 1,
total: 7,
},
],
},
null,
);
assert.match(
flowText,
/2026-09-23:交车5车次,还车2车次,其中替换交车1车次,合计7车次/,
);
assert.match(flowText, /替换交车包含在交车中,不重复相加/);
assert.ok(!flowText.includes("华东:99台"));
assert.ok(!flowText.includes("测试车牌不应复制"));
});
test("single totals use metric cards while comparisons, trends and explicit records retain their display", () => {
const total = {
...result.plan,
groupBy: "none" as const,
view: "chart" as const,
};
assert.equal(resultDisplay(total), "metrics");
assert.equal(resultDisplay({ ...total, groupBy: "region" }), "chart");
assert.equal(resultDisplay({ ...total, secondaryGroupBy: "type" }), "chart");
assert.equal(resultDisplay({ ...total, kind: "flow" }), "chart");
assert.equal(resultDisplay({ ...total, view: "list" }), "list");
assert.equal(resultDisplay({ ...total, view: "table" }), "table");
});
test("current path retains every active slice and highlights the current grouping", () => {
const path = queryPath({
...result.plan,
filters: { region: ["华东"], city: ["嘉兴市"] },
excludeFilters: { model: ["演示型号"] },
groupBy: "vehicleClass",
exception: "overdue",
});
assert.deepEqual(path, [
"可运营库存",
"区域:华东",
"城市:嘉兴市",
"排除型号:演示型号",
"已超期",
"按车型细分统计",
]);
});
test("analysis path collapses display-only steps and preserves the latest node of each scope", () => {
const path = compactAnalysisPath([
{ resultId: "all", label: "全部库存" },
{ resultId: "east-chart", label: "库存 · 华东" },
{ resultId: "east-table", label: "库存 · 华东" },
{ resultId: "jiaxing", label: "库存 · 华东 / 嘉兴" },
]);
assert.deepEqual(
path.map((crumb) => crumb.resultId),
["all", "east-table", "jiaxing"],
);
assert.equal(path.at(-2)?.label, "库存 · 华东");
});
test("vehicle status navigation identifies a vehicle answer instead of a total", () => {
const path = queryPath({ kind: "assets", category: "all", groupBy: "none", view: "list", filters: {}, vehicleIds: ["v1"], responseMode: "vehicle-status" });
assert.ok(path.includes("车辆状态"));
assert.ok(!path.includes("总量"));
});
@@ -1,194 +0,0 @@
import {
CATEGORY_LABELS,
GROUP_LABELS,
type AssistantResult,
type Dimension,
type QueryPlan,
} from "../../../shared/assets/assistant";
export function compactAnalysisPath(
breadcrumbs: AssistantResult["breadcrumbs"] = [],
) {
const path: NonNullable<AssistantResult["breadcrumbs"]> = [];
for (const crumb of breadcrumbs) {
if (path.at(-1)?.label === crumb.label) path[path.length - 1] = crumb;
else path.push(crumb);
}
return path;
}
export function queryPath(plan: QueryPlan): string[] {
return [
plan.kind === "flow" ? "交退换车" : CATEGORY_LABELS[plan.category],
...Object.entries(plan.filters).map(
([dimension, values]) =>
`${GROUP_LABELS[dimension as Dimension]}:${values.join("、")}`,
),
...Object.entries(plan.excludeFilters || {}).map(
([dimension, values]) =>
`排除${GROUP_LABELS[dimension as Dimension]}:${values.join("、")}`,
),
...(plan.vehicleIds?.length ? [`已选${plan.vehicleIds.length}台车辆`] : []),
...(plan.exception
? [
plan.exception === "overdue"
? "已超期"
: plan.exception === "dueSoon"
? "即将到期"
: "未填写预计完成时间",
]
: []),
plan.groupBy === "none" && !plan.secondaryGroupBy
? plan.responseMode === "vehicle-status" ? "车辆状态" : "总量"
: `按${[GROUP_LABELS[plan.groupBy], plan.secondaryGroupBy && GROUP_LABELS[plan.secondaryGroupBy]].filter(Boolean).join(" × ")}统计`,
];
}
export function resultDisplay(
plan: QueryPlan,
): "metrics" | "chart" | "table" | "list" {
if (
plan.kind === "assets" &&
plan.groupBy === "none" &&
!plan.secondaryGroupBy &&
plan.view === "chart"
)
return "metrics";
return plan.view;
}
export function displayTime(value: string | null | undefined) {
if (!value) return "未提供";
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: date.toLocaleString("zh-CN", { hour12: false });
}
export function resultScope(result: AssistantResult, subject: string | null) {
if (result.scope) return result.scope;
const { plan } = result;
return {
company: subject || "全部所属公司",
permission: "当前账号可见范围",
filters: [
CATEGORY_LABELS[plan.category],
...Object.entries(plan.filters).map(
([dimension, values]) =>
`${GROUP_LABELS[dimension as Dimension]}:${values.join("、")}`,
),
...Object.entries(plan.excludeFilters || {}).map(
([dimension, values]) =>
`排除${GROUP_LABELS[dimension as Dimension]}:${values.join("、")}`,
),
...(plan.vehicleIds?.length
? [`指定车辆:${plan.vehicleIds.length}台`]
: []),
...(plan.exception
? [
plan.exception === "overdue"
? "已超过预计完成时间"
: plan.exception === "dueSoon"
? "即将到期"
: "未填写预计完成时间",
]
: []),
],
grouping: [
GROUP_LABELS[plan.groupBy],
plan.secondaryGroupBy && GROUP_LABELS[plan.secondaryGroupBy],
]
.filter(Boolean)
.join(" × "),
period: plan.start
? `${plan.start} — ${plan.end || plan.start}`
: "当前资产台账",
definitions: [],
sourceUpdatedAt: null,
};
}
export function copyResultText(
result: AssistantResult,
subject: string | null,
correctionMessage?: string,
): string {
if (result.sections?.length) return [
result.title, result.summary,
...(correctionMessage ? [`【此回答已撤回】${correctionMessage}`] : []),
...result.sections.map((section, index) => `\n${index + 1}. ${section.title}\n${section.result ? copyResultText(section.result, subject) : section.error || "暂未完成"}`),
...(result.unanswered?.length ? ["尚未回答:", ...result.unanswered.map(item => `${item.question}:${item.reason}`)] : []),
].join("\n");
const scope = resultScope(result, subject);
return [
result.presentation?.title || result.title,
...(correctionMessage ? [`【此回答已撤回】${correctionMessage}`] : []),
...(result.correction ? [`【纠正说明】${result.correction.message}`] : []),
result.summary,
...(result.presentation?.blocks.filter(block => block.kind === "narrative" && block.text).map(block => `${block.title ? `${block.title}:` : ""}${block.text}`) || []),
...result.metrics.map(
(metric) =>
`${metric.label}:${metric.value.toLocaleString()}${metric.unit}`,
),
...(result.plan.kind === "assets" &&
result.plan.analysis !== "briefing" &&
result.groups.length
? [
"分组统计:",
...result.groups.map(
(group) =>
`${group.name}:${group.count}台${group.share === undefined ? "" : `,占比${group.share}%`},运营率${group.rate}%${group.abnormal === undefined ? "" : `,异动${group.abnormal}台`}${group.pending === undefined ? "" : `,待交车${group.pending}台`}`,
),
]
: []),
...(result.plan.kind === "flow" && result.daily?.length
? [
"每日流转(车次):替换交车包含在交车中,不重复相加。",
...result.daily.map(
(day) =>
`${day.date}:交车${day.delivered}车次,还车${day.returned}车次,其中替换交车${day.replaced}车次,合计${day.total}车次`,
),
]
: []),
...(result.insights?.length ? ["分析发现:", ...result.insights] : []),
...(result.inventoryBreakdown
? [
result.inventoryBreakdown.title,
result.inventoryBreakdown.summary,
...result.inventoryBreakdown.groups.map(
(group) =>
`${group.name}:${group.count}台${group.share === undefined ? "" : `,占库存${group.share}%`}`,
),
]
: []),
...(result.comparison?.length
? [
`对比期间:${result.comparisonPeriod?.start || "未提供"} — ${result.comparisonPeriod?.end || "未提供"}`,
...result.comparison.map(
(metric) =>
`${metric.label}:本期${metric.current},上期${metric.previous},差值${metric.delta},变化率${metric.rate === null ? "基期为0,不计算" : `${metric.rate}%`}`,
),
]
: []),
...(result.unanswered?.length
? [
"尚未回答:",
...result.unanswered.map(
(item) => `${item.question}:${item.reason}`,
),
]
: []),
`公司:${scope.company}`,
`权限范围:${scope.permission}`,
`筛选条件:${scope.filters.join(";") || "无额外筛选"}`,
`分组:${scope.grouping}`,
`统计期间:${scope.period}`,
...(result.plan.topN
? [`仅展示前${result.plan.topN}组;总计与明细包含全部匹配车辆`]
: []),
...scope.definitions.map((definition) => `统计口径:${definition}`),
`来源:${result.source}`,
`源数据更新时间:${displayTime(scope.sourceUpdatedAt)}`,
`查询时间:${displayTime(result.fetchedAt)}`,
].join("\n");
}
@@ -1,60 +0,0 @@
import Blur from '../../../components/Blur';
import type { VehicleListItem } from '../types';
type DetailsProps = { vehicles: VehicleListItem[] };
function InventoryVehicleDetails({ vehicles, kind }: DetailsProps & { kind: 'abnormal' | 'pending' }) {
const pending = kind === 'pending';
const labels = pending ? ['合同编号', '客户名称(已脱敏)'] : ['异动类型', '审批通过时间', '已异动时长', '具体工作', '申请人', '预计完成时间', '实际完成时间'];
const details = (vehicle: VehicleListItem) => pending
? [vehicle.pendingDelivery?.contractNo || '未提供', vehicle.pendingDelivery?.customerName || '未提供']
: [
vehicle.abnormalMove?.type || '未提供',
vehicle.abnormalMove?.approvalPassedAt ? new Date(vehicle.abnormalMove.approvalPassedAt).toLocaleString('zh-CN', { hour12: false }) : '审批时间缺失',
vehicle.abnormalMove?.elapsedDays != null ? `${vehicle.abnormalMove.elapsedDays.toFixed(1)} 天` : '无法计算',
vehicle.abnormalMove?.work || '未提供',
vehicle.abnormalMove?.applicant || '未提供',
vehicle.abnormalMove?.targetCompletionTime || '未提供',
vehicle.abnormalMove?.actualCompletionTime || (vehicle.abnormalMove?.status === '待结束' ? '未完成' : '未提供'),
];
if (vehicles.length === 0) return <div className="py-14 text-center text-sm text-slate-500">暂无符合筛选条件的{pending ? '待交车' : '异动车辆'}</div>;
return <>
<p className="px-4 py-3 text-xs leading-5 text-slate-500">{pending ? '展示当前待交车任务的合同信息,客户名称已脱敏。' : '申请人取异动单创建人;未结束的异动显示“未完成”,缺失信息显示“未提供”。'}</p>
<div className="space-y-3 px-3 pb-3 md:hidden">
{vehicles.map(vehicle => <article key={vehicle.id} className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<h4 className="break-all font-mono text-base font-bold text-blue-700"><Blur>{vehicle.plateNumber || vehicle.vin || '—'}</Blur></h4>
<p className="mt-1 text-xs text-slate-500">{vehicle.brandLabel || '—'} · {vehicle.type} · {vehicle.location === '其他' ? '对接中' : vehicle.location}</p>
<dl className="mt-4 space-y-3 text-sm">
{details(vehicle).map((value, index) => <div key={labels[index]} className="grid grid-cols-[6rem_minmax(0,1fr)] gap-2">
<dt className="text-slate-500">{labels[index]}</dt>
<dd className="whitespace-pre-wrap break-words font-medium text-slate-800 [overflow-wrap:anywhere]"><Blur>{value}</Blur></dd>
</div>)}
</dl>
</article>)}
</div>
<div className="hidden overflow-x-auto md:block" tabIndex={0} role="region" aria-label={pending ? "待交车明细表" : "异动明细表,可横向滚动"}>
<table className={`w-full ${pending ? "min-w-[700px]" : "min-w-[1160px]"} border-collapse text-left text-sm`}>
<thead className="sticky top-0 z-20 bg-slate-700 text-white"><tr>
{['车牌', '品牌', '车型', '所在地', ...labels].map(label => <th key={label} scope="col" className={`whitespace-nowrap p-3 ${label === "具体工作" ? "w-64" : ""}`}>{label}</th>)}
</tr></thead>
<tbody>{vehicles.map(vehicle => <tr key={vehicle.id} className="border-b border-slate-200 bg-white align-top">
<td className="p-3 font-mono font-bold text-blue-700"><Blur>{vehicle.plateNumber || vehicle.vin || '—'}</Blur></td>
<td className="p-3 text-slate-600">{vehicle.brandLabel || '—'}</td>
<td className="p-3 text-slate-600">{vehicle.type}</td>
<td className="p-3 text-slate-600">{vehicle.location === '其他' ? '对接中' : vehicle.location}</td>
{details(vehicle).map((value, index) => <td key={labels[index]} className={`whitespace-pre-wrap break-words p-3 leading-6 text-slate-700 [overflow-wrap:anywhere] ${labels[index] === "具体工作" ? "min-w-56 max-w-72" : "min-w-28 max-w-48"}`}><Blur>{value}</Blur></td>)}
</tr>)}</tbody>
</table>
</div>
</>;
}
export function AbnormalVehicleDetails(props: DetailsProps) {
return <InventoryVehicleDetails {...props} kind="abnormal" />;
}
export function PendingVehicleDetails(props: DetailsProps) {
return <InventoryVehicleDetails {...props} kind="pending" />;
}
@@ -1,225 +0,0 @@
import React from 'react';
import { ChevronDown, Filter, Loader2, Search } from 'lucide-react';
import { motion } from 'motion/react';
import type { SubjectOption } from '../api';
export type AssetsTab = 'overview' | 'department' | 'region' | 'customer';
export type AssetsTheme = 'soft' | 'minimal' | 'vibrant';
interface AssetsHeaderProps {
activeTab: AssetsTab;
setActiveTab: React.Dispatch<React.SetStateAction<AssetsTab>>;
theme: AssetsTheme;
setTheme: React.Dispatch<React.SetStateAction<AssetsTheme>>;
lastUpdate: string;
loading: boolean;
selectedSubject: string | null;
setSelectedSubject: React.Dispatch<React.SetStateAction<string | null>>;
subjects: SubjectOption[];
subjectDropdownOpen: boolean;
setSubjectDropdownOpen: React.Dispatch<React.SetStateAction<boolean>>;
subjectSearch: string;
setSubjectSearch: React.Dispatch<React.SetStateAction<string>>;
subjectDropdownRef: React.RefObject<HTMLDivElement | null>;
}
// --- Constants ---
const TABS = [
{ id: 'overview', label: '总览' },
{ id: 'department', label: '按部门' },
{ id: 'region', label: '按区域' },
{ id: 'customer', label: '按客户' },
];
export function AssetsHeader({
activeTab,
setActiveTab,
theme,
setTheme,
lastUpdate,
loading,
selectedSubject,
setSelectedSubject,
subjects,
subjectDropdownOpen,
setSubjectDropdownOpen,
subjectSearch,
setSubjectSearch,
subjectDropdownRef,
}: AssetsHeaderProps) {
return (
<>
{/* Compact Header Bar */}
<div className="sticky top-0 z-40 -mx-3 -mt-3 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80 md:-mx-6 md:-mt-6">
{/* Title row */}
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">羚牛氢能-资产BI</h1>
{/* Right: status + theme */}
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse inline-block" />
<span>{lastUpdate}</span>
</div>
{loading && (
<div className="flex items-center gap-1 text-[10px] text-gray-400">
<Loader2 className="animate-spin" size={10} />
</div>
)}
<div className="hidden sm:flex bg-gray-100 p-0.5 rounded-lg text-[10px]">
{(['soft','minimal','vibrant'] as const).map((t) => (
<button
key={t}
onClick={() => setTheme(t)}
className={`px-2 py-0.5 rounded-md transition-all ${theme === t ? 'bg-white text-blue-600 shadow-sm font-semibold' : 'text-gray-400 hover:text-gray-600'}`}
>
{t === 'soft' ? '柔和' : t === 'minimal' ? '简约' : '经典'}
</button>
))}
</div>
</div>
</div>
{/* 归属公司作用域筛选 (Scope Chip) */}
<div className="flex items-center justify-center px-4 pt-1">
<div className="relative" ref={subjectDropdownRef}>
<button
type="button"
onClick={() => {
setSubjectDropdownOpen((o) => !o);
setSubjectSearch('');
}}
className={`group inline-flex items-center gap-1.5 h-7 pl-2.5 pr-2 rounded-full border text-[11px] font-normal transition-all cursor-pointer ${
selectedSubject
? 'bg-blue-50 border-blue-300 text-blue-700 hover:bg-blue-100'
: 'bg-white border-gray-200 text-gray-500 hover:border-gray-300 hover:text-gray-700'
}`}
title={selectedSubject || '全部公司'}
>
<Filter size={11} className={selectedSubject ? 'text-blue-500' : 'text-gray-400'} />
<span className="max-w-[180px] truncate">
所属公司:{selectedSubject || '全部公司'}
</span>
{selectedSubject ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
setSelectedSubject(null);
}}
className="ml-0.5 w-3.5 h-3.5 inline-flex items-center justify-center rounded-full text-blue-500 hover:bg-blue-200 hover:text-blue-700 cursor-pointer"
aria-label="清除归属公司筛选"
>
×
</span>
) : (
<ChevronDown size={11} className="text-gray-400" />
)}
</button>
{subjectDropdownOpen && (
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1.5 w-[320px] max-h-[380px] bg-white border border-gray-200 rounded-lg shadow-lg z-50 flex flex-col">
<div className="p-2 border-b border-gray-100">
<div className="relative">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
<input
autoFocus
value={subjectSearch}
onChange={(e) => setSubjectSearch(e.target.value)}
placeholder="搜索公司名"
className="w-full h-7 pl-6 pr-2 text-[11px] bg-gray-50 border border-gray-100 rounded focus:outline-none focus:border-blue-300 focus:bg-white"
/>
</div>
</div>
<div className="overflow-y-auto flex-1 py-1">
<button
type="button"
onClick={() => {
setSelectedSubject(null);
setSubjectDropdownOpen(false);
}}
className={`w-full flex items-center justify-between px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
!selectedSubject ? 'text-blue-600 font-medium' : 'text-gray-700'
}`}
>
<span className="flex items-center gap-1.5">
<span className={`w-1.5 h-1.5 rounded-full ${!selectedSubject ? 'bg-blue-500' : 'bg-gray-300'}`} />
全部公司
</span>
<span className="text-[10px] text-gray-400">
{subjects.reduce((s, x) => s + x.total, 0)} 台
</span>
</button>
<div className="my-1 mx-3 border-t border-gray-100" />
{subjects
.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase()))
.map((s) => {
const active = selectedSubject === s.name;
return (
<button
key={s.name}
type="button"
onClick={() => {
setSelectedSubject(s.name);
setSubjectDropdownOpen(false);
}}
className={`w-full flex items-center justify-between gap-2 px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
active ? 'text-blue-600 font-medium bg-blue-50/40' : 'text-gray-700'
}`}
title={s.name}
>
<span className="flex items-center gap-1.5 min-w-0">
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${active ? 'bg-blue-500' : 'bg-gray-300'}`} />
<span className="truncate">{s.name}</span>
</span>
<span className="text-[10px] text-gray-400 flex-shrink-0 tabular-nums">
{s.total} 台
<span className="mx-1 text-gray-200">·</span>
<span className="text-green-500">运营 {s.operating}</span>
</span>
</button>
);
})}
{subjects.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())).length === 0 && (
<div className="px-3 py-6 text-center text-[11px] text-gray-400">未找到匹配公司</div>
)}
</div>
</div>
)}
</div>
</div>
{/* Tab row */}
<div className="flex items-center justify-center gap-1 px-4 pb-0 overflow-x-auto no-scrollbar">
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`relative px-4 py-2 text-[13px] font-normal transition-all whitespace-nowrap ${
activeTab === tab.id
? 'text-blue-600 font-medium'
: 'text-gray-400 hover:text-gray-500'
}`}
>
{tab.label}
{activeTab === tab.id && (
<motion.div
layoutId="activeTab"
className="absolute bottom-0 left-2 right-2 h-[1.5px] bg-blue-600 rounded-full"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
)}
</button>
))}
</div>
{/* Status row */}
<div className="flex flex-wrap items-center justify-center gap-x-1 gap-y-0.5 px-3 py-1.5 text-[10px] text-gray-400">
<div className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
数据更新时间:{lastUpdate.slice(0, 16)}
</div>
<span aria-hidden="true">|</span>
<span className="whitespace-nowrap">运维台账每日统计口径:当日17:00</span>
</div>
</div>
</>
);
}
@@ -1,174 +0,0 @@
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>;
}
@@ -1,149 +0,0 @@
import React from 'react';
import { Download, X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { FlowDetailItem, FlowType } from '../api';
import type { FlowSelection } from '../model';
export const FLOW_META: Record<FlowType, { label: string; tone: string; chip: string }> = {
delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' },
returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' },
replaced: { label: '替换交车', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' },
};
interface FlowDetailModalProps {
selectedFlow: FlowSelection | null;
selectedFlowDetails: FlowDetailItem[];
setSelectedFlow: React.Dispatch<React.SetStateAction<FlowSelection | null>>;
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
}
export function FlowDetailModal({
selectedFlow,
selectedFlowDetails,
setSelectedFlow,
exportFlowDetails,
}: FlowDetailModalProps) {
const dateLabel = selectedFlow
? ('date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`)
: '';
return (
<>
{/* Flow Detail Modal */}
<AnimatePresence>
{selectedFlow && (
<div className="fixed inset-0 z-[1000] flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-4">
<motion.div
data-testid="asset-flow-detail-modal"
initial={{ opacity: 0, y: 28, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
className="flex max-h-[88vh] w-full flex-col overflow-hidden rounded-t-3xl bg-white shadow-2xl sm:max-w-5xl sm:rounded-3xl"
>
<div className="border-b border-slate-100 bg-slate-950 px-4 py-4 text-white sm:px-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400">运维签章时间口径 · 单位:车次</div>
<h3 className="mt-1 text-lg font-black">
{dateLabel} · {FLOW_META[selectedFlow.type].label}明细
</h3>
<div className="mt-1 text-[12px] font-bold text-slate-300">
共 {selectedFlowDetails.length} 车次,包含车牌、运维签章时间、建单时间、部门、业务负责人和客户
</div>
</div>
<button
type="button"
aria-label="关闭流转明细"
onClick={() => setSelectedFlow(null)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
>
<X size={18} />
</button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => exportFlowDetails(selectedFlowDetails, `${dateLabel}-${FLOW_META[selectedFlow.type].label}明细`)}
disabled={selectedFlowDetails.length === 0}
className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
>
<Download size={13} />
导出当前明细
</button>
</div>
</div>
<div className="overflow-auto bg-slate-50 p-3 sm:p-4">
<div className="hidden overflow-hidden rounded-2xl border border-slate-100 bg-white lg:block">
<table className="w-full table-fixed text-left">
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
<tr>
<th className="w-28 px-3 py-3">车牌</th>
<th className="w-40 px-3 py-3">运维签章时间</th>
<th className="w-40 px-3 py-3">建单时间</th>
<th className="w-36 px-3 py-3">部门</th>
<th className="w-28 px-3 py-3">业务负责人</th>
<th className="px-3 py-3">客户</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-[12px] font-bold text-slate-700">
{selectedFlowDetails.map((item) => (
<tr key={item.id} className="hover:bg-blue-50/40">
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}{item.type === 'replaced' && <span className="mt-1 block text-[10px] text-violet-600">替换交车</span>}</td>
<td className="px-3 py-3 text-slate-500">{item.eventTime || '-'}</td>
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
<td className="px-3 py-3">{item.department || '-'}</td>
<td className="px-3 py-3">{item.manager || '-'}</td>
<td className="px-3 py-3">{item.customerName || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="space-y-2 lg:hidden">
{selectedFlowDetails.map((item) => (
<div key={item.id} className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-base font-black text-slate-950">{item.plateNumber}</div>
<div className={`mt-1 inline-flex rounded-full border px-2 py-0.5 text-[10px] font-black ${FLOW_META[item.type].chip}`}>
{item.typeLabel}
</div>
</div>
<div className="text-right text-[10px] font-bold text-slate-400">
<div>建单</div>
<div className="mt-0.5 text-[11px] text-blue-600">{item.submitTime?.slice(5, 16) || '-'}</div>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">运维签章时间</div>
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">业务负责人</div>
<div className="mt-1 font-bold text-slate-700">{item.manager || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">部门</div>
<div className="mt-1 font-bold text-slate-700">{item.department || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">客户</div>
<div className="mt-1 line-clamp-2 font-bold text-slate-700">{item.customerName || '-'}</div>
</div>
</div>
</div>
))}
</div>
{selectedFlowDetails.length === 0 && (
<div className="rounded-2xl bg-white px-4 py-10 text-center text-sm font-bold text-slate-400">当前日期暂无明细</div>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</>
);
}
@@ -1,64 +0,0 @@
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}
/>
</>
);
}
@@ -1,292 +0,0 @@
import React, { useCallback, useRef } from 'react';
import { AbnormalVehicleDetails, PendingVehicleDetails } from './AbnormalVehicleDetails';
import { useDialogFocus } from './useDialogFocus';
import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import Blur from '../../../components/Blur';
import { SearchSelect } from '../../../components/SearchSelect';
import type { WeeklyDetailItem } from '../api';
import type { ModalVehicleFilters, VehicleModalSelection } from '../model';
import type { VehicleListItem } from '../types';
interface VehicleDetailModalProps {
selection: VehicleModalSelection | null;
setSelection: React.Dispatch<React.SetStateAction<VehicleModalSelection | null>>;
isFilterExpanded: boolean;
setIsFilterExpanded: React.Dispatch<React.SetStateAction<boolean>>;
filters: ModalVehicleFilters;
setFilters: React.Dispatch<React.SetStateAction<ModalVehicleFilters>>;
plates: string[];
models: string[];
brands: string[];
locations: string[];
loading: boolean;
weeklyDetails: WeeklyDetailItem[];
filteredWeeklyDetails: WeeklyDetailItem[];
filteredVehicles: VehicleListItem[];
}
export function VehicleDetailModal({
selection: showPlateNumbers,
setSelection: setShowPlateNumbers,
isFilterExpanded: isModalFilterExpanded,
setIsFilterExpanded: setIsModalFilterExpanded,
filters: modalFilters,
setFilters: setModalFilters,
plates: uniqueModalPlates,
models: uniqueModalModels,
brands: uniqueModalBrands,
locations: uniqueModalLocations,
loading: modalLoading,
weeklyDetails: modalWeeklyDetail,
filteredWeeklyDetails: filteredModalWeeklyDetail,
filteredVehicles: filteredModalVehicles,
}: VehicleDetailModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => setShowPlateNumbers(null), [setShowPlateNumbers]);
useDialogFocus(Boolean(showPlateNumbers), dialogRef, close);
return (
<>
{/* Vehicle Detail Modal */}
<AnimatePresence>
{showPlateNumbers && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<motion.div
ref={dialogRef} role="dialog" aria-modal="true" aria-label={showPlateNumbers.title || "车辆明细"} tabIndex={-1}
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
className={`bg-white rounded-xl shadow-2xl w-full max-w-[95vw] ${showPlateNumbers.source === 'customer' || showPlateNumbers.category === 'Abnormal' ? 'lg:max-w-6xl' : 'lg:max-w-4xl'} overflow-hidden flex flex-col max-h-[85vh] sm:max-h-[90vh] min-h-[40vh]`}
>
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-slate-800 text-white shrink-0">
<div>
<h3 className="font-bold text-base flex items-center gap-2">
<Truck size={18} className="text-blue-400" />
{showPlateNumbers.title || (
(showPlateNumbers.manager ? `${showPlateNumbers.manager} 的${showPlateNumbers.type || ''}车辆` :
showPlateNumbers.customer ? `${showPlateNumbers.customer} 的${showPlateNumbers.type || ''}车辆` :
showPlateNumbers.batch === 'All' ? '全量批次' : `${showPlateNumbers.batch} 批次`) + ' - 运营明细'
)}
</h3>
<p className="text-[10px] opacity-60 mt-0.5">
{showPlateNumbers.scopeLabel || <>{showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} |
{showPlateNumbers.category === 'Pending' ? '待交车' :
showPlateNumbers.category === 'Delivered' ? '本周已交车' :
showPlateNumbers.category === 'Returned' ? '已还车' :
showPlateNumbers.category === 'Replaced' ? '已替换' :
showPlateNumbers.category === 'Abnormal' ? '异动' :
showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` :
showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}</>}
</p>
</div>
<button aria-label="关闭车辆明细" onClick={close} className="hover:bg-white/10 p-2 rounded-full transition-colors">
<PlusCircle className="rotate-45" size={24} />
</button>
</div>
{/* Modal Filters */}
<div className="px-4 py-2 bg-slate-50 border-b border-gray-200 shrink-0">
<div
className="flex justify-between items-center cursor-pointer py-1"
onClick={() => setIsModalFilterExpanded(!isModalFilterExpanded)}
>
<div className="flex items-center gap-2 text-slate-600">
<Filter size={14} />
<span className="text-xs font-bold">筛选条件</span>
</div>
<div className="flex items-center gap-3">
{/* Quick Search always visible when collapsed */}
{!isModalFilterExpanded && (
<div className="w-40 sm:w-64" onClick={(e) => e.stopPropagation()}>
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="快速搜索车牌..." className="text-[11px] py-1 px-2" />
</div>
)}
<motion.div
animate={{ rotate: isModalFilterExpanded ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown size={16} className="text-slate-400" />
</motion.div>
</div>
</div>
<AnimatePresence>
{isModalFilterExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 py-3 border-t border-gray-100 mt-1">
<div className="space-y-1">
<label className="block text-[10px] text-gray-500 font-medium">车牌号码</label>
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="全部车牌" className="text-[11px] py-1.5 px-2" />
</div>
<div className="space-y-1">
<label className="block text-[10px] text-gray-500 font-medium">车型型号</label>
<select value={modalFilters.model} onChange={(e) => setModalFilters({...modalFilters, model: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部车型</option>
{uniqueModalModels.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
<div className="space-y-1">
<label className="block text-[10px] text-gray-500 font-medium">品牌名称</label>
<select value={modalFilters.brand} onChange={(e) => setModalFilters({...modalFilters, brand: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部品牌</option>
{uniqueModalBrands.map(b => <option key={b} value={b}>{b}</option>)}
</select>
</div>
<div className="space-y-1">
<label className="block text-[10px] text-gray-500 font-medium">运营所在地</label>
<select value={modalFilters.location} onChange={(e) => setModalFilters({...modalFilters, location: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部地区</option>
{uniqueModalLocations.map(l => <option key={l} value={l}>{l}</option>)}
</select>
</div>
</div>
<div className="flex justify-end pb-2">
<button
onClick={() => setModalFilters({ plateNumber: '', model: '', brand: '', location: '' })}
className="text-[10px] text-blue-500 hover:text-blue-600 font-medium"
>
重置筛选
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="flex-1 overflow-auto p-0 sm:p-4 bg-gray-50 min-h-0 overscroll-contain">
{modalLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="animate-spin text-blue-500" size={32} />
</div>
) : showPlateNumbers.category === 'Abnormal' ? (
<AbnormalVehicleDetails vehicles={filteredModalVehicles} />
) : showPlateNumbers.category === 'Pending' ? (
<PendingVehicleDetails vehicles={filteredModalVehicles} />
) : modalWeeklyDetail.length > 0 ? (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden w-full">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-700 text-white text-[10px] uppercase tracking-wider sticky top-0 z-20 shadow-sm">
<th className="p-2 font-semibold border-r border-slate-600 text-center">车牌</th>
<th className="p-2 font-semibold border-r border-slate-600 text-center">客户名称</th>
<th className="p-2 font-semibold text-center">日期</th>
</tr>
</thead>
<tbody className="text-[11px]">
{filteredModalWeeklyDetail.map((v, i) => (
<tr key={`${v.truck_id}-${i}`} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors ${i % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center"><Blur>{v.plate_number}</Blur></td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{v.customer_name || '—'}</Blur></td>
<td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm w-full">
<table className="min-w-full w-max text-left border-collapse">
<thead className="sticky top-0 z-20 shadow-sm">
<tr className="bg-slate-700 text-white text-[10px] uppercase tracking-wider whitespace-nowrap">
{showPlateNumbers.source === 'customer' ? (
<>
<th className="p-2 font-semibold border-r border-slate-600 w-24">业务部门</th>
<th className="p-2 font-semibold border-r border-slate-600 w-24">业务负责人</th>
<th className="p-2 font-semibold border-r border-slate-600 w-16">品牌</th>
<th className="p-2 font-semibold border-r border-slate-600 w-16">车型</th>
<th className="p-2 font-semibold border-r border-slate-600 w-48">资产归属</th>
<th className="p-2 font-semibold border-r border-slate-600 w-48">客户名称</th>
<th className="p-2 font-semibold border-r border-slate-600 w-24">车牌</th>
<th className="p-2 font-semibold border-r border-slate-600 w-16 text-center">状态</th>
<th className="p-2 font-semibold border-r border-slate-600 w-24 text-center">提车时间</th>
<th className="p-2 font-semibold border-r border-slate-600 w-24 text-center">合同到期时间</th>
<th className="p-2 font-semibold border-r border-slate-600 w-24">运营区域</th>
<th className="p-2 font-semibold border-r border-slate-600 w-20 text-center">离到期</th>
<th className="p-2 font-semibold w-48">签约公司</th>
</>
) : (
<>
<th className="p-2 font-semibold border-r border-slate-600 text-center">车牌</th>
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
<th className="p-2 font-semibold border-r border-slate-600 text-center">客户名称</th>
)}
<th className="p-2 font-semibold border-r border-slate-600 text-center">品牌</th>
<th className="p-2 font-semibold border-r border-slate-600 text-center">车型</th>
<th className="p-2 font-semibold text-center">所在地</th>
</>
)}
</tr>
</thead>
<tbody className="text-[11px]">
{filteredModalVehicles.map((v, idx) => (
<tr key={v.id} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors whitespace-nowrap ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
{showPlateNumbers.source === 'customer' ? (
<>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.departmentName || '—'}</td>
<td className="p-2 border-r border-gray-100 font-medium text-gray-700"><Blur>{v.customerManager || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.brandLabel || '—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.type}</td>
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]"><Blur>{v.subjectOrg || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 font-bold text-gray-800"><Blur>{v.customerName || '—'}</Blur></td>
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 text-center">
<span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${
v.status === 'Operating' ? 'bg-green-100 text-green-700' :
v.status === 'Inventory' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'
}`}>
{v.status === 'Operating' ? '在租' : v.status === 'Inventory' ? '库存' : '异常'}
</span>
</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.location === '其他' ? '对接中' : v.location}</td>
<td className="p-2 border-r border-gray-100 text-center font-bold text-orange-600">{'—'}</td>
<td className="p-2 text-gray-500 text-[10px]"><Blur>{v.orgName || '—'}</Blur></td>
</>
) : (
<>
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center"><Blur>{v.customerName || '—'}</Blur></td>
)}
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td>
<td className="p-2 text-gray-600 text-center">{v.location === '其他' ? '对接中' : v.location}</td>
</>
)}
</tr>
))}
{filteredModalVehicles.length === 0 && (
<tr>
<td colSpan={showPlateNumbers.source === 'customer' ? 13 : ((showPlateNumbers.source === 'asset' || showPlateNumbers.category === 'Inventory') ? 4 : 5)} className="p-8 text-center text-gray-400 italic">
暂无符合条件的车辆数据
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
<div className="p-4 bg-white border-t border-gray-100 flex justify-between items-center shrink-0">
<div className="text-xs text-gray-500">
共计 <span className="font-bold text-blue-600">{filteredModalWeeklyDetail.length > 0 ? filteredModalWeeklyDetail.length : filteredModalVehicles.length}</span> 台车辆
</div>
<button
onClick={() => setShowPlateNumbers(null)}
className="px-6 py-2 bg-slate-800 text-white rounded-lg text-xs font-bold hover:bg-slate-700 transition-colors shadow-sm"
>
确认并关闭
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</>
);
}
@@ -1,89 +0,0 @@
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>;
}
@@ -1,23 +0,0 @@
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>;
}
@@ -1,296 +0,0 @@
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>;
}
@@ -1,77 +0,0 @@
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>;
}
@@ -1,18 +0,0 @@
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>;
}
@@ -1,177 +0,0 @@
.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; }
@@ -1,34 +0,0 @@
.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; }
@@ -1,5 +0,0 @@
.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; }
@@ -1,48 +0,0 @@
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 };
}
@@ -1,87 +0,0 @@
.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); }
@@ -1,203 +0,0 @@
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>
);
}
@@ -1,129 +0,0 @@
import { ChevronDown, ChevronRight } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { AssetSummarySectionProps } from './types';
export function AssetSummaryMobileCards({
processedData,
theme,
expandedAssetTypes,
toggleAssetType,
expandedModels,
toggleModel,
setVehicleSelection: setShowPlateNumbers,
}: AssetSummarySectionProps) {
return (
<div className="lg:hidden p-4 space-y-4">
{processedData.map((typeGroup) => (
<div key={typeGroup.type} className="space-y-3">
<div
className={`px-3 py-2 rounded flex justify-between items-center shadow-sm cursor-pointer transition-all ${
theme === 'vibrant' ? 'bg-blue-600 text-white active:bg-blue-700' :
theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 text-gray-800 active:bg-gray-50' :
'bg-blue-50 border border-blue-100 text-blue-700 active:bg-blue-100'
}`}
onClick={() => toggleAssetType(typeGroup.type)}
>
<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-blue-300'} />
}
<span className="text-xs font-bold">{typeGroup.type}</span>
</div>
<div className={`flex gap-3 text-[9px] font-normal ${theme === 'vibrant' ? 'opacity-90' : 'text-gray-500'}`}>
<span>资产 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
<span>库存 <span className={theme === 'vibrant' ? 'font-bold' : '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' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
</div>
</div>
<AnimatePresence>
{expandedAssetTypes.has(typeGroup.type) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="space-y-3 overflow-hidden"
>
{typeGroup.models.map((model) => (
<div key={model.model} className="bg-white rounded-lg border border-gray-100 shadow-sm overflow-hidden">
<div
className="p-3 flex justify-between items-center cursor-pointer active:bg-gray-50"
onClick={() => toggleModel(model.model)}
>
<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="text-xs font-bold text-gray-700">{model.model}</span>
</div>
<div className="flex flex-wrap justify-end gap-2">
<span className="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-blue-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model }); }}>资产 {model.total}</span>
<span className="text-[10px] bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-orange-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` }); }}>库存 {model.inventory}</span>
<button type="button" className="text-[10px] bg-amber-50 text-amber-700 px-1.5 py-0.5 rounded font-bold" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Abnormal', source: 'asset', title: `${model.model} - 异动` }); }}>异动 {model.abnormal}</button>
<span className="text-[10px] bg-green-50 text-green-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-green-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` }); }}>运营 {model.operating}</span>
</div>
</div>
{expandedModels.has(model.model) && (
<div className="px-3 pb-3 pt-1 border-t border-gray-50 bg-gray-50/30">
<div className="grid grid-cols-2 gap-y-3 gap-x-4 mt-2">
<div className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` })}>
<span className="text-[10px] text-gray-400">总库存</span>
<span className="text-xs font-bold text-blue-600">{model.inventory}</span>
</div>
<div className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` })}>
<span className="text-[10px] text-gray-400">待交车</span>
<span className="text-xs font-bold text-gray-600">{model.pending}</span>
</div>
<div className="col-span-2 grid grid-cols-5 gap-1 py-2 border-y border-gray-100">
{['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => (
<div key={reg} className="text-center">
<div className="text-[8px] text-gray-400 mb-0.5">{reg === '嘉兴' ? '浙' : reg === '广东' ? '粤' : reg === '北京' ? '京' : reg === '新疆' ? '新' : '其'}</div>
{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-[10px] font-bold text-blue-500 hover:underline"
>
{model.inventoryRegions[reg]}
</button>
) : (
<div className="text-[10px] font-bold text-gray-300">-</div>
)}
</div>
))}
</div>
<div className="col-span-2 grid grid-cols-3 gap-2 pt-1">
<div className="bg-blue-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-blue-100/50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` })}>
<span className="text-[8px] text-gray-400 mb-1">本周已交车</span>
<span className="text-xs font-bold text-blue-600">{model.weeklyDelivered}</span>
</div>
<div className="bg-orange-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-orange-100/50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` })}>
<span className="text-[8px] text-gray-400 mb-1">已还车</span>
<span className="text-xs font-bold text-orange-600">{model.weeklyReturned}</span>
</div>
<div className="bg-purple-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-purple-100/50 transition-colors"
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` })}>
<span className="text-[8px] text-gray-400 mb-1">已替换</span>
<span className="text-xs font-bold text-purple-600">{model.weeklyReplaced}</span>
</div>
</div>
</div>
</div>
)}
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
);
}
@@ -1,26 +0,0 @@
import { Info } from 'lucide-react';
import { AssetSummaryDesktopTable } from './AssetSummaryDesktopTable';
import { AssetSummaryMobileCards } from './AssetSummaryMobileCards';
import type { AssetSummarySectionProps } from './types';
export function AssetSummarySection(props: AssetSummarySectionProps) {
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden mb-6">
<div className="p-4 border-b border-gray-50 bg-gray-50/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
<div className="flex flex-wrap items-center gap-4 sm:gap-6">
<h2 className="text-sm font-bold text-gray-700">资产数据实时汇总</h2>
<div className="hidden md:flex items-center gap-1 text-[10px] text-blue-500 bg-blue-50 px-2 py-0.5 rounded">
<Info size={10} />
点击型号展开详情
</div>
</div>
</div>
{/* Desktop View Table */}
<AssetSummaryDesktopTable {...props} />
{/* Mobile View Cards for Asset Summary */}
<AssetSummaryMobileCards {...props} />
</div>
);
}
@@ -1,118 +0,0 @@
import { useRef, useState } from 'react';
import { ArrowRight, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { getRecentFlowRange, getWeeklyFlowRange, type DateRange } from '../../model';
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
const ymd = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const parseDate = (value: string) => new Date(`${value}T12:00:00`);
const shiftDay = (date: Date, offset: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset);
const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1;
export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onChange: (range: DateRange) => void }) {
const dialog = useRef<HTMLDialogElement>(null);
const [draft, setDraft] = useState(value);
const [selecting, setSelecting] = useState<'start' | 'end'>('start');
const [month, setMonth] = useState(() => parseDate(value.start));
const [hovered, setHovered] = useState('');
const today = ymd(new Date());
const count = draft.end ? dayCount(draft.start, draft.end) : 0;
const valid = count > 0 && count <= 370;
const monthStart = new Date(month.getFullYear(), month.getMonth(), 1);
const previewEnd = draft.end || hovered || draft.start;
const rangeStart = draft.start < previewEnd ? draft.start : previewEnd;
const rangeEnd = draft.start > previewEnd ? draft.start : previewEnd;
const quickPicks = [
{ label: '今天', range: { start: today, end: today } },
{ label: '昨天', range: { start: ymd(shiftDay(new Date(), -1)), end: ymd(shiftDay(new Date(), -1)) } },
{ label: '业务周', range: getWeeklyFlowRange() },
{ label: '近 7 天', range: getRecentFlowRange() },
{ label: '近 30 天', range: { start: ymd(shiftDay(new Date(), -29)), end: today } },
];
const open = () => {
setDraft(value); setSelecting('start'); setMonth(parseDate(value.start)); setHovered('');
dialog.current?.showModal();
};
const choose = (date: string) => {
if (selecting === 'start') {
setDraft({ start: date, end: '' }); setSelecting('end');
} else {
setDraft(date < draft.start ? { start: date, end: draft.start } : { start: draft.start, end: date });
setSelecting('start');
}
setHovered('');
};
const calendar = (offset: number) => {
const current = new Date(monthStart.getFullYear(), monthStart.getMonth() + offset, 1);
const year = current.getFullYear(), m = current.getMonth();
const padding = (current.getDay() + 6) % 7;
const length = new Date(year, m + 1, 0).getDate();
return <div className={offset ? 'hidden sm:block' : ''} key={offset}>
<div className="mb-3 text-center text-sm font-bold text-slate-700">{year} 年 {m + 1} 月</div>
<div className="grid grid-cols-7 text-center">
{weekdays.map(day => <div key={day} className="py-2 text-[11px] font-medium text-slate-400">{day}</div>)}
{Array.from({ length: padding }, (_, i) => <div key={`blank-${i}`} />)}
{Array.from({ length }, (_, i) => {
const date = ymd(new Date(year, m, i + 1));
const endpoint = date === draft.start || date === draft.end;
const inRange = date >= rangeStart && date <= rangeEnd;
return <button key={date} type="button" aria-label={date} aria-pressed={endpoint}
onClick={() => choose(date)} onMouseEnter={() => { if (!draft.end) setHovered(date); }}
className={`relative my-0.5 h-10 text-xs font-semibold transition focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-blue-500 ${endpoint ? 'rounded-xl bg-blue-600 text-white shadow-sm' : inRange ? 'bg-blue-50 text-blue-700' : 'rounded-xl text-slate-600 hover:bg-slate-100'} ${date === today && !endpoint ? 'font-black text-blue-600' : ''}`}>
{i + 1}{date === today && <span className={`absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${endpoint ? 'bg-white' : 'bg-blue-500'}`} />}
</button>;
})}
</div>
</div>;
};
return <>
<button type="button" data-testid="asset-flow-date-range" aria-haspopup="dialog" onClick={open}
className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-medium text-slate-400">统计日期 · 运维签章时间</span>
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
</span>
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} 天</span>
<ChevronDown size={16} className="shrink-0 text-slate-400" />
</button>
<dialog ref={dialog} aria-labelledby="flow-date-title" onClick={e => { if (e.target === e.currentTarget) dialog.current?.close(); }}
className="fixed inset-0 m-auto max-h-[90dvh] w-[calc(100%-24px)] max-w-[680px] overflow-y-auto rounded-3xl border-0 bg-white p-0 text-slate-700 shadow-2xl backdrop:bg-slate-950/30 backdrop:backdrop-blur-sm">
<div className="p-5 sm:p-6">
<div className="flex items-start justify-between gap-3">
<div><h3 id="flow-date-title" className="text-base font-bold text-slate-900">选择统计日期</h3><p className="mt-1 text-xs text-slate-400">先选开始日期,再选结束日期</p></div>
<button type="button" aria-label="关闭日期选择" onClick={() => dialog.current?.close()} className="rounded-full p-2 text-slate-400 hover:bg-slate-100"><X size={18} /></button>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{quickPicks.map(pick => <button key={pick.label} type="button" onClick={() => { setDraft(pick.range); setMonth(parseDate(pick.range.start)); setSelecting('start'); setHovered(''); }}
className={`rounded-lg border px-3 py-2 text-xs font-medium transition ${draft.start === pick.range.start && draft.end === pick.range.end ? 'border-blue-200 bg-blue-50 text-blue-600' : 'border-slate-100 text-slate-500 hover:border-blue-200 hover:bg-blue-50'}`}>{pick.label}</button>)}
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
{(['start', 'end'] as const).map(field => <button key={field} type="button" onClick={() => { setSelecting(field); setHovered(''); setMonth(parseDate(draft[field] || draft.start)); }}
className={`rounded-xl border px-3 py-2.5 text-left transition ${selecting === field ? 'border-blue-400 bg-blue-50/60 ring-2 ring-blue-50' : 'border-slate-200 bg-slate-50/50'}`}>
<span className="block text-[10px] text-slate-400">{field === 'start' ? '开始日期' : '结束日期'}</span><span className="mt-1 block text-sm font-bold">{draft[field] || '请选择结束日期'}</span>
</button>)}
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<button type="button" aria-label="上个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronLeft size={18} /></button>
<div className="flex gap-2">
<select aria-label="年份" value={monthStart.getFullYear()} onChange={e => setMonth(new Date(Number(e.target.value), monthStart.getMonth(), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
{Array.from({ length: Math.max(new Date().getFullYear() + 1, monthStart.getFullYear()) - Math.min(2016, monthStart.getFullYear()) + 1 }, (_, i) => Math.min(2016, monthStart.getFullYear()) + i).map(year => <option key={year} value={year}>{year} 年</option>)}
</select>
<select aria-label="月份" value={monthStart.getMonth()} onChange={e => setMonth(new Date(monthStart.getFullYear(), Number(e.target.value), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
{Array.from({ length: 12 }, (_, i) => <option key={i} value={i}>{i + 1} 月</option>)}
</select>
</div>
<button type="button" aria-label="下个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronRight size={18} /></button>
</div>
<div className="mt-3 grid gap-6 sm:grid-cols-2" onMouseLeave={() => setHovered('')}>{calendar(0)}{calendar(1)}</div>
</div>
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4 sm:px-6">
<span role="status" className={`text-xs ${count > 370 ? 'text-red-500' : 'text-slate-400'}`}>{!draft.end ? '请选择结束日期' : count > 370 ? '最多选择 370 天' : `已选 ${count} 天(包含首尾)`}</span>
<div className="flex shrink-0 gap-2">
<button type="button" onClick={() => dialog.current?.close()} className="rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-500 hover:bg-slate-100">取消</button>
<button type="button" disabled={!valid} onClick={() => { onChange(draft); dialog.current?.close(); }} className="flex items-center gap-1 rounded-xl bg-blue-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"><Check size={14} />应用日期</button>
</div>
</div>
</dialog>
</>;
}
@@ -1,135 +0,0 @@
import { CalendarDays, ChevronDown, Download, ListFilter, Loader2 } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { FlowType } from '../../api';
import { FLOW_META } from '../FlowDetailModal';
import type { FlowStatisticsCardProps } from './types';
import { FlowDateRangePicker } from './FlowDateRangePicker';
const FLOW_TYPES: FlowType[] = ['delivered', 'returned', 'replaced'];
export function FlowStatisticsCard({
flowRange,
setFlowRange,
flowLoading,
flowStats,
exportFlowDetails,
flowDailyExpanded,
setFlowDailyExpanded,
setSelectedFlow,
}: FlowStatisticsCardProps) {
return (
<div className="grid grid-cols-1 gap-3 mb-4">
<div data-testid="asset-flow-card" className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[13px] font-black text-slate-700">
<CalendarDays size={14} className="text-blue-500" />
<span>交还车统计(车次)</span>
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => exportFlowDetails()}
disabled={!flowStats?.details.length}
className="inline-flex h-8 items-center justify-center gap-1 rounded-xl border border-slate-200 bg-slate-50 px-2.5 text-[11px] font-black text-slate-600 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
>
<Download size={13} />
导出
</button>
</div>
</div>
<p className="mt-1 text-[11px] text-slate-400">按运维签章时间统计;交车包含替换交车,合计=交车+还车,同车多笔业务分别计次。</p>
<FlowDateRangePicker value={flowRange} onChange={setFlowRange} />
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5">
<div className="grid grid-cols-4 items-center text-center">
<div className="px-2">
<div className="text-lg font-black leading-none text-slate-950">{flowStats?.totals.total ?? 0}</div>
<div className="mt-1 text-[10px] font-black text-slate-400">合计</div>
</div>
{FLOW_TYPES.map((type) => (
<button key={type} type="button"
data-testid={`asset-flow-total-${type}`}
disabled={flowLoading || !flowStats?.totals[type]}
onClick={() => flowStats && setSelectedFlow({ start: flowStats.start, end: flowStats.end, type })}
className="border-l border-slate-200/70 px-2 transition hover:bg-slate-100 focus-visible:outline-blue-500 disabled:cursor-not-allowed disabled:opacity-35">
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>
{flowStats?.totals[type] ?? 0}
</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{type === 'replaced' ? '其中:替换交车' : FLOW_META[type].label}</div>
</button>
))}
</div>
</div>
<button
type="button"
data-testid="asset-flow-daily-toggle"
aria-expanded={flowDailyExpanded}
aria-controls="asset-flow-daily-details"
onClick={() => setFlowDailyExpanded((prev) => !prev)}
className="mt-3 flex min-h-16 w-full items-center justify-between gap-3 rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-left text-blue-700 shadow-sm transition hover:border-blue-400 hover:bg-blue-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
<span className="flex min-w-0 items-center gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white text-blue-600"><ListFilter size={21} /></span>
<span><span className="block text-sm font-black">{flowDailyExpanded ? '收起每日明细' : '展开每日明细'}</span><span className="mt-0.5 block text-[11px] font-medium text-blue-600">查看每日交还车数量 · 最新日期在前</span></span>
</span>
<span className="flex shrink-0 items-center gap-2 text-xs font-bold">
<span className="rounded-lg bg-white px-2 py-1">{flowStats?.daily.length ?? 0} 天</span>
<ChevronDown size={20} className={`transition-transform ${flowDailyExpanded ? 'rotate-180' : ''}`} />
</span>
</button>
<AnimatePresence initial={false}>
{flowDailyExpanded && (
<motion.div
id="asset-flow-daily-details"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="mt-3 max-h-[280px] space-y-2 overflow-y-auto pr-1">
{flowLoading && (
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">正在加载交还车统计...</div>
)}
{!flowLoading && flowStats?.daily.slice().sort((a, b) => b.date.localeCompare(a.date)).map((day) => (
<div key={day.date} className="rounded-2xl border border-slate-100 bg-white px-3 py-3 shadow-sm">
<div className="flex items-center justify-between">
<div className="text-[11px] font-black text-slate-400">{day.date}</div>
<div className="text-[10px] font-bold italic text-slate-300">运维签章时间</div>
</div>
<div className="mt-2 grid grid-cols-4 items-center text-center">
<div className="px-2">
<div className="text-lg font-black leading-none text-slate-900">{day.total}</div>
<div className="mt-1 text-[10px] font-black text-slate-400">合计</div>
</div>
{FLOW_TYPES.map((type) => {
const count = day[type];
return (
<button
key={type}
type="button"
data-testid={`asset-flow-cell-${day.date}-${type}`}
disabled={count === 0}
onClick={() => setSelectedFlow({ date: day.date, type })}
className="border-l border-slate-100 px-2 text-center transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-35"
>
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>{count}</div>
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{type === 'replaced' ? '其中:替换交车' : FLOW_META[type].label}</div>
</button>
);
})}
</div>
</div>
))}
{!flowLoading && !flowStats?.daily.length && (
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">暂无交换车数据</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
@@ -1,176 +0,0 @@
import { InventoryStatusCounts } from './InventoryStatusCounts';
import { inventorySelection, type InventoryDisplayGroup } from './inventory-presentation';
import type { InventoryFilters } from '../../model';
import type { RegionalInventoryStats } from '../../types';
import { inventorySubtotalSelection } from './inventory-presentation';
import React from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { InventoryStatisticsSectionProps } from './types';
type InventoryDesktopTableProps = Pick<
InventoryStatisticsSectionProps,
| 'inventoryTab'
| 'inventoryByRegion'
| 'expandedInventoryRegions'
| 'toggleInventoryRegion'
| 'inventoryByModel'
| 'expandedInventoryTypes'
| 'toggleInventoryType'
| 'setVehicleSelection'
>;
export function InventoryDesktopTable({
inventoryTab,
inventoryByRegion,
expandedInventoryRegions,
toggleInventoryRegion,
inventoryByModel,
expandedInventoryTypes,
toggleInventoryType,
setVehicleSelection: setShowPlateNumbers,
}: InventoryDesktopTableProps) {
return (
<div className="hidden lg:block overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50 text-[11px] text-slate-500 uppercase tracking-wider border-b border-slate-100">
<th className="p-3 font-semibold w-64">{inventoryTab === 'region' ? '区域 / 城市' : '车型分类 / 型号'}</th>
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '品牌' : '品牌'}</th>
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '车型' : '所在区域/城市'}</th>
<th className="p-3 font-semibold text-center w-32">库存数量</th>
</tr>
</thead>
<tbody className="text-xs">
{inventoryTab === 'region' ? (
Object.entries(inventoryByRegion).map(([region, cities]) => (
<React.Fragment key={region}>
<tr
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
onClick={() => toggleInventoryRegion(region)}
>
<td colSpan={4} className="p-3 font-bold text-slate-700">
<div className="flex flex-wrap items-center gap-2">
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span>{region}</span>
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers(inventorySubtotalSelection(Object.values(cities).flat(), `库存统计 - ${region}`));
}}>
(共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
</span>
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={Object.values(cities).flat()} />
</div>
</td>
</tr>
<AnimatePresence>
{expandedInventoryRegions.has(region) && Object.entries(cities).map(([city, stats]) => (
<React.Fragment key={city}>
{stats.map((stat, idx) => (
<motion.tr
key={`${city}-${stat.model}-${idx}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
>
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
{idx === 0 ? <span className="font-medium text-slate-600">{city}</span> : ''}
</td>
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.model}</td>
<td className="p-3 text-center font-bold text-blue-600">
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers(inventorySubtotalSelection([stat], `库存统计 - ${stat.model} - ${stat.city}`));
}}
className="text-blue-600 hover:underline font-bold"
>
{stat.quantity}
</button>
{((stat.pending ?? 0) > 0 || (stat.abnormal ?? 0) > 0) && (
<div className="mt-1"><InventoryStatusCounts onSelect={setShowPlateNumbers} rows={[stat]} /></div>
)}
</td>
</motion.tr>
))}
</React.Fragment>
))}
</AnimatePresence>
</React.Fragment>
))
) : (
Object.entries(inventoryByModel).map(([type, models]) => (
<React.Fragment key={type}>
<tr
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
onClick={() => toggleInventoryType(type)}
>
<td colSpan={4} className="p-3 font-bold text-slate-700">
<div className="flex flex-wrap items-center gap-2">
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span>{type}</span>
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers(inventorySubtotalSelection(Object.values(models).flat(), `库存统计 - ${type}`));
}}>
(共 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
</span>
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={Object.values(models).flat()} />
</div>
</td>
</tr>
<AnimatePresence>
{expandedInventoryTypes.has(type) && Object.entries(models).map(([model, stats]) => (
<React.Fragment key={model}>
{stats.map((stat, idx) => (
<motion.tr
key={`${model}-${stat.region}-${stat.city}-${idx}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
>
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
{idx === 0 ? <span className="font-medium text-slate-600">{model}</span> : ''}
</td>
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.region} / {stat.city}</td>
<td className="p-3 text-center font-bold text-blue-600">
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers(inventorySubtotalSelection([stat], `库存统计 - ${stat.model} - ${stat.city}`));
}}
className="text-blue-600 hover:underline font-bold"
>
{stat.quantity}
</button>
{((stat.pending ?? 0) > 0 || (stat.abnormal ?? 0) > 0) && (
<div className="mt-1"><InventoryStatusCounts onSelect={setShowPlateNumbers} rows={[stat]} /></div>
)}
</td>
</motion.tr>
))}
</React.Fragment>
))}
</AnimatePresence>
</React.Fragment>
))
)}
</tbody>
</table>
</div>
);
}
export function selectInventoryRow(row: RegionalInventoryStats, select: InventoryStatisticsSectionProps['setVehicleSelection']) {
select(inventorySelection(row, `库存统计 · ${row.city} · ${row.model}`, { vehicleType: row.type }));
}
export function selectInventoryGroup(group: InventoryDisplayGroup, { tab, filters, select }: { tab: 'region' | 'model'; filters: InventoryFilters; select: InventoryStatisticsSectionProps['setVehicleSelection'] }) {
select(inventorySelection(tab === 'model' ? { ...filters, type: group.name } : filters, `库存统计 · ${group.name}`, tab === 'region'
? { location: filters.city || group.name }
: {}));
}
@@ -1,131 +0,0 @@
import { InventoryStatusCounts } from './InventoryStatusCounts';
import { inventorySubtotalSelection } from './inventory-presentation';
import { ChevronDown, ChevronRight } from 'lucide-react';
import type { InventoryStatisticsSectionProps } from './types';
type InventoryMobileListProps = Pick<
InventoryStatisticsSectionProps,
| 'inventoryTab'
| 'inventoryByRegion'
| 'expandedInventoryRegions'
| 'toggleInventoryRegion'
| 'inventoryByModel'
| 'expandedInventoryTypes'
| 'toggleInventoryType'
| 'setVehicleSelection'
>;
export function InventoryMobileList({
inventoryTab,
inventoryByRegion,
expandedInventoryRegions,
toggleInventoryRegion,
inventoryByModel,
expandedInventoryTypes,
toggleInventoryType,
setVehicleSelection: setShowPlateNumbers,
}: InventoryMobileListProps) {
return (
<div className="lg:hidden p-3 space-y-3">
{inventoryTab === 'region' ? (
Object.entries(inventoryByRegion).map(([region, cities]) => (
<div key={region} className="border border-slate-100 rounded overflow-hidden">
<div
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
onClick={() => toggleInventoryRegion(region)}
>
<div className="flex items-center gap-2">
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span className="text-xs font-bold text-slate-700">{region}</span>
</div>
<div className="flex flex-col items-end gap-1 text-right">
<span className="text-[10px] font-bold text-blue-600">
{Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
</span>
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={Object.values(cities).flat()} />
</div>
</div>
{expandedInventoryRegions.has(region) && (
<div className="p-2 space-y-2 bg-white">
{Object.entries(cities).map(([city, stats]) => (
<div key={city} className="border-l-2 border-slate-200 pl-3 py-1">
<div className="text-[10px] font-bold text-slate-500 mb-2">{city}</div>
<div className="space-y-2">
{stats.map((stat, idx) => (
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
<div className="flex flex-col">
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
<span className="text-slate-700 font-medium">{stat.model}</span>
</div>
<div className="flex flex-col items-end gap-1 text-right">
<button
onClick={() => setShowPlateNumbers(inventorySubtotalSelection([stat], `库存统计 - ${stat.model} - ${stat.city}`))}
className="font-bold text-blue-600 hover:underline"
>
{stat.quantity} 台
</button>
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={[stat]} />
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
))
) : (
Object.entries(inventoryByModel).map(([type, models]) => (
<div key={type} className="border border-slate-100 rounded overflow-hidden">
<div
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
onClick={() => toggleInventoryType(type)}
>
<div className="flex items-center gap-2">
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span className="text-xs font-bold text-slate-700">{type}</span>
</div>
<div className="flex flex-col items-end gap-1 text-right">
<span className="text-[10px] font-bold text-blue-600">
{Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
</span>
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={Object.values(models).flat()} />
</div>
</div>
{expandedInventoryTypes.has(type) && (
<div className="p-2 space-y-2 bg-white">
{Object.entries(models).map(([model, stats]) => (
<div key={model} className="border-l-2 border-slate-200 pl-3 py-1">
<div className="text-[10px] font-bold text-slate-500 mb-2">{model}</div>
<div className="space-y-2">
{stats.map((stat, idx) => (
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
<div className="flex flex-col">
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
<span className="text-slate-700 font-medium">{stat.region} / {stat.city}</span>
</div>
<div className="flex flex-col items-end gap-1 text-right">
<button
onClick={() => setShowPlateNumbers(inventorySubtotalSelection([stat], `库存统计 - ${stat.model} - ${stat.city}`))}
className="font-bold text-blue-600 hover:underline"
>
{stat.quantity} 台
</button>
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={[stat]} />
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
))
)}
</div>
);
}
@@ -1,11 +0,0 @@
import type { RegionalInventoryStats } from '../../types';
import { inventoryModelTags } from './inventory-presentation';
export function InventoryModelTags({ row, hideType = false }: { row: Pick<RegionalInventoryStats, 'type' | 'model' | 'batch' | 'brand'>; hideType?: boolean }) {
const tags = inventoryModelTags(row).filter(tag => !hideType || tag.kind !== 'type');
return <span className="inventory-model-tags">
{tags.length ? tags.map(tag => <span key={`${tag.kind}-${tag.value}`} className={`inventory-tag inventory-tag-${tag.kind}`}>
<span className="sr-only">{tag.label}:</span>{tag.value}
</span>) : '—'}
</span>;
}
@@ -1,213 +0,0 @@
import { useAuth } from '../../../../auth/useAuth';
import { InventoryStatusCounts } from './InventoryStatusCounts';
import { inventorySubtotalSelection } from './inventory-presentation';
import { Filter } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { InventoryDesktopTable } from './InventoryDesktopTable';
import { InventoryMobileList } from './InventoryMobileList';
import type { InventoryStatisticsSectionProps } from './types';
export function InventoryStatisticsSection({
inventoryFilters,
setInventoryFilters,
draftInventoryFilters,
setDraftInventoryFilters,
isInventoryFilterOpen,
setIsInventoryFilterOpen,
uniqueInventoryRegions,
uniqueInventoryCities,
uniqueInventoryBrands,
uniqueInventoryTypes,
uniqueInventoryModelsForType,
filteredInventoryStats,
inventoryTab,
setInventoryTab,
inventoryByRegion,
expandedInventoryRegions,
toggleInventoryRegion,
inventoryByModel,
expandedInventoryTypes,
toggleInventoryType,
setVehicleSelection: setShowPlateNumbers,
}: InventoryStatisticsSectionProps) {
const { preview } = useAuth();
const listProps = {
inventoryTab,
inventoryByRegion,
expandedInventoryRegions,
toggleInventoryRegion,
inventoryByModel,
expandedInventoryTypes,
toggleInventoryType,
setVehicleSelection: setShowPlateNumbers,
};
return (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm mb-6 overflow-hidden min-h-[420px]">
<div className="p-3 sm:p-4 border-b border-gray-50 bg-white flex items-center justify-between relative">
<div className="flex items-center gap-3">
<div className="w-1.5 h-6 bg-blue-600 rounded-full"></div>
<div className="flex flex-col">
<h2 className="text-lg font-bold text-gray-800 leading-tight">库存统计</h2>
<span className="text-[10px] text-gray-400 font-medium">{preview ? '全量库存 · 汇总与明细口径一致' : '实时更新库存数据'}</span>
</div>
</div>
<div className="flex items-center gap-6 ml-auto pr-2">
<div className="flex flex-col items-end cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setShowPlateNumbers(inventorySubtotalSelection(filteredInventoryStats, '库存总数'))}>
<span className="text-[10px] text-gray-400 font-bold tracking-wider uppercase mb-0.5">总库存数据</span>
<span className="text-2xl font-black text-gray-900 leading-none">
{filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)}
<span className="text-xs font-bold text-gray-400 ml-1">台</span>
</span>
</div>
<div className="relative">
<button
onClick={() => { if (!isInventoryFilterOpen) setDraftInventoryFilters({...inventoryFilters}); setIsInventoryFilterOpen(!isInventoryFilterOpen); }}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
isInventoryFilterOpen || (inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model)
? 'bg-blue-600 text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<Filter size={14} />
<span>筛选</span>
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
<span className="w-2 h-2 bg-white rounded-full animate-pulse"></span>
)}
</button>
<AnimatePresence>
{isInventoryFilterOpen && (
<>
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-slate-100 z-50 p-4"
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-xs font-bold text-slate-800">库存统计 - 数据筛选</h3>
<button onClick={() => setDraftInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[10px] text-blue-500 hover:underline">重置所有</button>
</div>
<div className="space-y-3 text-left">
<div>
<label className="text-[10px] text-slate-400 block mb-1">区域</label>
<select value={draftInventoryFilters.region} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, region: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部区域</option>
{uniqueInventoryRegions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-slate-400 block mb-1">城市</label>
<select value={draftInventoryFilters.city} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, city: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部城市</option>
{uniqueInventoryCities.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-slate-400 block mb-1">品牌</label>
<select value={draftInventoryFilters.brand} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, brand: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部品牌</option>
{uniqueInventoryBrands.map(b => <option key={b} value={b}>{b}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-slate-400 block mb-1">车型</label>
<select value={draftInventoryFilters.type} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, type: e.target.value, model: ''})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部车型</option>
{uniqueInventoryTypes.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-slate-400 block mb-1">批次</label>
<select value={draftInventoryFilters.model} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, model: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部批次</option>
{uniqueInventoryModelsForType.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
</div>
<button onClick={() => { setInventoryFilters({...draftInventoryFilters}); setIsInventoryFilterOpen(false); }} className="w-full mt-4 py-2 bg-blue-600 text-white rounded-lg text-xs font-bold hover:bg-blue-700 transition-colors">确认</button>
</motion.div>
</>
)}
</AnimatePresence>
</div>
</div>
</div>
{filteredInventoryStats.some(row => (row.pending ?? 0) > 0 || (row.abnormal ?? 0) > 0) && (
<div className="border-b border-gray-100 px-4 py-2" data-testid="inventory-status-summary">
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={filteredInventoryStats} />
</div>
)}
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
<div className="flex bg-gray-200/50 p-1 rounded-lg w-fit shadow-inner">
<button
onClick={() => setInventoryTab('region')}
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
inventoryTab === 'region' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
按区域
</button>
<button
onClick={() => setInventoryTab('model')}
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
inventoryTab === 'model' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
按车型
</button>
</div>
</div>
{/* Active Filters Bar */}
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
<div className="px-4 py-2 border-b border-gray-100 flex flex-wrap gap-2 items-center">
{inventoryFilters.region && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
区域: {inventoryFilters.region}
<button onClick={() => setInventoryFilters({...inventoryFilters, region: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
{inventoryFilters.city && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
城市: {inventoryFilters.city}
<button onClick={() => setInventoryFilters({...inventoryFilters, city: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
{inventoryFilters.brand && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
品牌: {inventoryFilters.brand}
<button onClick={() => setInventoryFilters({...inventoryFilters, brand: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
{inventoryFilters.type && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
车型: {inventoryFilters.type}
<button onClick={() => setInventoryFilters({...inventoryFilters, type: '', model: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
{inventoryFilters.model && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
批次: {inventoryFilters.model}
<button onClick={() => setInventoryFilters({...inventoryFilters, model: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
<button onClick={() => setInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[11px] text-red-500 font-bold ml-auto hover:text-red-600">清除</button>
</div>
)}
{/* Desktop View Table */}
<InventoryDesktopTable {...listProps} />
{/* Mobile View */}
<InventoryMobileList {...listProps} />
</div>
);
}

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