From 1e4afacac7323ab21a54001d373d53f960e7cb0e Mon Sep 17 00:00:00 2001 From: shishengliang Date: Wed, 16 Sep 2026 10:49:39 +0800 Subject: [PATCH 01/13] ci: sync Woodpecker configuration from main --- woodpecker.yml | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/woodpecker.yml b/woodpecker.yml index 14f4d5e..353c045 100644 --- a/woodpecker.yml +++ b/woodpecker.yml @@ -1,3 +1,7 @@ +# 凭据通过 Woodpecker Secret 注入(HARBOR_USERNAME / HARBOR_PASSWORD), +# 不再把 Harbor 账号密码 base64 后写进流水线脚本。 +# 镜像推送只在 push / manual 触发:pull_request 不应向生产 registry 推送。 + steps: - name: npm-build image: node:22-alpine @@ -12,14 +16,17 @@ steps: - main - feature/asset-statistics commands: | - cd $CI_WORKSPACE - npm ci + set -eu + cd "$CI_WORKSPACE" + node --version + npm --version + npm ci --registry=https://registry.npmmirror.com/ --no-audit --no-fund --loglevel=verbose npm run lint npm test npm run build # 获取分支名 - BRANCH_NAME=$(echo $CI_COMMIT_BRANCH | tr / -) + BRANCH_NAME=$(echo ${CI_COMMIT_BRANCH:-manual} | tr / -) echo "Branch name: $BRANCH_NAME" # 版本号: 分支名-package.json版本 @@ -30,10 +37,10 @@ steps: - name: docker-build image: docker:24.0.5-cli + depends_on: [npm-build] when: event: - push - - pull_request - manual branch: - master @@ -42,25 +49,22 @@ steps: - feature/asset-statistics volumes: - /var/run/docker.sock:/var/run/docker.sock + environment: + HARBOR_USERNAME: + from_secret: HARBOR_USERNAME + HARBOR_PASSWORD: + from_secret: HARBOR_PASSWORD commands: | PROJECT_VERSION=$(cat $CI_WORKSPACE/project_version.txt) MODULE_NAME=ln-bi + REGISTRY=harbor.lnh2e.com + PROJECT=lingniu-v1 echo "Building Docker image: $MODULE_NAME:$PROJECT_VERSION" cd $CI_WORKSPACE + docker build -t $REGISTRY/$PROJECT/$MODULE_NAME:$PROJECT_VERSION . - docker build -t harbor.lnh2e.com/lingniu-v1/$MODULE_NAME:$PROJECT_VERSION . + echo "$HARBOR_PASSWORD" | docker login $REGISTRY -u "$HARBOR_USERNAME" --password-stdin - mkdir -p /root/.docker - cat > /root/.docker/config.json < Date: Wed, 16 Sep 2026 11:54:27 +0800 Subject: [PATCH 02/13] fix(energy): use new hydrogen site master data Use new_hydrogen_site for daily and overview station names and regional aggregation, with empty-name fallback. Include the OneOS mileage reconciliation investigation report. --- ...reconciliation-investigation-2026-09-16.md | 198 ++++++++++++++++++ src/server/routes/energy/hydrogen-daily.ts | 4 +- src/server/routes/energy/hydrogen-overview.ts | 26 +-- src/server/routes/energy/routes.test.ts | 10 +- 4 files changed, 218 insertions(+), 20 deletions(-) create mode 100644 docs/oneos-mileage-reconciliation-investigation-2026-09-16.md diff --git a/docs/oneos-mileage-reconciliation-investigation-2026-09-16.md b/docs/oneos-mileage-reconciliation-investigation-2026-09-16.md new file mode 100644 index 0000000..593d7bc --- /dev/null +++ b/docs/oneos-mileage-reconciliation-investigation-2026-09-16.md @@ -0,0 +1,198 @@ +# OneOS 日里程与累计里程差额排查报告 + +- 排查日期:2026-09-16(Asia/Shanghai) +- 涉及数据日期:2026-09-11 至 2026-09-15 +- 接收方:OneOS 开放平台及里程计算服务维护人员 +- 状态:已复现;待上游核查计算基准、跨日缺报及协议选源逻辑 + +## 1. 问题概述 + +BI 用户发现:昨日累计总里程减去前日累计总里程,不等于昨日的区间里程;继续向前检查,多日均存在差额。 + +本次在相同车辆范围、相同协议优先级下复现,并绕过 BI 直接请求 OneOS 单日接口核对异常车辆。上游返回本身就存在相邻日期累计差与日里程不一致的情况,不能仅用 BI 显示取整解释。 + +已确认的现象: + +1. 某查询日返回的累计里程对应更早日期的报文;后续更新后的累计差跨越多天,与当日日里程不一致。 +2. 同一车辆相邻日期切换 MQTT / GB32960,累计读数发生跳变,且切换后可能选中很早的读数。 + +尚未确认:日里程的内部计算公式、有效数据判定、跨日补算规则及异常过滤规则。本文不将差额直接认定为漏算里程,也不将累计差直接认定为真实日行驶里程。 + +## 2. 查询范围与 BI 计算口径 + +本次 BI 查询未附加客户、部门、车牌或里程筛选条件,每天返回 916 台车辆。 + +```text +GET /api/mileage/monitoring + ?startDate=2026-09-15 + &endDate=2026-09-15 + &sourcePriority=instrument + &limit=2000 +``` + +`instrument` 对应上游协议优先级: + +```json +["GB32960", "MQTT"] +``` + +BI 当前计算方式: + +| 指标 | 计算方式 | +| --- | --- | +| 单车累计里程 | OneOS `totalMileageKm`,不以日里程反推 | +| 单车单日里程 | OneOS `dailyMileageKm` | +| 单车区间里程 | 区间内每日 `dailyMileageKm` 求和 | +| 累计总里程 | 查询范围内车辆的累计里程求和;空值按 0 参与汇总 | +| 当日总里程 | 查询范围内车辆的日里程求和;汇总接口对结果取整 | + +以下对账表使用逐车日里程原始数值求和,保留三位小数,避免整数显示带来的误差。 + +## 3. 汇总对账结果 + +单位:km。定义 `差额 = 当天累计总里程 - 前一天累计总里程 - 当天日里程合计`。 + +| 日期 | 累计总里程 | 相邻日累计差 | 当日日里程合计 | 差额 | +| --- | ---: | ---: | ---: | ---: | +| 2026-09-11 | 48,448,486.800 | — | 98,182.208 | — | +| 2026-09-12 | 48,546,128.300 | 97,641.500 | 97,321.774 | +319.726 | +| 2026-09-13 | 48,619,784.800 | 73,656.500 | 82,322.847 | −8,666.347 | +| 2026-09-14 | 48,721,190.600 | 101,405.800 | 94,242.687 | +7,163.113 | +| 2026-09-15 | 48,824,917.800 | 103,727.200 | 103,554.604 | +172.596 | + +按车牌匹配相邻日期,差额来源分解如下: + +| 日期 | 两日累计非空且协议相同的车辆数 / 净差额 | 两日累计非空且协议变化的车辆数 / 净差额 | 至少一日累计为空的车辆数 / 净差额 | +| --- | --- | --- | --- | +| 09-12 | 759 / +319.726 | 0 / 0 | 157 / 0 | +| 09-13 | 758 / +674.653 | 1 / −9,341.000 | 157 / 0 | +| 09-14 | 759 / +7,163.113 | 0 / 0 | 157 / 0 | +| 09-15 | 759 / +172.596 | 0 / 0 | 157 / 0 | + +本次样本中,空累计值组对差额净贡献为 0;同协议数据也存在明显不一致,不能只修复协议切换问题。 + +## 4. 异常样本一:累计读数跨日,日里程未与累计差对齐 + +以下记录已通过 OneOS 单日接口直接复核,响应均为 HTTP 200、`code=SUCCESS`,记录状态均为 `NORMAL`。 + +### 4.1 粤AGQ8377:9 月 15 日差额 +70.2 km + +| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) | +| --- | --- | ---: | ---: | --- | +| 2026-09-13 | GB32960 | 14,366.0 | 6.1 | 2026-09-13 07:17:27 | +| 2026-09-14 | GB32960 | 14,366.0 | 0 | 2026-09-13 07:17:27 | +| 2026-09-15 | GB32960 | 14,467.4 | 31.2 | 2026-09-15 20:57:04 | + +```text +累计差 = 14,467.4 - 14,366.0 = 101.4 +差额 = 101.4 - 31.2 = 70.2 km +``` + +查询 9 月 14 日时,累计读数仍来自 9 月 13 日。请核查 9 月 15 日日里程的起点读数及其时间,并解释 70.2 km 在日统计中的归属或过滤原因。 + +### 4.2 粤AGR6839:9 月 15 日差额 +38.0 km + +| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) | +| --- | --- | ---: | ---: | --- | +| 2026-09-14 | GB32960 | 45,122.2 | 0 | 2026-09-11 11:10:53 | +| 2026-09-15 | GB32960 | 45,162.8 | 2.6 | 2026-09-15 10:34:02 | + +累计差为 40.6 km,日里程为 2.6 km,差额为 38.0 km。请核查跨多日无新读数后的统计基准及补算策略。 + +### 4.3 粤AGP3609:9 月 14 日差额 +808.7 km + +| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) | +| --- | --- | ---: | ---: | --- | +| 2026-09-12 | GB32960 | 48,102.9 | 1,293.6 | 2026-09-12 15:08:47 | +| 2026-09-13 | GB32960 | 48,102.9 | 0 | 2026-09-12 15:08:47 | +| 2026-09-14 | GB32960 | 48,981.7 | 70.1 | 2026-09-14 23:59:51 | +| 2026-09-15 | GB32960 | 50,349.1 | 1,367.4 | 2026-09-15 23:59:50 | + +9 月 14 日累计差为 878.8 km,日里程为 70.1 km,差额为 808.7 km;9 月 15 日累计差与日里程则同为 1,367.4 km。该车适合对比正常日期与跨日缺报日期的计算路径。 + +## 5. 异常样本二:协议切换导致累计值倒退 + +车辆:沪A60591F。以下记录也已直接请求上游复核,均返回 `NORMAL`。 + +| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) | +| --- | --- | ---: | ---: | --- | +| 2026-09-12 | MQTT | 51,416 | 0 | 2026-09-12 04:53:49 | +| 2026-09-13 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 | +| 2026-09-14 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 | +| 2026-09-15 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 | + +```text +9 月 13 日累计差 = 42,075 - 51,416 = -9,341 km +9 月 13 日日里程 = 0 km +``` + +该车单独贡献 −9,341 km,叠加其他同协议车辆 +674.653 km 的差额后,形成当日全量净差额 −8,666.347 km。 + +请重点核查: + +1. 同一请求优先级下,9 月 12 日采用 MQTT,9 月 13 日为何改用 7 月 16 日的 GB32960 读数? +2. 协议“有效数据”的定义是否包含数据时间、新鲜度和查询日期边界? +3. `NORMAL` 是否仅表示读数数值有效,而不表示查询当日存在有效报文? +4. 两协议累计读数是否具有同一基准?切换时是否应提供不连续标识? +5. 请核查该车协议记录对应的 VIN、设备及绑定历史,排除历史绑定变化造成的读数混用。 + +## 6. 上游直接复现方法 + +接口:`POST https://open.d.lnoneos.com/api/v1/vehicles/mileage/query`。 + +使用已授权的 API Key,通过环境变量注入,不将真实凭证写入报告或日志。 + +```bash +curl --fail-with-body --silent --show-error \ + 'https://open.d.lnoneos.com/api/v1/vehicles/mileage/query' \ + -H "Authorization: Bearer ${ONEOS_MILEAGE_API_KEY}" \ + -H 'Content-Type: application/json' \ + --data '{ + "date": "2026-09-15", + "plateNumbers": ["粤AGQ8377", "粤AGR6839", "粤AGP3609", "沪A60591F"], + "protocolPriority": ["GB32960", "MQTT"] + }' +``` + +依次将 `date` 替换为 `2026-09-12`、`2026-09-13`、`2026-09-14`、`2026-09-15`。 + +逐车对比字段:`date`、`plateNumber`、`vin`、`status`、`dailyMileageKm`、`totalMileageKm`、`sourceProtocol`、`dataTime`、`calculatedAt`、`updatedAt`。 + +本次直接复核保留了报告中的关键字段,未记录响应 `traceId`;上游重放时请保存完整响应及 `traceId`。历史数据如已重算或补报,当前返回值可能与本报告不同,请同时提供重算时间和修改前后结果。 + +## 7. 请上游提供的定位结果 + +### 7.1 明确字段契约 + +- `dailyMileageKm`:是当天首尾读数差、前日末值到当日末值的差,还是逐报文有效增量之和?是否丢弃跨日增量、异常跳变或负增量? +- `totalMileageKm`:是查询日最后读数、截至查询日最近读数,还是其他快照?无当日报文时是否延用历史值? +- `dataTime`:具体对应累计读数、日里程终点还是其他时间? +- 两个里程字段是否保证来自同一协议、同一设备、同一计算快照? +- 自然日边界是否统一为 Asia/Shanghai 的 00:00:00 至次日 00:00:00? +- 是否承诺相邻日累计差等于日里程;若不承诺,请明确哪些场景不成立及差额如何解释。 + +### 7.2 提供样本计算明细 + +对上述四辆车,请返回: + +1. 各协议参与选源的候选记录、时间及未选中原因。 +2. 日里程计算起止报文的时间、累计读数、设备/VIN 标识。 +3. 有效增量、被过滤增量及过滤原因。 +4. 缺报、补报、跨日增量归属,以及历史重算记录。 +5. 对每个差额给出可核对的数值分解,不能只解释为“统计口径不同”。 + +## 8. 修复或口径确认后的验收建议 + +1. 使用同一车辆集合和协议配置,重放 9 月 11 至 15 日数据,逐车核对后再核对汇总。 +2. 对连续上报且同协议的正常车辆,若契约约定可对账,应满足累计差与日里程一致;容差按上游实际数值精度明确。 +3. 对跨日缺报车辆,明确跨日增量是否补算、归属哪天,并能解释全部差额。 +4. 对协议切换车辆,避免将不同基准的累计值视为连续可减的序列;提供明确的协议切换和不连续信息。 +5. 对陈旧读数,区分“历史累计值仍有效”和“当日有有效报文”,避免用单一 `NORMAL` 混淆两者。 +6. 验证单日接口与区间接口对同一车、同一天的日里程、累计值和选源结果一致。 +7. 如修复涉及历史重算,提供重算范围及完成时间;BI 历史接口缓存最长 6 小时,应在刷新后复核。 + +## 9. 本次排查边界 + +- 已检查 BI 里程映射、日/区间聚合和汇总逻辑,并对异常样本直接调用上游单日接口复核。 +- 尚未访问 OneOS 原始报文、计算服务源码或内部存储,不能确定其内部算法根因。 +- 本次未修改业务代码、未变更里程口径、未触发历史数据重算。 diff --git a/src/server/routes/energy/hydrogen-daily.ts b/src/server/routes/energy/hydrogen-daily.ts index d7abde9..57a7a65 100644 --- a/src/server/routes/energy/hydrogen-daily.ts +++ b/src/server/routes/energy/hydrogen-daily.ts @@ -48,14 +48,14 @@ export function registerHydrogenDailyRoute( const [stationRows] = await hydrogenPool.query( `SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d, COALESCE(b.station_id, 0) AS stationId, - COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name), + COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')), CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点' ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName, ROUND(SUM(b.amount_kg), 2) AS kg, -- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单 MAX(b.cost_price) AS pricePerKg FROM ${HYDROGEN_TABLE} b - LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0' + LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0' WHERE ${where} GROUP BY d, COALESCE(b.station_id, 0) ORDER BY d DESC, kg DESC`, diff --git a/src/server/routes/energy/hydrogen-overview.ts b/src/server/routes/energy/hydrogen-overview.ts index 0a4d3d3..5eb2fbf 100644 --- a/src/server/routes/energy/hydrogen-overview.ts +++ b/src/server/routes/energy/hydrogen-overview.ts @@ -119,13 +119,13 @@ export function registerHydrogenOverviewRoute( // Top5 加氢站(指定年份) const [top5Rows] = await hydrogenPool.query( `SELECT b.station_id AS id, - COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name), + COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')), CASE WHEN b.station_id IS NULL THEN '未关联站点' ELSE CONCAT('未知站点 #', b.station_id) END) AS name, SUM(b.amount_kg) AS kg, SUM(b.cost_total) AS fee FROM ${HYDROGEN_TABLE} b - LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0' + LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0' WHERE ${HYDROGEN_BASE_WHERE_B} AND b.${HYDROGEN_LOCAL} >= ? AND YEAR(b.${HYDROGEN_LOCAL}) = ? @@ -146,13 +146,13 @@ export function registerHydrogenOverviewRoute( // 加氢站全量汇总(同年所有站,按加氢量降序) const [stationFullRows] = await hydrogenPool.query( `SELECT b.station_id AS id, - COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name), + COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')), CASE WHEN b.station_id IS NULL THEN '未关联站点' ELSE CONCAT('未知站点 #', b.station_id) END) AS name, SUM(b.amount_kg) AS kg, SUM(b.fee_total) AS revenue FROM ${HYDROGEN_TABLE} b - LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0' + LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0' WHERE ${HYDROGEN_BASE_WHERE_B} AND b.${HYDROGEN_LOCAL} >= ? AND YEAR(b.${HYDROGEN_LOCAL}) = ? @@ -174,18 +174,18 @@ export function registerHydrogenOverviewRoute( const [regionRows] = await hydrogenPool.query( `SELECT region, SUM(kg) AS kg FROM ( SELECT CASE - WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴' - WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州' - WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山' - WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%成都%' THEN '成都' - WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%重庆%' THEN '重庆' - WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐' - WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%昆山%' THEN '昆山' - ELSE COALESCE(NULLIF(s.station_name, ''), NULLIF(b.station_name, ''), '未知') + WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%嘉兴%' OR COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%平湖%' THEN '嘉兴' + WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%广州%' THEN '广州' + WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%佛山%' THEN '佛山' + WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%成都%' THEN '成都' + WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%重庆%' THEN '重庆' + WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐' + WHEN COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '') LIKE '%昆山%' THEN '昆山' + ELSE COALESCE(NULLIF(s.site_name, ''), NULLIF(b.station_name, ''), '未知') END AS region, b.amount_kg AS kg FROM ${HYDROGEN_TABLE} b - LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0' + LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0' WHERE ${HYDROGEN_BASE_WHERE_B} AND b.${HYDROGEN_LOCAL} >= ? AND YEAR(b.${HYDROGEN_LOCAL}) = ? diff --git a/src/server/routes/energy/routes.test.ts b/src/server/routes/energy/routes.test.ts index 9b76b9e..8e3fdf8 100644 --- a/src/server/routes/energy/routes.test.ts +++ b/src/server/routes/energy/routes.test.ts @@ -61,7 +61,7 @@ test('能源路由继续按原顺序注册四个 GET 接口', () => { ); }); -test('四个处理器继续使用原缓存语义、数据库、SQL 与参数', async () => { +test('四个处理器保持缓存和参数契约,加氢站点使用新主数据表', async () => { const hydrogenCalls: QueryCall[] = []; const electricCalls: QueryCall[] = []; const cacheCalls: CacheCall[] = []; @@ -133,12 +133,12 @@ test('四个处理器继续使用原缓存语义、数据库、SQL 与参数', a [ '95f6d99b61c9', '63a63c5f78bb', - '1367eddb3b7f', - '83a52a7f9336', - 'f28fa4a3cb08', + '9cf6719a39c3', + '089d8a10d666', + '256ac8309d5a', 'dac9e86fc527', 'a71da8f05765', - 'b562668859ce', + '24b23780a259', '226fd8f950a1', '6e4031b10926', '4fc77463294c', From 746edeb72c94ba38484dbeaad0de24185b8d7cce Mon Sep 17 00:00:00 2001 From: shishengliang Date: Fri, 18 Sep 2026 09:54:08 +0800 Subject: [PATCH 03/13] fix(mileage): preserve anomalous odometer readings and show data quality --- .../monitoring/components/FullscreenMonitoring.tsx | 2 +- .../mileage/monitoring/components/VehicleList.tsx | 11 ++++++----- src/modules/mileage/types.ts | 1 + src/server/routes/mileage/cache-model.ts | 6 +++++- src/server/routes/mileage/cache.ts | 4 +++- src/server/routes/mileage/oneos-model.test.ts | 12 ++++++++++++ src/server/routes/mileage/oneos-model.ts | 8 +++++--- src/server/routes/mileage/types.ts | 1 + 8 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx b/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx index 38ea06e..b183e93 100644 --- a/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx +++ b/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx @@ -310,7 +310,7 @@ export default function FullscreenMonitoring({ {v.department || '-'} - {(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} km : 未对接} + {(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} {v.mileageAnomaly ? '' : 'km'} : 未对接} diff --git a/src/modules/mileage/monitoring/components/VehicleList.tsx b/src/modules/mileage/monitoring/components/VehicleList.tsx index 922523c..1bbb7c8 100644 --- a/src/modules/mileage/monitoring/components/VehicleList.tsx +++ b/src/modules/mileage/monitoring/components/VehicleList.tsx @@ -69,13 +69,13 @@ export default function VehicleList({
-
+
{v.plate} - {v.isOnline ? '在线' : '离线'} + {v.mileageAnomaly ? '数据异常' : v.isOnline ? '在线' : '离线'}
+ {v.mileageAnomaly &&
{v.mileageAnomaly === 'ODOMETER_SOURCE_CHANGED' ? '里程来源变化' : '上游里程异常'}
}
{statisticTime.label}
@@ -101,8 +102,8 @@ export default function VehicleList({
- {Math.max(0, v.dailyKm || 0).toLocaleString()} - km + {v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} + {v.mileageAnomaly ? '' : 'km'}
@@ -118,7 +119,7 @@ export default function VehicleList({ )} 区
- {(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} km : 未对接} + {(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} {v.mileageAnomaly ? '' : 'km'} : 未对接}
diff --git a/src/modules/mileage/types.ts b/src/modules/mileage/types.ts index 3ca395f..258739f 100644 --- a/src/modules/mileage/types.ts +++ b/src/modules/mileage/types.ts @@ -6,6 +6,7 @@ export interface MonitoringVehicle { plate: string; vin: string; dailyKm: number; + mileageAnomaly?: string | null; dailyMileage?: Record; totalKm: number | null; source: string; diff --git a/src/server/routes/mileage/cache-model.ts b/src/server/routes/mileage/cache-model.ts index 92fe861..25eacfc 100644 --- a/src/server/routes/mileage/cache-model.ts +++ b/src/server/routes/mileage/cache-model.ts @@ -15,6 +15,7 @@ export interface MileageRow { vin: string; daily_km: string; total_km: string | null; + mileage_anomaly?: string | null; source: string; source_protocol: OneOsProtocol | null; data_time: string | null; @@ -27,6 +28,7 @@ export interface DailyMileageRow { vin: string | null; date: string; daily_km: string | number | null; + mileage_anomaly?: string | null; source: string | null; source_protocol: OneOsProtocol | null; data_time: string | null; @@ -105,7 +107,8 @@ export function toMileageRows(rows: OneOsDailyMileage[]): MileageRow[] { vin: row.vin, daily_km: String(row.dailyMileageKm ?? 0), total_km: row.totalMileageKm == null ? null : String(row.totalMileageKm), - source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE', + source: row.status !== 'NO_DATA' ? 'ONEOS_API' : 'NONE', + mileage_anomaly: row.status === 'DATA_ANOMALY' ? row.dataQuality || 'DATA_ANOMALY' : null, source_protocol: row.sourceProtocol, data_time: row.dataTime, calculated_at: row.calculatedAt, @@ -174,6 +177,7 @@ export function mergeMonitoringVehicles( plate: info.plate, vin: mileage?.vin || info.vin || '', dailyKm, + mileageAnomaly: mileage?.mileage_anomaly || null, // Cumulative mileage is never backfilled from a different source here. totalKm: mileage?.total_km != null ? Number(mileage.total_km) : null, source, diff --git a/src/server/routes/mileage/cache.ts b/src/server/routes/mileage/cache.ts index fe3315c..2bf302a 100644 --- a/src/server/routes/mileage/cache.ts +++ b/src/server/routes/mileage/cache.ts @@ -164,7 +164,8 @@ export async function queryRangeMileage( vin: row.vin, date, daily_km: row.dailyMileageKm, - source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE', + source: row.status !== 'NO_DATA' ? 'ONEOS_API' : 'NONE', + mileage_anomaly: row.status === 'DATA_ANOMALY' ? row.dataQuality || 'DATA_ANOMALY' : null, source_protocol: row.sourceProtocol, data_time: row.dataTime, calculated_at: row.calculatedAt, @@ -216,6 +217,7 @@ export async function queryRangeMileage( vin: existing?.vin || row.vin || '', daily_km: String((Number(existing?.daily_km) || 0) + km), total_km: null, + mileage_anomaly: existing?.mileage_anomaly || row.mileage_anomaly || null, source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'), source_protocol: row.source_protocol || existing?.source_protocol || null, data_time: row.data_time || existing?.data_time || null, diff --git a/src/server/routes/mileage/oneos-model.test.ts b/src/server/routes/mileage/oneos-model.test.ts index 7fa80de..11574cb 100644 --- a/src/server/routes/mileage/oneos-model.test.ts +++ b/src/server/routes/mileage/oneos-model.test.ts @@ -86,3 +86,15 @@ test('连续日期分组、车牌规范化和请求键保持稳定', () => { ); assert.throws(() => assertMileageDate('2026/08/12'), /Invalid mileage date/); }); + +test('异常日里程不伪造为有效值,保留仪表累计和异常原因', () => { + const [row] = normalizeOneOsRows([{ + plateNumber: '粤AGE4862', date: '2026-09-17', status: 'DATA_ANOMALY', + dataQuality: 'ODOMETER_SOURCE_CHANGED', dailyMileageKm: null, + totalMileageKm: 66852.4, sourceProtocol: 'GB32960', + }], '2026-09-17'); + assert.equal(row.status, 'DATA_ANOMALY'); + assert.equal(row.dailyMileageKm, null); + assert.equal(row.totalMileageKm, 66852.4); + assert.equal(row.dataQuality, 'ODOMETER_SOURCE_CHANGED'); +}); diff --git a/src/server/routes/mileage/oneos-model.ts b/src/server/routes/mileage/oneos-model.ts index b4d28bc..53a12f6 100644 --- a/src/server/routes/mileage/oneos-model.ts +++ b/src/server/routes/mileage/oneos-model.ts @@ -6,7 +6,8 @@ export interface OneOsDailyMileage { date: string; dailyMileageKm: number | null; totalMileageKm: number | null; - status: 'NORMAL' | 'NO_DATA'; + status: 'NORMAL' | 'NO_DATA' | 'DATA_ANOMALY'; + dataQuality?: string; dataTime: string | null; calculatedAt: string | null; updatedAt: string | null; @@ -44,7 +45,7 @@ export function normalizeOneOsRows(value: unknown, requestedDate: string): OneOs const vin = typeof row.vin === 'string' ? row.vin.trim() : ''; const plateNumber = typeof row.plateNumber === 'string' ? row.plateNumber.trim() : ''; const date = typeof row.date === 'string' ? row.date : requestedDate; - const status = row.status === 'NORMAL' ? 'NORMAL' : 'NO_DATA'; + const status = row.status === 'NORMAL' || row.status === 'DATA_ANOMALY' ? row.status : 'NO_DATA'; // The aliases keep the BI compatible while the Open API formalizes its fields. const dataTime = stringField(row, 'dataTime', 'statisticTime', 'recordTime'); @@ -60,8 +61,9 @@ export function normalizeOneOsRows(value: unknown, requestedDate: string): OneOs plateNumber, date, dailyMileageKm: nonNegativeMileage(row.dailyMileageKm, status), - totalMileageKm: nonNegativeMileage(row.totalMileageKm, status), + totalMileageKm: nonNegativeMileage(row.totalMileageKm, status === 'DATA_ANOMALY' ? 'NORMAL' : status), status, + ...(status === 'DATA_ANOMALY' ? { dataQuality: stringField(row, 'dataQuality') || 'DATA_ANOMALY' } : {}), dataTime, calculatedAt, updatedAt, diff --git a/src/server/routes/mileage/types.ts b/src/server/routes/mileage/types.ts index 4344325..4cb17dd 100644 --- a/src/server/routes/mileage/types.ts +++ b/src/server/routes/mileage/types.ts @@ -3,6 +3,7 @@ export interface CachedVehicle { plate: string; vin: string; dailyKm: number; + mileageAnomaly?: string | null; dailyMileage?: Record; totalKm: number | null; source: string; From 8fb574599b6cf585bb0aca2ae5fc1e6dc9080cb9 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Sun, 20 Sep 2026 09:51:31 +0800 Subject: [PATCH 04/13] fix: align asset customer data with OneOS and refine statistics display --- src/modules/assets/AssetsModule.tsx | 6 +-- .../assets/components/AssetsHeader.tsx | 46 ++----------------- .../overview/FlowDateRangePicker.tsx | 4 +- .../overview/FlowStatisticsCard.tsx | 2 +- src/modules/assets/model.ts | 7 +++ src/server/routes/mileage/vehicle-info.ts | 25 +++------- src/server/routes/vehicles/repository.ts | 27 +++-------- src/server/vehicle-lease-sql.ts | 25 ++++++++++ 8 files changed, 56 insertions(+), 86 deletions(-) create mode 100644 src/server/vehicle-lease-sql.ts diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index 00e1415..eb19036 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -34,7 +34,7 @@ import { deriveInventoryView, deriveModalVehicleView, formatLocalDateTime, - getWeeklyFlowRange, + getRecentFlowRange, selectFlowDetails, type VehicleModalSelection, type FlowSelection, @@ -84,10 +84,10 @@ export default function AssetsModule() { const [error, setError] = useState(null); const [lastUpdate, setLastUpdate] = useState(() => formatLocalDateTime(new Date())); const [modalLoading, setModalLoading] = useState(false); - const [flowRange, setFlowRange] = useState(() => getWeeklyFlowRange()); + const [flowRange, setFlowRange] = useState(() => getRecentFlowRange()); const [flowStats, setFlowStats] = useState(null); const [flowLoading, setFlowLoading] = useState(false); - const [flowDailyExpanded, setFlowDailyExpanded] = useState(false); + const [flowDailyExpanded, setFlowDailyExpanded] = useState(true); const [selectedFlow, setSelectedFlow] = useState(null); // Dept/Region/Customer data diff --git a/src/modules/assets/components/AssetsHeader.tsx b/src/modules/assets/components/AssetsHeader.tsx index 008a9f8..9b690ca 100644 --- a/src/modules/assets/components/AssetsHeader.tsx +++ b/src/modules/assets/components/AssetsHeader.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React from 'react'; import { ChevronDown, Filter, Loader2, Search } from 'lucide-react'; import { motion } from 'motion/react'; import type { SubjectOption } from '../api'; @@ -30,42 +30,6 @@ const TABS = [ { id: 'customer', label: '按客户' }, ]; -function MarqueeBanner() { - const trackRef = useRef(null); - const innerRef = useRef(null); - const [overflow, setOverflow] = useState(false); - - useEffect(() => { - const check = () => { - if (!trackRef.current || !innerRef.current) return; - setOverflow(innerRef.current.scrollWidth > trackRef.current.clientWidth); - }; - check(); - const ro = new ResizeObserver(check); - ro.observe(trackRef.current!); - return () => ro.disconnect(); - }, []); - - const text = '车辆资产已于 2026 年 6 月 18 日完成“运营状态”与“业务关联”校验'; - - return ( -
-
-
- - {text} - - {overflow && ( - - {text} - - )} -
-
-
- ); -} - export function AssetsHeader({ activeTab, setActiveTab, @@ -246,16 +210,16 @@ export function AssetsHeader({ ))}
{/* Status row */} -
+
- 最后更新: {lastUpdate} + 数据更新时间:{lastUpdate.slice(0, 16)}
+ + 运维台账每日统计口径:当日17:00
- {/* OneOS 迁移提示滚动条 */} - ); } diff --git a/src/modules/assets/components/overview/FlowDateRangePicker.tsx b/src/modules/assets/components/overview/FlowDateRangePicker.tsx index e323fa2..ed33dee 100644 --- a/src/modules/assets/components/overview/FlowDateRangePicker.tsx +++ b/src/modules/assets/components/overview/FlowDateRangePicker.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from 'react'; import { ArrowRight, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react'; -import { getWeeklyFlowRange, type DateRange } from '../../model'; +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')}`; @@ -25,7 +25,7 @@ export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onC { 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: { start: ymd(shiftDay(new Date(), -6)), end: today } }, + { label: '近 7 天', range: getRecentFlowRange() }, { label: '近 30 天', range: { start: ymd(shiftDay(new Date(), -29)), end: today } }, ]; const open = () => { diff --git a/src/modules/assets/components/overview/FlowStatisticsCard.tsx b/src/modules/assets/components/overview/FlowStatisticsCard.tsx index a4f867d..181a85b 100644 --- a/src/modules/assets/components/overview/FlowStatisticsCard.tsx +++ b/src/modules/assets/components/overview/FlowStatisticsCard.tsx @@ -86,7 +86,7 @@ export function FlowStatisticsCard({ {flowLoading && (
正在加载交还车统计...
)} - {!flowLoading && flowStats?.daily.map((day) => ( + {!flowLoading && flowStats?.daily.slice().sort((a, b) => b.date.localeCompare(a.date)).map((day) => (
{day.date}
diff --git a/src/modules/assets/model.ts b/src/modules/assets/model.ts index 8b1330f..a80ad10 100644 --- a/src/modules/assets/model.ts +++ b/src/modules/assets/model.ts @@ -126,6 +126,13 @@ function addDays(date: Date, days: number): Date { } // 资产流转周报按周六至周五统计;周末查看时仍停留在刚结束的周五。 +export function getRecentFlowRange(referenceDate = new Date()): DateRange { + return { + start: formatLocalDate(addDays(referenceDate, -6)), + end: formatLocalDate(referenceDate), + }; +} + export function getWeeklyFlowRange(referenceDate = new Date()): DateRange { const day = referenceDate.getDay(); const end = day === 6 diff --git a/src/server/routes/mileage/vehicle-info.ts b/src/server/routes/mileage/vehicle-info.ts index 64fa258..d0b0f30 100644 --- a/src/server/routes/mileage/vehicle-info.ts +++ b/src/server/routes/mileage/vehicle-info.ts @@ -1,13 +1,14 @@ import pool from '../../db.js'; +import { LATEST_VEHICLE_LEASE_JOIN_SQL } from '../../vehicle-lease-sql.js'; import type { VehicleInfoRow } from './types.js'; /** 车辆关联信息 SQL(客户名、部门、经理、租赁状态、主体、项目、品牌) */ export const VEHICLE_INFO_SQL = `SELECT vi.plate_number AS plate, vi.vin AS vin, - COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS customer, - COALESCE(c.business_department_name, vor.business_dept) AS department, - COALESCE(c.business_manager_name, vor.business_manager) AS manager, + vor.customer_name AS customer, + vor.business_dept AS department, + vor.business_manager AS manager, CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS manager_id, CASE vs.operation_status WHEN '1' THEN '租赁' @@ -18,7 +19,7 @@ export const VEHICLE_INFO_SQL = `SELECT ELSE vs.operation_status END AS rent_status, NULLIF(vi.registered_ownership, '') AS entity, - COALESCE(c.project_name, vor.project_name) AS project, + vor.project_name AS project, COALESCE(vi_operation_city.NAME, NULLIF(vi.operation_city, '')) AS inventory_location, CASE vm.brand WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END @@ -50,21 +51,7 @@ LEFT JOIN vehicle_model vm LEFT JOIN common_district vi_operation_city ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci AND vi_operation_city.STATUS = 'VALID' -LEFT JOIN vehicle_lease_order_record vor - ON vor.vehicle_id = vi.id - AND vor.del_flag = '0' - AND vor.id = ( - SELECT MAX(vor2.id) - FROM vehicle_lease_order_record vor2 - WHERE vor2.vehicle_id = vi.id - AND vor2.del_flag = '0' - ) -LEFT JOIN vehicle_lease_contract_info c - ON c.order_id = vor.contract_id - AND c.del_flag = '0' -LEFT JOIN customer_info ci - ON ci.id = vi.customer_id - AND ci.del_flag = '0' +${LATEST_VEHICLE_LEASE_JOIN_SQL} WHERE vi.del_flag = '0' AND COALESCE(vs.operation_status, '') <> '5'`; diff --git a/src/server/routes/vehicles/repository.ts b/src/server/routes/vehicles/repository.ts index b2d23f6..aa10cc4 100644 --- a/src/server/routes/vehicles/repository.ts +++ b/src/server/routes/vehicles/repository.ts @@ -1,4 +1,5 @@ import pool from '../../db.js'; +import { LATEST_VEHICLE_LEASE_JOIN_SQL } from '../../vehicle-lease-sql.js'; import type { Vehicle, VehicleRow } from '../../types.js'; import { transformRow } from './model.js'; import type { WeeklyTruckIds } from './types.js'; @@ -80,13 +81,13 @@ export const MAIN_SQL = `SELECT ELSE vm.brand END AS 车辆品牌Label, c.id AS 合同ID, - COALESCE(c.contract_code, vor.contract_code, vi.contract_code) AS 合同编码, - COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS 客户名称, + vor.contract_code AS 合同编码, + vor.customer_name AS 客户名称, c.signing_company AS 合同归属公司, - COALESCE(c.business_department_name, vor.business_dept) AS 合同归属部门, + vor.business_dept AS 合同归属部门, NULLIF(vi.registered_ownership, '') AS 主体, - COALESCE(c.project_name, vor.project_name) AS 项目名称, - COALESCE(c.business_manager_name, vor.business_manager) AS 客户经理, + vor.project_name AS 项目名称, + vor.business_manager AS 客户经理, CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS 经理ID FROM vehicle_info vi LEFT JOIN vehicle_status vs @@ -113,21 +114,7 @@ LEFT JOIN common_district vi_city LEFT JOIN common_district vi_operation_city ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci AND vi_operation_city.STATUS = 'VALID' -LEFT JOIN vehicle_lease_order_record vor - ON vor.vehicle_id = vi.id - AND vor.del_flag = '0' - AND vor.id = ( - SELECT MAX(vor2.id) - FROM vehicle_lease_order_record vor2 - WHERE vor2.vehicle_id = vi.id - AND vor2.del_flag = '0' - ) -LEFT JOIN vehicle_lease_contract_info c - ON c.order_id = vor.contract_id - AND c.del_flag = '0' -LEFT JOIN customer_info ci - ON ci.id = vi.customer_id - AND ci.del_flag = '0' +${LATEST_VEHICLE_LEASE_JOIN_SQL} WHERE vi.del_flag = '0' AND COALESCE(vs.operation_status, '') <> '5'`; diff --git a/src/server/vehicle-lease-sql.ts b/src/server/vehicle-lease-sql.ts new file mode 100644 index 0000000..dde11a2 --- /dev/null +++ b/src/server/vehicle-lease-sql.ts @@ -0,0 +1,25 @@ +/** + * 与 OneOS VehicleInfoMapper.LatestVehicleLeaseJoin 保持一致。 + * vi 为车辆表;vor 为当前业务记录;c 为合同。 + * 还车会清空 vor 的客户与合同信息,调用方不得用历史档案补回空值。 + */ +export const LATEST_VEHICLE_LEASE_JOIN_SQL = `LEFT JOIN ( + SELECT ranked_record.* + FROM ( + SELECT source_record.*, + ROW_NUMBER() OVER ( + PARTITION BY source_record.vehicle_id + ORDER BY COALESCE( + source_record.last_delivery_time, + source_record.last_return_time, + source_record.update_time + ) DESC, source_record.id DESC + ) AS lease_rn + FROM vehicle_lease_order_record source_record + WHERE source_record.del_flag = '0' + ) ranked_record + WHERE ranked_record.lease_rn = 1 + ) vor ON vor.vehicle_id = vi.id +LEFT JOIN vehicle_lease_contract_info c + ON c.id = vor.contract_id + AND c.del_flag = '0'`; From b15951f08b5beab0855adadc861447aa230610ca Mon Sep 17 00:00:00 2001 From: shishengliang Date: Sun, 20 Sep 2026 09:54:36 +0800 Subject: [PATCH 05/13] fix: collapse daily vehicle flow details by default --- src/modules/assets/AssetsModule.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index eb19036..f96c26d 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -87,7 +87,7 @@ export default function AssetsModule() { const [flowRange, setFlowRange] = useState(() => getRecentFlowRange()); const [flowStats, setFlowStats] = useState(null); const [flowLoading, setFlowLoading] = useState(false); - const [flowDailyExpanded, setFlowDailyExpanded] = useState(true); + const [flowDailyExpanded, setFlowDailyExpanded] = useState(false); const [selectedFlow, setSelectedFlow] = useState(null); // Dept/Region/Customer data From 61406497cc6f6655b0847b3d830f857663e80d6d Mon Sep 17 00:00:00 2001 From: shishengliang Date: Sun, 20 Sep 2026 10:07:08 +0800 Subject: [PATCH 06/13] fix: show mileage query loading animation and bump version to 1.1.15 --- package-lock.json | 4 +- package.json | 2 +- src/modules/mileage/MileageLoadingOverlay.tsx | 44 +++++++++++++++++++ src/modules/mileage/MonitoringView.tsx | 20 ++++++--- .../DailyReportLoadingOverlay.tsx | 43 +----------------- .../monitoring/components/VehicleList.tsx | 4 +- 6 files changed, 66 insertions(+), 51 deletions(-) create mode 100644 src/modules/mileage/MileageLoadingOverlay.tsx diff --git a/package-lock.json b/package-lock.json index dd5f401..e3f05c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ln-bi", - "version": "1.1.14", + "version": "1.1.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ln-bi", - "version": "1.1.14", + "version": "1.1.15", "dependencies": { "@amap/amap-jsapi-loader": "^1.0.1", "@hono/node-server": "^1.13.0", diff --git a/package.json b/package.json index 03b0d3f..fb2e3d1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ln-bi", "private": true, - "version": "1.1.14", + "version": "1.1.15", "type": "module", "scripts": { "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", diff --git a/src/modules/mileage/MileageLoadingOverlay.tsx b/src/modules/mileage/MileageLoadingOverlay.tsx new file mode 100644 index 0000000..eb0cd71 --- /dev/null +++ b/src/modules/mileage/MileageLoadingOverlay.tsx @@ -0,0 +1,44 @@ +import { RefreshCw } from 'lucide-react'; +import { motion } from 'motion/react'; + +export default function MileageLoadingOverlay({ title = '正在查询里程', description = '正在加载所选条件的里程数据,请稍候' }: { title?: string; description?: string }) { + return ( + + +
+ + + + +
+
{title}
+
{description}
+
+ +
+
+
+ ); +} diff --git a/src/modules/mileage/MonitoringView.tsx b/src/modules/mileage/MonitoringView.tsx index 33be734..aee70fd 100644 --- a/src/modules/mileage/MonitoringView.tsx +++ b/src/modules/mileage/MonitoringView.tsx @@ -12,6 +12,7 @@ import type { import { fetchMonitoring } from './api'; import { exportMileageXlsx } from './xlsx-export'; import VehicleDetailModal from './VehicleDetailModal'; +import MileageLoadingOverlay from './MileageLoadingOverlay'; import { defaultMileageDate, getRangePreset, @@ -148,10 +149,14 @@ export default function MonitoringView() { return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM; }, [filterTargetNames]); + const queryRequestRef = useRef(0); + // 加载首页数据 const loadFirstPage = useCallback((showPageLoading = true) => { + const requestId = ++queryRequestRef.current; if (showPageLoading) setPageLoading(true); return fetchMonitoring(buildInitialMonitoringQuery(monitoringQuery, PAGE_SIZE)).then(d => { + if (requestId !== queryRequestRef.current) return; setVehicles(d.vehicles); setStats(d.stats); setFilterOptions(d.filters); @@ -162,31 +167,33 @@ export default function MonitoringView() { setHasMore(d.page < d.totalPages); setLastRefreshedAt(new Date()); }).catch(() => {}).finally(() => { - if (showPageLoading) setPageLoading(false); + if (requestId === queryRequestRef.current) setPageLoading(false); }); }, [monitoringQuery, rangeStart, rangeEnd]); const handleManualRefresh = useCallback(async () => { - if (manualRefreshing) return; + if (manualRefreshing || pageLoading) return; setManualRefreshing(true); try { await loadFirstPage(false); } finally { setManualRefreshing(false); } - }, [loadFirstPage, manualRefreshing]); + }, [loadFirstPage, manualRefreshing, pageLoading]); // 加载更多 const loadMore = useCallback(() => { - if (loadingMore || !hasMore) return; + if (pageLoading || loadingMore || !hasMore) return; + const requestId = queryRequestRef.current; const nextPage = page + 1; setLoadingMore(true); fetchMonitoring(buildNextPageMonitoringQuery(monitoringQuery, PAGE_SIZE, nextPage)).then(d => { + if (requestId !== queryRequestRef.current) return; setVehicles(prev => [...prev, ...d.vehicles]); setPage(nextPage); setHasMore(nextPage < d.totalPages); }).catch(() => {}).finally(() => setLoadingMore(false)); - }, [monitoringQuery, page, loadingMore, hasMore]); + }, [monitoringQuery, page, pageLoading, loadingMore, hasMore]); // 筛选/排序变化时重新加载 useEffect(() => { @@ -302,6 +309,9 @@ export default function MonitoringView() { return ( <> + + {pageLoading && } + {/* 顶部哨兵:离开视口时显示回到顶部按钮 */}
diff --git a/src/modules/mileage/daily-report/DailyReportLoadingOverlay.tsx b/src/modules/mileage/daily-report/DailyReportLoadingOverlay.tsx index fe065be..4507260 100644 --- a/src/modules/mileage/daily-report/DailyReportLoadingOverlay.tsx +++ b/src/modules/mileage/daily-report/DailyReportLoadingOverlay.tsx @@ -1,45 +1,6 @@ -import { RefreshCw } from 'lucide-react'; -import { motion } from 'motion/react'; +import MileageLoadingOverlay from '../MileageLoadingOverlay'; import { fmtDate } from './utils'; export default function DailyReportLoadingOverlay({ reportDate }: { reportDate: string }) { - return ( - - -
- - - - -
-
正在加载日报
-
正在汇总 {fmtDate(reportDate)} 的里程数据
-
- -
-
-
- ); + return ; } diff --git a/src/modules/mileage/monitoring/components/VehicleList.tsx b/src/modules/mileage/monitoring/components/VehicleList.tsx index 1bbb7c8..38ab994 100644 --- a/src/modules/mileage/monitoring/components/VehicleList.tsx +++ b/src/modules/mileage/monitoring/components/VehicleList.tsx @@ -134,7 +134,7 @@ export default function VehicleList({ })}
- {vehicles.length === 0 && !loadingMore && ( + {vehicles.length === 0 && !pageLoading && !loadingMore && (

未找到匹配数据

@@ -146,7 +146,7 @@ export default function VehicleList({ 加载中...
)} - {!hasMore && vehicles.length > 0 && ( + {!pageLoading && !hasMore && vehicles.length > 0 && (
已加载全部 {total} 条
From 96b64a7872cb86cdcb704bc9f07f4a28374d2e36 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Sun, 20 Sep 2026 17:12:03 +0800 Subject: [PATCH 07/13] fix: count deliveries awaiting customer signature --- src/server/routes/vehicles/repository.test.ts | 4 ++-- src/server/routes/vehicles/repository.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/server/routes/vehicles/repository.test.ts b/src/server/routes/vehicles/repository.test.ts index 5c95a11..623eba5 100644 --- a/src/server/routes/vehicles/repository.test.ts +++ b/src/server/routes/vehicles/repository.test.ts @@ -145,9 +145,9 @@ test('replacement trips come exclusively from linked delivery records', () => { assert.doesNotMatch(FLOW_STATS_SQL, /FROM vehicle_replacement|dv\.delivery_time|r\.arrival_time|vr\.replace_time/); }); -test('completed delivery and replacement trips use vehicle status and exclude suspended subjects', () => { +test('delivery and replacement trips include awaiting customer signature and exclude suspended subjects', () => { const [deliveries, returns] = FLOW_STATS_SQL.split('UNION ALL'); - assert.ok(deliveries.includes('AND dv.delivery_status IN (2,3,20)')); + assert.ok(deliveries.includes('AND dv.delivery_status IN (2,3,5,20)')); assert.ok(deliveries.includes('AND (dts.status IS NULL OR dts.status <> 10)')); assert.doesNotMatch(deliveries, /dts\.status\s*(?:=|IN)|dv\.delivery_status IN \(2,3,5\)/); assert.ok(returns.includes('AND r.status IN (2,3,5)')); diff --git a/src/server/routes/vehicles/repository.ts b/src/server/routes/vehicles/repository.ts index aa10cc4..46d42c5 100644 --- a/src/server/routes/vehicles/repository.ts +++ b/src/server/routes/vehicles/repository.ts @@ -239,7 +239,8 @@ export const FLOW_STATS_SQL = ` WHERE dv.del_flag = '0' AND dv.vehicle_id IS NOT NULL AND dv.delivery_completion_processed_time IS NOT NULL - AND dv.delivery_status IN (2,3,20) + -- 待客户签章也计入交车闭环,日期仍按运维签章时间归属。 + AND dv.delivery_status IN (2,3,5,20) AND (dts.status IS NULL OR dts.status <> 10) AND dv.delivery_completion_processed_time >= ? AND dv.delivery_completion_processed_time < DATE_ADD(?, INTERVAL 1 DAY) From 9b0980e349d256b2a1274ec4560ae0b18f968366 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Tue, 22 Sep 2026 19:50:58 +0800 Subject: [PATCH 08/13] fix: refresh vehicle asset statistics every 5 seconds --- src/modules/assets/AssetsModule.tsx | 2 +- src/server/routes/vehicles/repository.test.ts | 4 ++-- src/server/routes/vehicles/repository.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index f96c26d..3bec638 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -167,7 +167,7 @@ export default function AssetsModule() { useEffect(() => { loadData(); - const interval = setInterval(loadData, 60 * 1000); + const interval = setInterval(loadData, 5 * 1000); return () => clearInterval(interval); }, [loadData]); diff --git a/src/server/routes/vehicles/repository.test.ts b/src/server/routes/vehicles/repository.test.ts index 623eba5..7676e0d 100644 --- a/src/server/routes/vehicles/repository.test.ts +++ b/src/server/routes/vehicles/repository.test.ts @@ -49,7 +49,7 @@ test('keeps all vehicle GET endpoints in their established order', () => { assert.doesNotMatch(source, /\bSELECT\b|pool\.query|MAIN_SQL/); }); -test('keeps the non-empty vehicle cache for less than 60 seconds', async () => { +test('keeps the non-empty vehicle cache for less than 5 seconds', async () => { let now = 100_000; const db = new FakeDatabase([[{ id: 'first' }], [{ id: 'second' }]]); const repository = new VehicleRepository( @@ -59,7 +59,7 @@ test('keeps the non-empty vehicle cache for less than 60 seconds', async () => { ); assert.deepEqual(await repository.getVehicles(), [vehicle('first')]); - now += 59_999; + now += 4_999; assert.deepEqual(await repository.getVehicles(), [vehicle('first')]); now += 1; assert.deepEqual(await repository.getVehicles(), [vehicle('second')]); diff --git a/src/server/routes/vehicles/repository.ts b/src/server/routes/vehicles/repository.ts index 46d42c5..a883cd0 100644 --- a/src/server/routes/vehicles/repository.ts +++ b/src/server/routes/vehicles/repository.ts @@ -278,7 +278,7 @@ export const FLOW_STATS_SQL = ` ORDER BY flow.event_time DESC, flow.id DESC `; -const CACHE_TTL = 60 * 1000; +const CACHE_TTL = 5 * 1000; export class VehicleRepository { private cachedVehicles: Vehicle[] = []; From b0cba26fbb242fdec8b62757788187f961970051 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Wed, 23 Sep 2026 11:07:36 +0800 Subject: [PATCH 09/13] feat: improve asset inventory statistics and responsive UX (v1.1.16) --- package-lock.json | 4 +- package.json | 2 +- src/components/SearchSelect.tsx | 92 ++----- src/modules/assets/AssetsModule.tsx | 58 ++--- src/modules/assets/assets.css | 25 ++ .../components/AbnormalVehicleDetails.tsx | 52 ++++ .../assets/components/AssetsHeader.tsx | 238 +++--------------- .../assets/components/FlowDetailModal.tsx | 9 +- .../assets/components/OverviewView.tsx | 16 +- .../assets/components/VehicleDetailModal.tsx | 42 +++- .../assets/components/VehicleMobileCards.tsx | 15 ++ .../overview/AssetSummaryDesktopTable.tsx | 14 +- .../overview/AssetSummaryMobileCards.tsx | 20 +- .../overview/InventoryDesktopTable.tsx | 19 +- .../overview/InventoryMobileList.tsx | 21 +- .../overview/InventoryStatisticsSection.tsx | 23 +- .../overview/InventoryStatusCounts.tsx | 11 + .../components/overview/SummaryMetrics.tsx | 144 ++++------- .../assets/components/useDialogFocus.ts | 22 ++ src/modules/assets/types.ts | 6 + src/server/auth/permissions.ts | 4 + src/server/routes/vehicles.ts | 78 +++--- .../routes/vehicles/abnormal-move.test.ts | 29 +++ src/server/routes/vehicles/flow.test.ts | 64 ++++- src/server/routes/vehicles/model.test.ts | 12 +- src/server/routes/vehicles/model.ts | 35 +++ src/server/routes/vehicles/repository.ts | 33 +++ src/server/routes/vehicles/utils.ts | 10 +- src/server/types.ts | 13 + src/shared/assets/abnormal-move.ts | 13 + 30 files changed, 626 insertions(+), 498 deletions(-) create mode 100644 src/modules/assets/assets.css create mode 100644 src/modules/assets/components/AbnormalVehicleDetails.tsx create mode 100644 src/modules/assets/components/VehicleMobileCards.tsx create mode 100644 src/modules/assets/components/overview/InventoryStatusCounts.tsx create mode 100644 src/modules/assets/components/useDialogFocus.ts create mode 100644 src/server/routes/vehicles/abnormal-move.test.ts create mode 100644 src/shared/assets/abnormal-move.ts diff --git a/package-lock.json b/package-lock.json index e3f05c2..98a4ae1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ln-bi", - "version": "1.1.15", + "version": "1.1.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ln-bi", - "version": "1.1.15", + "version": "1.1.16", "dependencies": { "@amap/amap-jsapi-loader": "^1.0.1", "@hono/node-server": "^1.13.0", diff --git a/package.json b/package.json index fb2e3d1..4226b3c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ln-bi", "private": true, - "version": "1.1.15", + "version": "1.1.16", "type": "module", "scripts": { "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", diff --git a/src/components/SearchSelect.tsx b/src/components/SearchSelect.tsx index fc59a12..619871a 100644 --- a/src/components/SearchSelect.tsx +++ b/src/components/SearchSelect.tsx @@ -1,71 +1,33 @@ -import { useState, useEffect, useMemo, useRef } from 'react'; +import { useState, useEffect, useMemo, useRef, useId } from 'react'; import { ChevronDown } from 'lucide-react'; export function SearchSelect({ value, onChange, options, placeholder, className }: { - value: string; - onChange: (v: string) => void; - options: string[]; - placeholder: string; - className?: string; + value: string; onChange: (value: string) => void; options: string[]; placeholder: string; className?: string; }) { - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(''); - const ref = useRef(null); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, []); - - const filtered = useMemo(() => { - if (!query) return options; - const q = query.toLowerCase(); - return options.filter((o) => o.toLowerCase().includes(q)); - }, [options, query]); - - const displayValue = value || ''; - - return ( -
-
setOpen(!open)} - > - { setQuery(e.target.value); if (!open) setOpen(true); }} - onFocus={() => { setOpen(true); setQuery(''); }} - /> - -
- {open && ( -
-
{ onChange(''); setQuery(''); setOpen(false); }} - > - {placeholder} -
- {filtered.map((o) => ( -
{ onChange(o); setQuery(''); setOpen(false); }} - > - {o} -
- ))} - {filtered.length === 0 && ( -
无匹配项
- )} -
- )} + const [open,setOpen]=useState(false); + const [query,setQuery]=useState(''); + const [active,setActive]=useState(0); + const ref=useRef(null); + const inputRef=useRef(null); + const id=useId(); + const filtered=useMemo(()=>options.filter(option=>option.toLowerCase().includes(query.toLowerCase())),[options,query]); + const choices=['',...filtered]; + const choose=(option:string)=>{onChange(option);setQuery('');setOpen(false);}; + useEffect(()=>{const outside=(event:MouseEvent)=>{if(ref.current && !ref.current.contains(event.target as Node))setOpen(false);};document.addEventListener('mousedown',outside);return()=>document.removeEventListener('mousedown',outside);},[]); + useEffect(()=>{if(open)document.getElementById(`${id}-${active}`)?.scrollIntoView({block:'nearest'});},[open,active,id]); + return
+
+ setOpen(true)} onFocus={()=>{setOpen(true);setQuery('');setActive(0);}} + onChange={e=>{setQuery(e.target.value);setActive(0);setOpen(true);}} + onKeyDown={e=>{if(e.key==='Escape'){e.stopPropagation();setOpen(false);}else if(e.key==='ArrowDown'||e.key==='ArrowUp'){e.preventDefault();setOpen(true);setActive(i=>(i+(e.key==='ArrowDown'?1:-1)+choices.length)%choices.length);}else if(e.key==='Enter'&&open){e.preventDefault();choose(choices[active]??'');}else if(e.key==='Tab')setOpen(false);}} + /> +
- ); + {open &&
+ {choices.map((option,index)=>)} + {filtered.length===0 &&

无匹配项

} +
} +
; } diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index 3bec638..0ecf4a5 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -1,3 +1,4 @@ +import './assets.css'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Truck, @@ -129,6 +130,8 @@ export default function AssetsModule() { const [regionChartData, setRegionChartData] = useState<{ name: string; value: number }[]>([]); // Modal filter state + const [modalError, setModalError] = useState(null); + const [modalRetry, setModalRetry] = useState(0); const [modalFilters, setModalFilters] = useState({ plateNumber: '', model: '', brand: '', location: '' }); const [isModalFilterExpanded, setIsModalFilterExpanded] = useState(false); @@ -227,30 +230,22 @@ export default function AssetsModule() { .catch(() => setRegionChartData([])); }, [regionChartView, selectedSubject]); - // Load modal vehicles + // Keep stale requests from overwriting a newer selection or a closed dialog. useEffect(() => { - if (!showPlateNumbers) { - setModalVehicles([]); - setModalWeeklyDetail([]); - return; - } + let cancelled = false; + setModalVehicles([]); + setModalWeeklyDetail([]); + setModalError(null); + if (!showPlateNumbers) return; setModalLoading(true); const request = buildVehicleModalRequest(showPlateNumbers, selectedSubject); - if (request.kind === 'weekly') { - setModalVehicles([]); - fetchWeeklyDetail(request.type, request.filters) - .then(setModalWeeklyDetail) - .catch(() => setModalWeeklyDetail([])) - .finally(() => setModalLoading(false)); - return; - } - - setModalWeeklyDetail([]); - fetchVehicleList(request.params) - .then(setModalVehicles) - .catch(() => setModalVehicles([])) - .finally(() => setModalLoading(false)); - }, [showPlateNumbers, selectedSubject]); + const load = request.kind === 'weekly' + ? fetchWeeklyDetail(request.type, request.filters).then(rows=>{if(!cancelled)setModalWeeklyDetail(rows);}) + : fetchVehicleList(request.params).then(rows=>{if(!cancelled)setModalVehicles(rows);}); + load.catch(()=>{if(!cancelled)setModalError('车辆明细加载失败,请重试。');}) + .finally(()=>{if(!cancelled)setModalLoading(false);}); + return ()=>{cancelled=true;}; + }, [showPlateNumbers, selectedSubject, modalRetry]); const allTypesExpanded = processedData.length > 0 && processedData.every((t) => expandedAssetTypes.has(t.type)); @@ -473,7 +468,7 @@ export default function AssetsModule() { const pendingRate = SUMMARY.totalAssets > 0 ? SUMMARY.pendingDelivery / SUMMARY.totalAssets * 100 : 0; return ( -
+
+ {error && summary &&
更新失败,当前显示上次成功加载的数据。
} + {activeTab !== 'overview' &&

{activeTab==='department'?'按部门与负责人查看运营车辆,点击数量查看明细。':activeTab==='region'?'查看各区域的车辆分布,展开区域继续查看城市与车型。':'按客户查看运营车辆,客户名称已脱敏;点击合计查看车辆明细。'}

} {/* Main Content Area */}
@@ -1135,12 +1133,12 @@ export default function AssetsModule() { {isRegionFilterOpen && ( <> -
+ - ))} -
-
-
- {/* 归属公司作用域筛选 (Scope Chip) */} -
-
- - {subjectDropdownOpen && ( -
-
-
- - 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" - /> -
-
-
- -
- {subjects - .filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())) - .map((s) => { - const active = selectedSubject === s.name; - return ( - - ); - })} - {subjects.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())).length === 0 && ( -
未找到匹配公司
- )} -
-
- )} -
-
- - {/* Tab row */} -
- {TABS.map(tab => ( - - ))} -
- {/* Status row */} -
-
- - 数据更新时间:{lastUpdate.slice(0, 16)} -
- - 运维台账每日统计口径:当日17:00 -
+export function AssetsHeader(props: AssetsHeaderProps) { + const {activeTab,setActiveTab,selectedSubject,setSelectedSubject,subjectDropdownOpen,setSubjectDropdownOpen} = props; + const chooseSubject = (subject: string | null) => {setSelectedSubject(subject);setSubjectDropdownOpen(false);}; + return
+
+

资产管理

运营、库存与交还车,一处查看

+
+ +
- - - ); +
+
+
+ + {selectedSubject && } + {subjectDropdownOpen &&
{if(e.key==='Escape'){setSubjectDropdownOpen(false);e.stopPropagation();}}}> + +
+ + {props.subjects.filter(s=>s.name.includes(props.subjectSearch)).map(s=>)} + {props.subjects.filter(s=>s.name.includes(props.subjectSearch)).length===0 &&

没有匹配的公司

} +
+
} +
+

更新于 {props.lastUpdate.slice(11,16)} · 运维台账截至当日 17:00

+
+ +
; } diff --git a/src/modules/assets/components/FlowDetailModal.tsx b/src/modules/assets/components/FlowDetailModal.tsx index 5935a00..54ee692 100644 --- a/src/modules/assets/components/FlowDetailModal.tsx +++ b/src/modules/assets/components/FlowDetailModal.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useRef, useCallback } from 'react'; +import { useDialogFocus } from './useDialogFocus'; import { Download, X } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import type { FlowDetailItem, FlowType } from '../api'; @@ -22,6 +23,9 @@ export function FlowDetailModal({ setSelectedFlow, exportFlowDetails, }: FlowDetailModalProps) { + const dialogRef = useRef(null); + const close = useCallback(()=>setSelectedFlow(null),[setSelectedFlow]); + useDialogFocus(Boolean(selectedFlow),dialogRef,close); const dateLabel = selectedFlow ? ('date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`) : ''; @@ -32,6 +36,7 @@ export function FlowDetailModal({ {selectedFlow && (
运维签章时间口径 · 单位:车次
-

+

{dateLabel} · {FLOW_META[selectedFlow.type].label}明细

diff --git a/src/modules/assets/components/OverviewView.tsx b/src/modules/assets/components/OverviewView.tsx index a9f2189..4610a34 100644 --- a/src/modules/assets/components/OverviewView.tsx +++ b/src/modules/assets/components/OverviewView.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { AssetSummarySection } from './overview/AssetSummarySection'; import { FlowStatisticsCard } from './overview/FlowStatisticsCard'; import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection'; @@ -5,8 +6,9 @@ import { SummaryMetrics } from './overview/SummaryMetrics'; import type { OverviewViewProps } from './overview/types'; export function OverviewView(props: OverviewViewProps) { + const [panel, setPanel] = useState<'inventory' | 'models' | 'flow'>('inventory'); return ( - <> +
+ + + + + +
); } diff --git a/src/modules/assets/components/VehicleDetailModal.tsx b/src/modules/assets/components/VehicleDetailModal.tsx index fa8b609..b87e902 100644 --- a/src/modules/assets/components/VehicleDetailModal.tsx +++ b/src/modules/assets/components/VehicleDetailModal.tsx @@ -1,4 +1,7 @@ -import React from 'react'; +import React, { useRef, useCallback } from 'react'; +import { useDialogFocus } from './useDialogFocus'; +import { VehicleMobileCards } from './VehicleMobileCards'; +import { AbnormalVehicleDetails, PendingVehicleDetails } from './AbnormalVehicleDetails'; import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import Blur from '../../../components/Blur'; @@ -19,6 +22,8 @@ interface VehicleDetailModalProps { brands: string[]; locations: string[]; loading: boolean; + error?: string | null; + onRetry?: () => void; weeklyDetails: WeeklyDetailItem[]; filteredWeeklyDetails: WeeklyDetailItem[]; filteredVehicles: VehicleListItem[]; @@ -35,24 +40,30 @@ export function VehicleDetailModal({ brands: uniqueModalBrands, locations: uniqueModalLocations, loading: modalLoading, + error, + onRetry, weeklyDetails: modalWeeklyDetail, filteredWeeklyDetails: filteredModalWeeklyDetail, filteredVehicles: filteredModalVehicles, }: VehicleDetailModalProps) { + const dialogRef = useRef(null); + const close = useCallback(() => setShowPlateNumbers(null), [setShowPlateNumbers]); + useDialogFocus(Boolean(showPlateNumbers), dialogRef, close); return ( <> {/* Vehicle Detail Modal */} {showPlateNumbers && ( -
+
-

+

{showPlateNumbers.title || ( (showPlateNumbers.manager ? `${showPlateNumbers.manager} 的${showPlateNumbers.type || ''}车辆` : @@ -67,11 +78,11 @@ export function VehicleDetailModal({ showPlateNumbers.category === 'Returned' ? '已还车' : showPlateNumbers.category === 'Replaced' ? '已替换' : showPlateNumbers.category === 'Abnormal' ? '异动' : - showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` : + showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location === 'All' ? '总' : showPlateNumbers.location}库存` : showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}

-
@@ -90,7 +101,7 @@ export function VehicleDetailModal({ {/* Quick Search always visible when collapsed */} {!isModalFilterExpanded && (
e.stopPropagation()}> - setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="快速搜索车牌..." className="text-[11px] py-1 px-2" /> + setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="搜索车牌 / VIN" className="text-[11px] py-1 px-2" />
)}
- {modalLoading ? ( + {error ? ( +

{error}

+ ) : modalLoading ? (
+ ) : showPlateNumbers.category === 'Abnormal' ? ( + + ) : showPlateNumbers.category === 'Pending' ? ( + ) : modalWeeklyDetail.length > 0 ? (
@@ -177,7 +194,9 @@ export function VehicleDetailModal({
) : ( -
+ <> + +
@@ -223,7 +242,7 @@ export function VehicleDetailModal({
{v.customerName || '—'} {v.plateNumber || v.vin || '—'} - @@ -259,12 +278,13 @@ export function VehicleDetailModal({
+ )}
- 共计 {filteredModalWeeklyDetail.length > 0 ? filteredModalWeeklyDetail.length : filteredModalVehicles.length} 台车辆 + 共计 {['Delivered','Returned','Replaced'].includes(showPlateNumbers.category || '') ? filteredModalWeeklyDetail.length : filteredModalVehicles.length} 台车辆
+
+

+ 资产管理 /{" "} + {selection?.source === "department" + ? "部门运营" + : selection?.source === "region" + ? "区域分布" + : selection?.source === "customer" + ? "客户资产" + : "资产统计"} +

+ + {title} + +
+ +
+ + {subject || "全部公司"} ·{" "} + {loading + ? "正在加载车辆…" + : `${isWeekly ? weekly.length : vehicles.length} 条记录`} + + {selection?.scopeLabel && ( +

{selection.scopeLabel}

+ )} + {context.length > 0 && ( +
+ {context.map(([label, value]) => ( + + {label}:{value} + + ))} +
+ )} + {selection?.customer && ( +

+ 客户:{selection.customer} +

+ )} + {selection?.manager && ( +

+ 负责人:{selection.manager} +

+ )} + +
+ {error ? ( + setRetry((n) => n + 1)} /> + ) : loading ? ( + + ) : isWeekly ? ( + <> + + + + ) : ( + <> + ({ + label: ["型号", "品牌", "所在地"][i], + value: filters[key], + values: unique(vehicles.map((r) => r[key])), + change: (value) => + setFilters((f) => ({ ...f, [key]: value })), + }), + )} + onReset={() => + setFilters({ model: "", brandLabel: "", location: "" }) + } + /> + + + + )} +
+
+ 关闭后返回原来的筛选与浏览位置 + +
+ + + ); +} diff --git a/src/modules/assets/library/asset-ui.css b/src/modules/assets/library/asset-ui.css new file mode 100644 index 0000000..ccd252d --- /dev/null +++ b/src/modules/assets/library/asset-ui.css @@ -0,0 +1,1040 @@ +/* Asset BI only: shared by the page and portalled drill-down panels. */ +.asset-ui { + --font-sans: + "Geist Variable", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", + system-ui, sans-serif; + --font-heading: var(--font-sans); + --radius: 0.625rem; + --blue: #2563eb; + --label: #172b4d; + --label-secondary: #5e6e85; + --bg-secondary: #f3f6fa; + --bg-tertiary: #fff; + --background: #fff; + --foreground: #172b4d; + --card-foreground: #172b4d; + --muted: #edf2f7; + --muted-foreground: #5e6e85; + --primary: #2563eb; + --border: #dde5ef; + --input: #cbd5e1; + --separator: #dde5ef; + --asset-soft: #eff5ff; + --asset-hover: #e7efff; + --shadow-1: 0 1px 2px rgb(23 43 77 / 3%); + --shadow-2: var(--shadow-1); + --shadow-3: 0 2px 8px rgb(23 43 77 / 6%); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; + color: var(--label); +} +[data-theme="dark"] .asset-ui { + --label: #e2eaf5; + --label-secondary: #a4b3c8; + --bg-secondary: #111b2b; + --bg-tertiary: #19263a; + --background: #19263a; + --foreground: #e2eaf5; + --card-foreground: #e2eaf5; + --muted: #23324a; + --muted-foreground: #a4b3c8; + --primary: #8bb6ff; + --blue: #8bb6ff; + --border: #34445c; + --input: #52647e; + --separator: #34445c; + --asset-soft: #1d3050; + --asset-hover: #253c60; +} +.asset-ui [data-slot="card"] { + border: 1px solid var(--border); + border-radius: 12px; + --card-spacing: 12px; +} +.asset-ui [data-slot="card-title"] { + letter-spacing: 0; +} +.asset-ui [data-slot="card-description"] { + line-height: 1.6; +} +.asset-ui [data-slot="button"], +.asset-ui [data-slot="select-trigger"], +.asset-ui [data-slot="input"] { + border-radius: 8px; +} +.asset-ui button:not(:disabled), +.asset-ui [role="tab"] { + cursor: pointer; +} +.asset-ui button:active { + translate: none; +} +.asset-ui :focus-visible { + outline: 2px solid var(--blue); + outline-offset: 3px; +} +.asset-ui [data-slot="tabs-list"] { + height: auto; + min-height: 36px; +} +.asset-ui [data-slot="tabs-trigger"] { + min-height: 36px; + padding: 6px 12px; + color: var(--label-secondary); +} +.asset-ui [data-slot="tabs-trigger"][data-state="active"] { + background: var(--bg-tertiary); + color: var(--blue); + box-shadow: 0 1px 4px rgb(23 43 77 / 8%); +} +.asset-ui .asset-main-tabs { + background: transparent; + border-bottom: 1px solid var(--border); + border-radius: 0; + width: 100%; + justify-content: flex-start; + gap: 24px; + padding: 0; + margin-bottom: 4px; +} +.asset-ui .asset-main-tabs [role="tab"] { + flex: none; + border-radius: 0; + min-height: 36px; + padding-inline: 4px; + border-bottom: 2px solid transparent; +} +.asset-ui .asset-main-tabs [role="tab"][data-state="active"] { + box-shadow: none; + background: transparent; + border-bottom-color: var(--blue); +} +.asset-ui .asset-page-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} +.asset-ui .asset-title-icon { + color: var(--blue); + background: var(--asset-soft); + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px; +} +.asset-ui .asset-total { + display: flex; + align-items: center; + gap: 12px; + padding-left: 16px; + border-left: 1px solid var(--border); +} +.asset-ui .asset-total button { + font-size: 26px; + font-weight: 650; + color: var(--label); + min-height: 44px; +} +.asset-ui .asset-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--border); + padding-bottom: 8px; +} +.asset-ui .asset-toolbar [data-slot="field"] { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; +} +.asset-ui .asset-toolbar [data-slot="field-label"] { + white-space: nowrap; + color: var(--label-secondary); +} +.asset-ui .asset-toolbar [data-slot="select-trigger"] { + min-width: 180px; +} +.asset-ui .asset-kpi { + background: var(--bg-tertiary); + border-top: 3px solid var(--blue); +} +.asset-ui .asset-kpi.inventory { + border-top-color: #64748b; +} +.asset-ui .asset-kpi [data-slot="card-header"] { + min-height: 40px; +} +.asset-ui .asset-kpi [data-slot="card-content"] { + gap: 8px; +} +.asset-ui [data-slot="card-content"].grid { + row-gap: 12px; +} +@media (min-width: 768px) { + .asset-ui .asset-kpi { + row-gap: 8px; + } + .asset-ui .asset-kpi-number { + min-height: 36px; + } + .asset-ui .asset-toolbar [data-slot="button"], + .asset-ui .asset-toolbar [data-slot="select-trigger"] { + min-height: 36px; + height: 36px; + } + .asset-ui .asset-toolbar [data-size="icon"] { + min-width: 36px; + width: 36px; + } +} +@media (min-width: 1100px) { + .asset-ui .asset-kpi { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); + align-items: center; + column-gap: 0; + } + .asset-ui .asset-kpi [data-slot="card-content"] { + padding-left: 8px; + } +} +.asset-ui .asset-kpi [data-slot="card-title"] svg { + width: 18px; + height: 18px; + color: var(--label-secondary); +} +.asset-ui .asset-kpi-number { + min-height: 44px; + padding: 0; + gap: 8px; + font-size: 28px; + line-height: 1.1; + font-weight: 650; + letter-spacing: -1px; + color: var(--blue); +} +.asset-ui .asset-kpi-number .asset-kpi-unit { + font-size: 13px; + font-weight: 400; + letter-spacing: 0; + color: var(--label-secondary); +} +.asset-ui .asset-kpi-number svg { + width: 18px; + height: 18px; + margin-left: 6px; + opacity: 0.65; +} +.asset-ui .asset-kpi-number:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 6px; +} +.asset-ui .asset-kpi.inventory .asset-kpi-number { + color: var(--label); +} +.asset-ui .asset-group { + border: 1px solid var(--border); + border-radius: 10px; + margin-top: 10px; + overflow: hidden; +} +.asset-ui .asset-group > h3 { + background: var(--bg-secondary); +} +.asset-ui .asset-group > h3 button { + padding: 8px 12px; + min-height: 40px; + text-decoration: none; +} +.asset-ui .asset-group > [role="region"] > div { + padding: 12px; +} +.asset-ui .asset-group-name { + font-size: 14px; + font-weight: 600; +} +.asset-ui .asset-count { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 5px; + min-height: 32px; + min-width: 44px; + padding: 0 6px; + color: var(--blue); + font-size: 14px; + font-weight: 600; + border-radius: 5px; +} +.asset-ui .asset-count:hover { + background: var(--asset-hover); + text-decoration: none; +} +.asset-ui .asset-count svg { + width: 12px; + height: 12px; + opacity: 0.65; +} +.asset-ui .asset-zero { + color: var(--label-secondary); + display: inline-block; + padding-right: 23px; + font-weight: 400; +} +.asset-ui .ag-numeric-header .ag-header-cell-label { + justify-content: flex-end; +} +.asset-ui .ag-cell.ag-cell-last-left-pinned { + font-weight: 550; +} +.asset-ui .asset-grid-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.asset-ui .asset-grid-search { + position: relative; + width: min(100%, 280px); +} +.asset-ui .asset-grid-search input { + height: 38px; + padding-left: 34px; + padding-right: 36px; + background: var(--bg-tertiary); +} +.asset-ui .asset-grid-search > svg { + position: absolute; + left: 10px; + top: 11px; + width: 16px; + height: 16px; + color: var(--label-secondary); +} +.asset-ui .asset-grid-search button { + position: absolute; + right: 0; + top: 0; + height: 38px; + width: 36px; +} +.asset-ui .asset-record { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + box-shadow: var(--shadow-1); +} +.asset-ui .asset-record-head { + padding: 10px 12px; + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--border); +} +.asset-ui .asset-record-head h4 { + font-size: 15px; + font-weight: 600; + overflow-wrap: anywhere; +} +.asset-ui .asset-record-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 12px; + padding: 10px 12px; +} +.asset-ui .asset-record-fields dt { + font-size: 12px; + color: var(--label-secondary); + margin-bottom: 4px; +} +.asset-ui .asset-record-fields dd { + font-size: 14px; + overflow-wrap: anywhere; +} +.asset-ui .asset-record-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + background: var(--border); + border-top: 1px solid var(--border); +} +.asset-ui .asset-metric-cell { + min-width: 0; + background: var(--asset-soft); + padding: 10px 12px; +} +.asset-ui .asset-metric-cell dt { + font-size: 12px; + color: var(--label-secondary); +} +.asset-ui button.asset-metric-cell { + display: flex; + flex-direction: column; + align-items: start; + text-align: left; + min-height: 60px; + gap: 4px; +} +.asset-ui button.asset-metric-cell:hover { + background: var(--asset-hover); +} +.asset-ui .asset-metric-value { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + font-size: 20px; + font-weight: 600; + color: var(--blue); +} +.asset-ui .asset-metric-value svg { + width: 14px; + height: 14px; +} +.asset-ui .asset-metric-cell.asset-metric-empty { + background: var(--bg-secondary); +} +.asset-ui .asset-metric-empty .asset-metric-value { + color: var(--label-secondary); + font-weight: 400; +} +.asset-ui.asset-detail { + display: flex; + flex-direction: column; + gap: 0; + padding: 0; + width: calc(100% - 64px); + max-width: 1280px; + max-height: 90dvh; + overflow: hidden; + border: 1px solid var(--border); + background: var(--bg-tertiary); + color: var(--label); + border-radius: 14px; +} +.asset-ui .asset-detail-header { + flex-shrink: 0; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background: var(--bg-tertiary); +} +.asset-ui .asset-detail-body { + overflow: auto; + overscroll-behavior: contain; + min-height: 0; + padding: 12px 16px; + display: grid; + gap: 16px; +} +.asset-ui .asset-detail-footer { + border-top: 1px solid var(--border); + padding: 8px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--label-secondary); + font-size: 12px; +} +.asset-ui .asset-context { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 12px; +} +.asset-ui .asset-context span { + font-size: 12px; + padding: 3px 8px; + border: 1px solid var(--border); + background: var(--bg-secondary); + border-radius: 5px; + overflow-wrap: anywhere; +} +@media (max-width: 767px) { + .asset-ui { + font-size: 14px; + } + .asset-ui [data-slot="card"] { + --card-spacing: 12px; + border-radius: 10px; + } + .asset-ui .asset-page-header { + gap: 12px; + } + .asset-ui .asset-title-icon { + display: none; + } + .asset-ui .asset-total { + display: block; + padding-left: 12px; + text-align: right; + } + .asset-ui .asset-total button { + font-size: 26px; + padding-right: 0; + } + .asset-ui .asset-toolbar { + gap: 12px; + padding-bottom: 12px; + } + .asset-ui .asset-toolbar [data-slot="field"] { + flex-direction: column; + align-items: start; + gap: 6px; + } + .asset-ui .asset-toolbar [data-slot="select-trigger"] { + min-width: 0; + width: 100%; + } + .asset-ui .asset-main-tabs { + gap: 0; + margin-bottom: 8px; + } + .asset-ui .asset-main-tabs [role="tab"] { + min-height: 44px; + flex: 1; + } + .asset-ui [data-slot="tabs-trigger"] { + padding-inline: 10px; + min-height: 44px; + } + .asset-ui [data-slot="button"]:not(.asset-kpi-number), + .asset-ui [data-slot="select-trigger"] { + min-height: 44px; + } + .asset-ui [data-slot="button"][data-size^="icon"] { + min-width: 44px; + } + .asset-ui .asset-kpi [data-slot="card-header"] { + min-height: 0; + } + .asset-ui .asset-kpi-number { + font-size: 28px; + } + .asset-ui .asset-kpi [data-slot="card-action"] { + align-self: center; + } + .asset-ui .asset-group > h3 button { + padding: 12px; + } + .asset-ui .asset-group > [role="region"] > div { + padding: 12px; + } + .asset-ui .asset-grid-search { + width: 100%; + } + .asset-ui .asset-grid-search input { + height: 44px; + font-size: 16px; + } + .asset-ui .asset-grid-search > svg { + top: 14px; + } + .asset-ui .asset-grid-search button { + height: 44px; + width: 44px; + } + .asset-ui.asset-detail { + top: 0; + left: 0; + transform: none; + translate: none; + width: 100%; + max-width: 100%; + height: 100dvh; + max-height: 100dvh; + border: 0; + border-radius: 0; + } + .asset-ui .asset-detail-header { + padding: max(12px, env(safe-area-inset-top)) 16px 12px; + } + .asset-ui .asset-detail-body { + padding: 16px; + flex: 1; + align-content: start; + background: var(--bg-secondary); + } + .asset-ui .asset-detail-footer { + padding: 10px 16px max(10px, env(safe-area-inset-bottom)); + } +} +@media (prefers-reduced-motion: reduce) { + .asset-ui *, + .asset-ui *::before, + .asset-ui *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} +.asset-ui .inventory-panel { + min-width: 0; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + box-shadow: var(--shadow-1); +} +.asset-ui .inventory-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} +.asset-ui .inventory-heading-title { + position: relative; + padding-left: 16px; +} +.asset-ui .inventory-heading-title::before { + content: ""; + position: absolute; + left: 0; + top: 3px; + width: 4px; + height: 36px; + border-radius: 3px; + background: var(--blue); +} +.asset-ui .inventory-heading h2 { + font-size: 18px; + font-weight: 650; +} +.asset-ui .inventory-heading p { + font-size: 12px; + color: var(--label-secondary); + margin-top: 4px; +} +.asset-ui .inventory-total { + display: grid; + text-align: right; + gap: 0; + white-space: nowrap; +} +.asset-ui .inventory-total > span { + font-size: 12px; + color: var(--label-secondary); +} +.asset-ui .inventory-total strong { + font-size: 28px; + line-height: 1.2; + font-weight: 650; + letter-spacing: -0.5px; +} +.asset-ui .inventory-total small { + margin-left: 6px; + font-size: 12px; + font-weight: 400; + color: var(--label-secondary); +} +.asset-ui .inventory-filter-count { + background: var(--blue); + color: var(--background); + min-width: 20px; + border-radius: 4px; + font-size: 12px; +} +.asset-ui .inventory-filters { + padding: 16px 24px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); +} +.asset-ui .inventory-filter-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px 24px 0; +} +.asset-ui .inventory-filter-chips button { + white-space: normal; + height: auto; + text-align: left; +} +.asset-ui .inventory-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 24px 12px; +} +.asset-ui .inventory-search { + position: relative; + margin-left: auto; + width: 260px; +} +.asset-ui .inventory-search input { + height: 40px; + padding-left: 34px; + padding-right: 36px; + background: var(--bg-tertiary); +} +.asset-ui .inventory-search > svg { + width: 16px; + height: 16px; + position: absolute; + left: 10px; + top: 12px; + color: var(--label-secondary); +} +.asset-ui .inventory-search button { + position: absolute; + right: 0; + top: 0; + height: 40px; + width: 36px; +} +.asset-ui .inventory-summary { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + padding: 0 24px 12px; + font-size: 12px; + color: var(--label-secondary); +} +.asset-ui .inventory-table-scroll { + overflow: auto; + max-height: 660px; + scrollbar-gutter: stable; +} +.asset-ui .inventory-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + table-layout: fixed; + font-size: 14px; +} +.asset-ui .inventory-table thead { + position: sticky; + top: 0; + z-index: 2; +} +.asset-ui .inventory-table thead th { + background: var(--bg-secondary); + color: var(--label-secondary); + font-size: 12px; + font-weight: 600; + padding: 12px 20px; + text-align: left; + border-block: 1px solid var(--border); +} +.asset-ui .inventory-table td, +.asset-ui .inventory-table th.inventory-model-cell { + padding: 10px 20px; + border-bottom: 1px solid var(--border); + overflow-wrap: anywhere; +} +.asset-ui .inventory-table tr:hover td, +.asset-ui .inventory-table tr:hover th.inventory-model-cell { + background: var(--asset-soft); +} +.asset-ui .inventory-table .inventory-numeric { + text-align: right; +} +.asset-ui .inventory-numeric > button:not(.asset-count) { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 24px; +} +.asset-ui .inventory-numeric svg { + width: 14px; + height: 14px; +} +.asset-ui .inventory-group-row th { + padding: 0; + border-bottom: 1px solid var(--border); +} +.asset-ui .inventory-group-toggle { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + min-height: 48px; + text-align: left; + padding: 10px 20px; + background: var(--bg-secondary); + font-size: 14px; +} +.asset-ui .inventory-group-toggle > svg { + width: 16px; + height: 16px; + color: var(--label-secondary); + transition: transform 160ms ease; +} +.asset-ui .inventory-group-toggle:hover { + background: var(--asset-hover); +} +.asset-ui .inventory-group-meta { + font-size: 12px; + color: var(--label-secondary); + font-weight: 400; +} +.asset-ui .inventory-group-total { + margin-left: auto; + font-size: 12px; + font-weight: 400; + color: var(--label-secondary); +} +.asset-ui .inventory-group-total b { + font-size: 15px; + font-weight: 600; + color: var(--label); +} +.asset-ui .inventory-table .inventory-city-cell { + vertical-align: top; + text-align: left; + padding: 16px 20px 16px 46px; + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} +.asset-ui .inventory-city-cell strong { + font-size: 14px; + font-weight: 600; +} +.asset-ui .inventory-city-cell span { + display: block; + margin-top: 4px; + font-size: 12px; + font-weight: 400; + color: var(--label-secondary); +} +.asset-ui .inventory-model-cell { + text-align: left; +} +.asset-ui .inventory-model-cell strong { + font-weight: 500; +} +.asset-ui .inventory-model-cell span { + margin-left: 10px; + padding: 2px 6px; + background: var(--bg-secondary); + border-radius: 4px; + color: var(--label-secondary); + font-size: 12px; + font-weight: 400; + white-space: nowrap; +} +.asset-ui .inventory-footer { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 16px; + font-size: 12px; + line-height: 1.6; + color: var(--label-secondary); + border-top: 1px solid var(--border); +} +.asset-ui .inventory-empty { + display: grid; + justify-items: center; + gap: 12px; + padding: 48px 16px; + text-align: center; + color: var(--label-secondary); +} +.asset-ui .inventory-empty > svg { + width: 32px; + height: 32px; +} +.asset-ui .inventory-empty strong { + font-size: 16px; + color: var(--label); +} +.asset-ui .inventory-mobile { + background: var(--bg-secondary); + padding: 0 12px 12px; +} +.asset-ui .inventory-mobile-sort { + width: 100%; + justify-content: flex-end; + color: var(--label-secondary); + font-size: 12px; +} +.asset-ui .inventory-mobile-group { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + margin-bottom: 4px; + background: var(--bg-tertiary); +} +.asset-ui .inventory-city { + padding: 12px; + border-top: 1px solid var(--border); +} +.asset-ui .inventory-city-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + margin-bottom: 10px; +} +.asset-ui .inventory-city-title svg { + width: 14px; + height: 14px; + color: var(--label-secondary); +} +.asset-ui .inventory-city-title > span { + margin-left: auto; + font-size: 12px; + color: var(--label-secondary); +} +.asset-ui .inventory-mobile-record { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + margin-top: 8px; +} +.asset-ui .inventory-record-main { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; +} +.asset-ui .inventory-record-main h3 { + font-size: 14px; + font-weight: 600; + overflow-wrap: anywhere; + line-height: 1.5; +} +.asset-ui .inventory-record-main p { + color: var(--label-secondary); + font-size: 12px; + margin-top: 6px; + overflow-wrap: anywhere; +} +.asset-ui .inventory-record-main p > span { + margin-inline: 6px; +} +.asset-ui .inventory-mobile-quantity { + margin-left: auto; + display: grid; + grid-template-columns: auto auto; + align-items: baseline; + justify-content: center; + column-gap: 4px; + flex-shrink: 0; + min-width: 74px; + min-height: 64px; + padding: 6px 8px; + border-radius: 6px; + background: var(--asset-soft); + color: var(--blue); +} +.asset-ui .inventory-mobile-quantity strong { + font-size: 24px; + font-weight: 650; +} +.asset-ui .inventory-mobile-quantity > span { + display: flex; + gap: 3px; + font-size: 12px; +} +.asset-ui .inventory-mobile-quantity svg { + width: 12px; + height: 12px; +} +.asset-ui .inventory-mobile-quantity small { + grid-column: span 2; + font-size: 11px; +} +.asset-ui .inventory-mobile-quantity:hover { + background: var(--asset-hover); +} +.asset-ui .inventory-record-status { + border-top: 1px solid var(--border); + display: flex; + gap: 8px; + padding-inline: 8px; + background: var(--bg-secondary); +} +@media (max-width: 767px) { + .asset-ui .inventory-heading { + padding: 16px; + gap: 12px; + flex-wrap: wrap; + } + .asset-ui .inventory-heading-title { + width: 100%; + padding-left: 12px; + } + .asset-ui .inventory-heading-title h2 { + font-size: 17px; + } + .asset-ui .inventory-heading > div:last-child { + width: 100%; + justify-content: space-between; + } + .asset-ui .inventory-total { + display: flex; + align-items: baseline; + gap: 12px; + text-align: left; + } + .asset-ui .inventory-total strong { + font-size: 26px; + } + .asset-ui .inventory-toolbar { + padding: 12px 16px; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px; + } + .asset-ui .inventory-toolbar [role="tab"] { + padding-inline: 12px; + } + .asset-ui .inventory-toolbar > div:last-child { + gap: 0; + } + .asset-ui .inventory-search { + order: 3; + margin: 0; + width: 100%; + } + .asset-ui .inventory-search input { + height: 44px; + font-size: 16px; + } + .asset-ui .inventory-search > svg { + top: 14px; + } + .asset-ui .inventory-search button { + height: 44px; + width: 44px; + } + .asset-ui .inventory-summary { + padding: 0 16px 12px; + } + .asset-ui .inventory-filters, + .asset-ui .inventory-filter-chips { + padding: 12px 16px; + } + .asset-ui .inventory-group-toggle { + padding: 12px; + } + .asset-ui .inventory-footer { + padding: 12px 16px; + } + .asset-ui .inventory-mobile-quantity small { + font-size: 12px; + } +} +.asset-ui .asset-toolbar [data-slot="field-label"] { + width: auto; + flex: none; +} +/* The global feedback launcher must not cover the modal's vehicle cards. */ +body:has(.asset-detail[data-state="open"]) button[aria-label="反馈"] { + visibility: hidden; +} diff --git a/src/modules/assets/library/export.tsx b/src/modules/assets/library/export.tsx new file mode 100644 index 0000000..4814afb --- /dev/null +++ b/src/modules/assets/library/export.tsx @@ -0,0 +1,54 @@ +import { Download } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { Column } from "./DataGrid"; + +function cellValue(row: T, col: Column): string | number { + const raw = row[col.key]; + if (col.format) return col.format(raw, row); + return (raw as string | number) ?? ""; +} + +/** 按表格列定义导出 xlsx;xlsx 按需加载,避免进入首屏包。 */ +export async function exportToXlsx( + rows: T[], + columns: Column[], + filename: string, + sheetName = "明细", +): Promise { + const XLSX = await import("xlsx"); + const sheet = XLSX.utils.json_to_sheet( + rows.map((row) => + Object.fromEntries(columns.map((col) => [col.label, cellValue(row, col)])), + ), + ); + const book = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(book, sheet, sheetName); + XLSX.writeFile(book, `${filename}.xlsx`); +} + +export function ExportButton({ + rows, + columns, + filename, + sheetName, + label = "导出", +}: { + rows: T[]; + columns: Column[]; + filename: string; + sheetName?: string; + label?: string; +}) { + return ( + + ); +} diff --git a/src/modules/assets/library/inventory-explorer.test.ts b/src/modules/assets/library/inventory-explorer.test.ts new file mode 100644 index 0000000..de60b08 --- /dev/null +++ b/src/modules/assets/library/inventory-explorer.test.ts @@ -0,0 +1,181 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { RegionalInventoryStats } from "../types"; +import { + inventoryBrowseRows, + inventoryBrowseUrl, + inventoryModels, + inventoryPlaces, + inventoryTypeRows, + readInventoryBrowse, +} from "./inventory-explorer"; +import { + INVENTORY_TYPES, + inventoryTotals, + scopedInventorySelection, +} from "./inventory-view"; +import { inventoryKey } from "../../../shared/assets/inventory"; + +const make = ( + changes: Partial = {}, +): RegionalInventoryStats => ({ + region: "华东", + city: "嘉兴市", + brand: "现代", + type: "4.5T冷链", + model: "冷链(公司甲)", + batch: "批次甲", + quantity: 5, + available: 3, + awaiting: 2, + pending: 1, + abnormal: 1, + ...changes, +}); + +test("region, city and type partitions reconcile without counting pending/abnormal twice", () => { + const rows = [ + make(), + make({ city: "杭州市", quantity: 3 }), + make({ region: "华南", city: "广州市", type: "49T", quantity: 7 }), + make({ type: "挂车", quantity: 2 }), + ]; + const total = inventoryTotals(rows).quantity; + assert.equal(total, 17); + assert.equal( + inventoryPlaces(rows, "region", "").reduce( + (n, place) => n + place.total.quantity, + 0, + ), + total, + ); + assert.equal( + INVENTORY_TYPES.reduce( + (n, type) => n + inventoryTotals(inventoryTypeRows(rows, type)).quantity, + 0, + ), + total, + ); + for (const region of inventoryPlaces(rows, "region", "")) { + assert.equal( + inventoryPlaces(region.rows, "city", "").reduce( + (n, city) => n + city.total.quantity, + 0, + ), + region.total.quantity, + ); + } +}); + +test("type selection keeps zero-stock locations visible and never uses other-type totals", () => { + const places = inventoryPlaces( + [ + make(), + make({ region: "华南", city: "广州市", type: "49T", quantity: 7 }), + ], + "region", + "49T", + ); + assert.deepEqual( + places.map((place) => [place.name, place.total.quantity]), + [ + ["华南", 7], + ["华东", 0], + ], + ); + assert.equal(places[1].rows.length, 0); +}); + +test("city drill-down intersects geography, brand, query and type before opening exact detail keys", () => { + const wanted = make(); + const rows = [ + wanted, + make({ region: "华南" }), + make({ city: "杭州市" }), + make({ brand: "另一品牌" }), + make({ model: "普货", type: "4.5T普货" }), + ]; + const state = { + ...readInventoryBrowse(""), + region: "华东", + city: "嘉兴市", + brand: "现代", + query: "冷链", + type: "4.5T冷链", + }; + const filtered = inventoryTypeRows( + inventoryBrowseRows(rows, state), + state.type, + ); + assert.deepEqual(filtered, [wanted]); + const model = inventoryModels(filtered)[0]; + assert.deepEqual( + scopedInventorySelection({ rows: model.rows, label: "测试范围" }) + .inventoryKeys, + [inventoryKey(wanted)], + ); +}); + +test("missing geography and duplicate city/model names retain exact identity", () => { + const rows = [ + make({ region: "", city: "" }), + make({ city: "" }), + make({ region: "华南", city: "" }), + ]; + assert.equal(inventoryPlaces(rows, "city", "").length, 3); + assert.deepEqual( + inventoryBrowseRows(rows, { + ...readInventoryBrowse(""), + region: "", + city: "", + }), + [rows[0]], + ); + const models = inventoryModels([ + make({ type: "挂车" }), + make({ type: "35T" }), + make({ brand: "另一品牌" }), + ]); + assert.equal(models.length, 3); + assert.equal(new Set(models.map((model) => model.key)).size, 3); +}); + +test("deep links round-trip filters, empty geography and model identity without changing the host route", () => { + const state = { + region: "", + city: "", + type: "其他", + model: '["现代","挂车","A/B"]', + query: "A/B & C", + brand: "现代", + }; + const url = new URL( + inventoryBrowseUrl( + "https://example.test/asset?existing=yes#assets/overview", + state, + ), + ); + assert.deepEqual(readInventoryBrowse(url.search), state); + assert.equal(url.hash, "#assets/overview"); + assert.equal(url.pathname, "/asset"); + assert.equal(url.searchParams.get("existing"), "yes"); + const cleared = new URL( + inventoryBrowseUrl(url.href, readInventoryBrowse("")), + ); + assert.equal(cleared.search, "?existing=yes"); +}); + +test("orphan child links and invalid type values cannot select an unrelated scope", () => { + assert.deepEqual( + readInventoryBrowse("?stockCity=嘉兴市&stockModel=x&stockType=invalid"), + readInventoryBrowse(""), + ); + const state = readInventoryBrowse("?stockRegion=华东&stockModel=x"); + assert.equal(state.model, null); + assert.equal(inventoryModels([]).length, 0); + assert.equal( + inventoryBrowseRows([make()], { ...state, query: "no matching model" }) + .length, + 0, + ); +}); diff --git a/src/modules/assets/library/inventory-explorer.ts b/src/modules/assets/library/inventory-explorer.ts new file mode 100644 index 0000000..1fafcb5 --- /dev/null +++ b/src/modules/assets/library/inventory-explorer.ts @@ -0,0 +1,150 @@ +import type { RegionalInventoryStats } from "../types"; +import { + INVENTORY_TYPES, + inventoryColumn, + inventoryTotals, +} from "./inventory-view"; + +export interface InventoryBrowseState { + region: string | null; + city: string | null; + type: string; + model: string | null; + query: string; + brand: string; +} + +export function readInventoryBrowse(search: string): InventoryBrowseState { + const params = new URLSearchParams(search); + const region = params.get("stockRegion"); + const city = region === null ? null : params.get("stockCity"); + const type = params.get("stockType") || ""; + return { + region, + city, + type: INVENTORY_TYPES.includes(type as (typeof INVENTORY_TYPES)[number]) + ? type + : "", + model: city === null ? null : params.get("stockModel"), + query: params.get("stockQuery") || "", + brand: params.get("stockBrand") || "", + }; +} + +/** Keep the application's route and unrelated URL parameters intact. */ +export function inventoryBrowseUrl(href: string, state: InventoryBrowseState) { + const url = new URL(href); + for (const [key, value] of Object.entries({ + stockRegion: state.region, + stockCity: state.region === null ? null : state.city, + stockType: state.type || null, + stockModel: + state.region === null || state.city === null ? null : state.model, + stockQuery: state.query || null, + stockBrand: state.brand || null, + })) { + if (value === null) url.searchParams.delete(key); + else url.searchParams.set(key, value); + } + return url.href; +} + +export function inventoryBrowseRows( + rows: RegionalInventoryStats[], + state: InventoryBrowseState, +) { + const query = state.query.trim().toLocaleLowerCase(); + return rows.filter( + (row) => + (state.region === null || row.region === state.region) && + (state.city === null || row.city === state.city) && + (!state.brand || row.brand === state.brand) && + (!query || + [ + row.region, + row.city, + row.brand, + row.type, + row.model, + inventoryColumn(row.type), + ] + .join(" ") + .toLocaleLowerCase() + .includes(query)), + ); +} + +export function inventoryTypeRows( + rows: RegionalInventoryStats[], + type: string, +) { + return type ? rows.filter((row) => inventoryColumn(row.type) === type) : rows; +} + +/** Build places before applying the type filter so no-stock places remain explicit. */ +export function inventoryPlaces( + rows: RegionalInventoryStats[], + level: "region" | "city", + type: string, +) { + const groups = new Map(); + for (const row of rows) { + const key = JSON.stringify( + level === "region" ? [row.region] : [row.region, row.city], + ); + const group = groups.get(key) || []; + group.push(row); + groups.set(key, group); + } + return [...groups] + .map(([key, records]) => { + const selected = inventoryTypeRows(records, type); + return { + key, + value: records[0][level], + name: + records[0][level] || + (level === "region" ? "未归属地区" : "未归属城市"), + rows: selected, + total: inventoryTotals(selected), + cities: new Set( + selected.filter((row) => row.quantity > 0).map((row) => row.city), + ).size, + types: INVENTORY_TYPES.map((name) => ({ + name, + quantity: inventoryTotals(inventoryTypeRows(records, name)).quantity, + })).filter((item) => item.quantity > 0), + }; + }) + .sort( + (a, b) => + b.total.quantity - a.total.quantity || + a.name.localeCompare(b.name, "zh-CN"), + ); +} + +/** Keep the exact type/model/brand identity even when displayed in the Other column. */ +export function inventoryModels(rows: RegionalInventoryStats[]) { + const groups = new Map(); + for (const row of rows) { + const key = JSON.stringify([row.brand, row.type, row.model]); + const group = groups.get(key) || []; + group.push(row); + groups.set(key, group); + } + return [...groups] + .map(([key, records]) => ({ + key, + rows: records, + brand: records[0].brand, + model: records[0].model, + type: records[0].type, + total: inventoryTotals(records), + })) + .filter((group) => group.total.quantity > 0) + .sort( + (a, b) => + b.total.quantity - a.total.quantity || + a.model.localeCompare(b.model, "zh-CN"), + ); +} diff --git a/src/modules/assets/library/inventory-matrix.css b/src/modules/assets/library/inventory-matrix.css new file mode 100644 index 0000000..4f04fb7 --- /dev/null +++ b/src/modules/assets/library/inventory-matrix.css @@ -0,0 +1,1173 @@ +/* Inventory surfaces use the app's vendored Apple tokens; data surfaces stay opaque. */ +.asset-ui { + --inventory-awaiting: #74829b; + --inventory-warning: #a85512; + font-family: + -apple-system, BlinkMacSystemFont, "Geist Variable", "PingFang SC", + "Microsoft YaHei", system-ui, sans-serif; + font-optical-sizing: auto; +} +[data-theme="dark"] .asset-ui { + --inventory-awaiting: #9aa9c0; + --inventory-warning: #fdba74; +} +.inventory-matrix { + overflow: hidden; + min-width: 0; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: var(--shadow-1); +} +.matrix-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; +} +.matrix-heading h2 { + font-size: 1rem; + font-weight: 600; + letter-spacing: var(--tracking-snug); + line-height: 1.25; +} +.matrix-heading p, +.matrix-header-total > span, +.analysis-eyebrow { + font-size: 0.8125rem; + color: var(--label-secondary); + margin-top: 5px; +} +.matrix-heading-actions { + display: flex; + align-items: center; + gap: 16px; +} +.matrix-header-total { + text-align: right; +} +.matrix-header-total .matrix-value { + font-size: 1.5rem; + letter-spacing: var(--tracking-tight); + font-weight: 650; +} +.matrix-header-total .matrix-number { + align-items: center; + flex-direction: row; + gap: 12px; +} +.matrix-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 16px 10px; +} +.matrix-search { + position: relative; + display: flex; + align-items: center; + width: 360px; + max-width: 100%; +} +.matrix-search > svg { + position: absolute; + left: 12px; + width: 16px; + color: var(--label-secondary); +} +.matrix-search input { + padding-left: 36px; + padding-right: 40px; + min-height: 44px; + background: var(--bg-secondary); + border-color: transparent; +} +.matrix-search > button { + position: absolute; + right: 0; + display: grid; + place-items: center; + min-width: 44px; + min-height: 44px; +} +.matrix-search > button svg { + width: 16px; +} +.matrix-tools { + display: flex; + align-items: center; + gap: 8px; +} +.matrix-filters { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)) auto; + align-items: end; + gap: 12px; + background: var(--bg-secondary); + padding: 12px 16px; + border-top: 1px solid var(--border); +} +.matrix-filter-summary { + display: flex; + gap: 16px; + align-items: center; + flex-wrap: wrap; + padding: 8px 24px; + background: var(--asset-soft); + font-size: 0.8125rem; +} +.matrix-filter-summary button { + color: var(--blue); + min-height: 44px; +} +.matrix-scroll { + overflow: auto; + max-height: 640px; + border-block: 1px solid var(--border); +} +.matrix-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 0.875rem; +} +.matrix-table th, +.matrix-table td { + min-width: 104px; + padding: 4px 12px; + text-align: right; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} +.matrix-table thead { + position: sticky; + top: 0; + z-index: 3; + background: var(--bg-secondary); +} +.matrix-table thead th { + font-size: 0.8125rem; + font-weight: 500; + color: var(--label-secondary); + background: var(--bg-secondary); + height: 36px; +} +.matrix-table .matrix-geography { + position: sticky; + left: 0; + z-index: 2; + min-width: 176px; + width: 176px; + max-width: 176px; + text-align: left; + background: var(--bg-tertiary); + border-right: 1px solid var(--border); +} +.matrix-table thead .matrix-geography { + z-index: 4; + background: var(--bg-secondary); +} +.matrix-table .matrix-total { + position: sticky; + left: 176px; + z-index: 1; + background: var(--asset-soft); + border-right: 1px solid var(--border); +} +.matrix-table thead .matrix-total { + z-index: 4; +} +.matrix-table .matrix-selected-column, +.matrix-table .matrix-region-row .matrix-selected-column { + background: var(--asset-soft); +} +.matrix-type-overview { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + padding: 0 16px 10px; +} +.matrix-type-overview > button { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 4px 8px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + text-align: left; + background: var(--bg-tertiary); +} +.matrix-type-overview > button[aria-pressed="true"] { + border-color: var(--blue); + background: var(--asset-soft); +} +.matrix-type-overview > button > span { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 0.8125rem; + color: var(--label-secondary); +} +.matrix-type-overview > button > span:first-child { + color: var(--label); + font-weight: 600; +} +.matrix-type-overview svg { + width: 14px; + height: 14px; +} +.matrix-type-overview > button > span:last-child { + grid-column: 1; + grid-row: 2; +} +@media (min-width: 768px) { + .matrix-search input, + .matrix-search > button { + min-height: 36px; + height: 36px; + } +} + +.matrix-type-overview strong { + font-size: 1.5rem; + line-height: 1.25; + font-weight: 650; + grid-column: 2; + grid-row: 1 / 3; + white-space: nowrap; +} +.matrix-type-overview small { + font-size: 0.75rem; + margin-left: 6px; + color: var(--label-secondary); + font-weight: 400; +} +@media (min-width: 768px) and (max-width: 1100px) { + .matrix-type-overview > button { + grid-template-columns: minmax(0, 1fr); + } + .matrix-type-overview strong, + .matrix-type-overview > button > span:last-child { + grid-column: auto; + grid-row: auto; + } +} +.matrix-reading-guide { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px; + padding: 0 16px 8px; + font-size: 0.75rem; + color: var(--label-secondary); +} +.matrix-table .matrix-region-row th, +.matrix-table .matrix-region-row td { + background: var(--bg-secondary); + font-weight: 600; +} +.matrix-table .matrix-region-row .matrix-total { + background: var(--asset-soft); +} +.matrix-table .matrix-city-row .matrix-geography span { + padding-left: 28px; + color: var(--label-secondary); + font-weight: 500; +} +.matrix-table tr:hover td, +.matrix-table .matrix-city-row:hover th { + background: var(--asset-hover); +} +.matrix-table .matrix-brand-cell { + border-left: 1px solid var(--border); +} +.matrix-table tfoot { + position: sticky; + bottom: 0; + z-index: 2; +} +.matrix-table tfoot th, +.matrix-table tfoot td { + background: var(--bg-secondary); + border-bottom: 0; + border-top: 1px solid var(--border); + font-weight: 600; +} +.matrix-expand { + display: flex; + align-items: center; + gap: 8px; + min-height: 32px; + text-align: left; + width: 100%; + font-weight: 600; +} +.matrix-expand > svg { + width: 16px; + height: 16px; + color: var(--label-secondary); + flex-shrink: 0; +} +.matrix-expand small { + font-size: 0.75rem; + font-weight: 400; + color: var(--label-secondary); + margin-left: 8px; +} +.matrix-column-toggle { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-height: 28px; + color: var(--label); + font-weight: 600; +} +.matrix-column-toggle svg { + width: 14px; + height: 14px; + color: var(--label-secondary); +} +.matrix-column-toggle[aria-expanded="true"] { + color: var(--blue); +} +.matrix-number { + display: flex; + flex-direction: column; + align-items: flex-end; +} +.matrix-value { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + min-height: 28px; + min-width: 44px; + color: var(--blue); + font-size: 0.9375rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.matrix-value svg { + width: 12px; + height: 12px; + opacity: 0.55; +} +.matrix-value:hover { + text-decoration: underline; + text-underline-offset: 4px; +} +.matrix-secondary { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + justify-content: flex-end; + font-size: 0.75rem; + color: var(--label-secondary); + font-weight: 400; +} +.matrix-secondary button { + min-height: 24px; + white-space: nowrap; +} +.matrix-warning { + color: var(--inventory-warning); +} +.matrix-zero { + color: var(--label-secondary); + font-size: 0.875rem; + font-weight: 400; +} +.matrix-footer { + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + font-size: 0.75rem; + color: var(--label-secondary); + padding: 10px 16px; +} +.matrix-empty { + display: grid; + justify-items: center; + gap: 12px; + padding: 48px 24px; + color: var(--label-secondary); + text-align: center; +} +.matrix-empty strong { + color: var(--label); + font-size: 1rem; +} +.matrix-mobile-types { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + padding: 0 16px 16px; +} +.matrix-mobile-types button { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + padding: 8px 10px; + border: 1px solid transparent; + background: var(--bg-secondary); + border-radius: 8px; + font-size: 0.75rem; + min-height: 44px; +} +.matrix-mobile-types button span { + font-size: 0.75rem; + color: var(--label-secondary); +} +.matrix-mobile-types button[aria-pressed="true"] { + background: var(--asset-soft); + color: var(--blue); + border-color: var(--blue); +} +.asset-ui.inventory-analysis { + max-width: 960px; +} +.analysis-overview { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: 20px; +} +.analysis-composition, +.analysis-workflow { + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + background: var(--bg-tertiary); +} +.analysis-composition { + display: flex; + align-items: center; + gap: 16px; +} +.analysis-donut { + position: relative; + width: 180px; + flex-shrink: 0; +} +.analysis-donut svg { + width: 100%; + display: block; + overflow: visible; +} +.analysis-donut circle[role="button"] { + cursor: pointer; + pointer-events: stroke; +} +.analysis-donut circle[role="button"]:hover { + opacity: 0.8; +} +.analysis-donut-center { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + pointer-events: none; +} +.analysis-donut-center span, +.analysis-donut-center small { + font-size: 0.75rem; + color: var(--label-secondary); +} +.analysis-donut-center strong { + font-size: 2.125rem; + line-height: 1.2; + letter-spacing: var(--tracking-tight); + font-weight: 650; + margin: 4px 0; +} +.analysis-legend { + flex: 1; + min-width: 0; +} +.analysis-legend h3, +.analysis-workflow h3, +.analysis-variants h3 { + font-size: 0.9375rem; + font-weight: 600; +} +.analysis-legend button { + display: flex; + align-items: center; + gap: 8px; + min-height: 44px; + width: 100%; + font-size: 0.8125rem; + white-space: nowrap; +} +.analysis-legend button strong { + margin-left: auto; + font-weight: 600; +} +.analysis-legend button svg { + width: 12px; + flex-shrink: 0; + color: var(--blue); +} +.analysis-legend button > span:nth-last-child(2) { + color: var(--label-secondary); + font-size: 0.75rem; +} +.analysis-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.analysis-legend p { + font-size: 0.75rem; + color: var(--label-secondary); + margin-top: 8px; + line-height: 1.6; +} +.analysis-workflow-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.analysis-workflow-heading > span { + font-size: 0.75rem; + color: var(--label-secondary); +} +.analysis-workflow-total { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 52px; + width: 100%; +} +.analysis-workflow-total > span, +.analysis-workflow-total > strong { + display: flex; + align-items: center; + gap: 8px; +} +.analysis-workflow-total svg { + width: 16px; + height: 16px; + color: var(--label-secondary); +} +.analysis-workflow-total strong { + font-size: 1.25rem; + color: var(--blue); + font-weight: 600; +} +.analysis-workflow-total small { + font-size: 0.75rem; + color: var(--label-secondary); + font-weight: 400; +} +.analysis-workflow-total:disabled strong { + color: var(--label-secondary); +} +.analysis-abnormal-types { + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); + font-size: 0.75rem; + color: var(--label-secondary); +} +.analysis-abnormal-types button { + display: flex; + align-items: center; + gap: 12px; + min-height: 36px; + color: var(--inventory-warning); +} +.analysis-variants { + display: grid; + gap: 16px; +} +.analysis-variants-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.analysis-variants-heading p { + font-size: 0.75rem; + color: var(--label-secondary); + margin-top: 4px; +} +.analysis-variant-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.analysis-variant { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + text-align: left; + padding: 16px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-tertiary); + min-width: 0; +} +.analysis-variant > div { + min-width: 0; +} +.analysis-variant:hover { + border-color: var(--blue); + background: var(--asset-soft); +} +.analysis-variant > div > strong { + font-size: 0.9375rem; + font-weight: 600; + overflow-wrap: anywhere; +} +.analysis-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} +.analysis-tags span { + font-size: 0.75rem; + line-height: 1.5; + color: var(--label-secondary); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 5px; + padding: 2px 6px; + overflow-wrap: anywhere; +} +.analysis-variant-count { + display: flex; + align-items: center; + gap: 6px; + color: var(--blue); + font-size: 1.25rem; + font-weight: 600; + white-space: nowrap; +} +.analysis-variant-count small { + font-size: 0.75rem; + font-weight: 400; + color: var(--label-secondary); +} +.analysis-variant-count svg { + width: 14px; + height: 14px; +} +.inventory-matrix button:active, +.inventory-analysis button:active { + opacity: 0.65; +} +@media (max-width: 1100px) { + .analysis-overview { + grid-template-columns: 1fr; + } + .analysis-composition { + justify-content: center; + } + .analysis-legend { + flex: 0 1 300px; + } +} +@media (max-width: 767px) { + .matrix-heading { + padding: 20px 16px; + gap: 12px; + align-items: flex-start; + } + .matrix-heading h2 { + font-size: 1.125rem; + } + .matrix-heading p { + font-size: 0.75rem; + max-width: 130px; + } + .matrix-heading-actions { + gap: 10px; + align-items: flex-end; + } + .matrix-header-total .matrix-value { + font-size: 1.5rem; + } + .matrix-heading-actions > [data-slot="button"] { + font-size: 0.75rem; + padding: 8px; + } + .matrix-toolbar { + padding: 0 16px 14px; + gap: 8px; + flex-wrap: wrap; + } + .matrix-search { + width: 100%; + } + .matrix-tools { + width: 100%; + justify-content: space-between; + } + .matrix-filters { + padding: 16px; + gap: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .matrix-secondary button { + min-height: 44px; + } + .matrix-secondary { + gap: 0 8px; + } + .matrix-footer { + padding: 12px 16px; + gap: 6px; + } + .matrix-number { + flex-shrink: 0; + } + .analysis-overview { + gap: 12px; + } + .analysis-composition { + padding: 12px; + gap: 8px; + } + .analysis-donut { + width: 140px; + } + .analysis-legend { + flex: 1; + } + .analysis-legend button { + gap: 5px; + font-size: 0.75rem; + } + .analysis-legend button svg { + display: none; + } + .analysis-workflow { + padding: 16px; + } + .analysis-workflow-heading > span { + font-size: 0.75rem; + } + .analysis-abnormal-types button { + min-height: 44px; + } + .analysis-variant-list { + grid-template-columns: 1fr; + } + .analysis-variant { + padding: 14px; + } + .analysis-variants-heading { + align-items: center; + } + .analysis-variants-heading > [data-slot="button"] { + font-size: 0.75rem; + padding: 8px; + } + .analysis-donut-center strong { + font-size: 1.75rem; + } + .asset-ui.inventory-analysis { + max-width: 100%; + } +} +@media (prefers-contrast: more) { + .inventory-matrix, + .analysis-variant, + .finder-place { + border-color: var(--label-secondary); + } +} +.asset-ui [data-slot="tabs"], +.asset-ui [data-slot="tabs-content"] { + min-width: 0; + max-width: 100%; +} +.inventory-explorer { + --finder-space: 12px; +} +.finder-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px var(--finder-space) 10px; +} +.finder-header h2 { + font-size: 1.125rem; + font-weight: 650; +} +.finder-header p { + color: var(--label-secondary); + font-size: 0.8125rem; + margin-top: 4px; +} +.finder-header > svg { + width: 24px; + height: 24px; + color: var(--blue); + flex-shrink: 0; +} +.finder-steps { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0 var(--finder-space); + padding: 8px 10px; + background: var(--bg-secondary); + border-radius: 10px; + list-style: none; + gap: 8px; +} +.finder-steps li { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + color: var(--label-secondary); +} +.finder-steps li > span { + display: grid; + place-items: center; + border: 1px solid var(--border); + border-radius: 50%; + width: 20px; + height: 20px; +} +.finder-steps li[aria-current] { + color: var(--blue); + font-weight: 650; +} +.finder-steps li[aria-current] > span { + background: var(--blue); + color: var(--bg-tertiary); + border-color: transparent; +} +.finder-steps svg { + width: 12px; + height: 12px; + color: var(--label-secondary); +} +.finder-breadcrumb { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0 8px; + padding: 0 var(--finder-space); + font-size: 0.8125rem; + min-height: 44px; +} +.finder-breadcrumb button { + min-width: 44px; + min-height: 44px; + color: var(--blue); +} +.finder-breadcrumb span { + color: var(--label-secondary); + overflow-wrap: anywhere; +} +.finder-breadcrumb svg { + width: 12px; + height: 12px; + color: var(--label-secondary); +} +.finder-scope { + display: flex; + align-items: center; + gap: 8px; + padding: 4px var(--finder-space) 12px; +} +.finder-back { + display: grid; + place-items: center; + width: 44px; + height: 44px; + flex-shrink: 0; + border: 1px solid var(--border); + border-radius: 10px; + margin-right: 4px; +} +.finder-back svg { + width: 18px; + height: 18px; +} +.finder-scope > div:not(.finder-scope-total) { + min-width: 0; +} +.finder-scope h3 { + font-size: 1.25rem; + font-weight: 650; + overflow-wrap: anywhere; +} +.finder-scope p { + font-size: 0.75rem; + color: var(--label-secondary); + margin-top: 5px; +} +.finder-scope-total { + display: grid; + text-align: right; + margin-left: auto; + flex-shrink: 0; +} +.finder-scope-total strong { + font-size: 1.875rem; + font-weight: 650; + line-height: 1.2; + letter-spacing: -0.03em; +} +.finder-scope-total > span { + font-size: 0.75rem; + color: var(--label-secondary); + margin-top: 5px; +} +.finder-type-heading, +.finder-results-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 var(--finder-space) 10px; +} +.finder-type-heading strong, +.finder-results-heading strong { + font-size: 0.875rem; + font-weight: 600; +} +.finder-type-heading > span, +.finder-results-heading > span { + font-size: 0.75rem; + color: var(--label-secondary); +} +.finder-types { + gap: 8px; + padding: 0 var(--finder-space) 12px; +} +.finder-types button { + padding: 8px 10px; + border-color: var(--border); + font-size: 0.8125rem; + min-height: 56px; + white-space: normal; +} +.finder-types button[aria-pressed="true"] { + box-shadow: inset 0 0 0 1px var(--blue); +} +.finder-types button[aria-pressed="true"] span { + color: var(--blue); +} +.finder-search-row { + display: grid; + gap: 12px; + padding: 0 var(--finder-space) 12px; +} +.finder-search-row .matrix-search { + width: 100%; +} +.finder-search-row input { + font-size: 16px; +} +.finder-search-row [data-slot="field"] { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 12px; +} +.finder-search-row [data-slot="field-label"] { + font-size: 0.8125rem; + color: var(--label-secondary); +} +.finder-search-row [data-slot="select-trigger"] { + min-height: 44px; + width: 100%; +} +.finder-results-heading { + min-height: 48px; + padding-bottom: 4px; +} +.finder-results-heading button { + min-height: 44px; + font-size: 0.8125rem; + color: var(--blue); +} +.finder-list { + display: grid; + gap: 10px; + padding: 0 var(--finder-space) 16px; +} +.finder-place { + display: grid; + width: 100%; + gap: 8px; + min-width: 0; + padding: 12px; + text-align: left; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-tertiary); +} +.finder-place:disabled { + background: var(--bg-secondary); +} +.finder-place-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.finder-place-top > strong { + font-size: 1rem; + font-weight: 600; + overflow-wrap: anywhere; + min-width: 0; +} +.finder-place-count { + display: flex; + align-items: center; + gap: 6px; + font-size: 1.375rem; + font-weight: 650; + color: var(--blue); + white-space: nowrap; +} +.finder-place:disabled .finder-place-count { + color: var(--label-secondary); +} +.finder-place-count small { + font-size: 0.75rem; + font-weight: 400; + color: var(--label-secondary); +} +.finder-place-count svg { + width: 16px; + height: 16px; + margin-left: 4px; + color: var(--label-secondary); +} +.finder-place-status { + display: flex; + gap: 6px 12px; + flex-wrap: wrap; + color: var(--label-secondary); + font-size: 0.75rem; +} +.finder-type-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.finder-type-tags > span { + padding: 4px 8px; + background: var(--bg-secondary); + border-radius: 5px; + color: var(--label-secondary); + font-size: 0.75rem; +} +.finder-type-tags b { + font-weight: 600; + margin-left: 8px; + color: var(--label); +} +.finder-model-meta { + font-size: 0.8125rem; + color: var(--label-secondary); + overflow-wrap: anywhere; +} +.finder-model-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + border-top: 1px solid var(--border); + padding-top: 12px; + font-size: 0.75rem; +} +.finder-model-footer > span:first-child { + color: var(--inventory-warning); +} +.finder-model-footer > span:last-child { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; + color: var(--blue); + white-space: nowrap; +} +.finder-model-footer svg { + width: 14px; + height: 14px; +} +.finder-empty { + display: grid; + justify-items: center; + gap: 12px; + padding: 24px var(--finder-space); + text-align: center; +} +.finder-empty > svg { + width: 28px; + height: 28px; + color: var(--label-secondary); +} +.finder-empty strong { + font-size: 0.9375rem; +} +.finder-empty p { + font-size: 0.8125rem; + color: var(--label-secondary); + max-width: 32ch; +} +.inventory-explorer [data-slot="button"] { + min-height: 44px; +} +.inventory-explorer .matrix-footer { + background: var(--bg-secondary); + font-size: 0.75rem; + line-height: 1.6; +} +@media (hover: hover) { + .finder-place:not(:disabled):hover, + .matrix-type-overview > button:hover { + background: var(--asset-soft); + border-color: var(--blue); + } +} +@media (prefers-reduced-motion: no-preference) { + .inventory-explorer button, + .matrix-type-overview > button { + transition: + background-color 140ms ease, + border-color 140ms ease, + opacity 100ms ease; + } +} +@media (max-width: 767px) { + .matrix-mobile-types button { + flex-direction: column; + align-items: flex-start; + white-space: nowrap; + gap: 3px; + } + .matrix-mobile-types button span { + align-self: flex-end; + font-size: 0.9375rem; + font-weight: 600; + } + .matrix-heading p { + max-width: none; + } +} diff --git a/src/modules/assets/library/inventory-view.test.ts b/src/modules/assets/library/inventory-view.test.ts new file mode 100644 index 0000000..2716ccb --- /dev/null +++ b/src/modules/assets/library/inventory-view.test.ts @@ -0,0 +1,203 @@ +import { + inventoryKey, + inventoryOperation, + inventoryColor, +} from "../../../shared/assets/inventory"; +import { + INVENTORY_TYPES, + inventoryColumn, + inventoryTotals, + inventoryModel, + scopedInventorySelection, + matchesInventorySelection, +} from "./inventory-view"; +import type { VehicleListItem } from "../types"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { groupInventory, inventorySelection } from "./inventory-view"; +import { buildVehicleModalRequest } from "../model"; +import type { RegionalInventoryStats } from "../types"; +const make = ( + change: Partial = {}, +): RegionalInventoryStats => ({ + region: "华东", + city: "嘉兴市", + brand: "明威", + type: "挂车", + model: "挂车", + batch: "挂车", + quantity: 2, + abnormal: 1, + pending: 1, + ...change, +}); +test("inventory grouping preserves totals and does not double-count abnormal/pending subsets", () => { + const rows = [ + make(), + make({ brand: "万风", quantity: 1 }), + make({ region: "华南", city: "广州市", quantity: 4 }), + ]; + const original = JSON.stringify(rows); + const groups = groupInventory(rows, "region"); + assert.deepEqual( + groups.map((group) => [group.name, group.total]), + [ + ["华南", 4], + ["华东", 3], + ], + ); + assert.equal(groups[1].cities[0].rows.length, 2); + assert.equal( + groups.reduce((sum, group) => sum + group.total, 0), + 7, + ); + assert.equal(JSON.stringify(rows), original); +}); +test("type grouping keeps same-name cities in different regions separate", () => { + const grouped = groupInventory( + [make(), make({ region: "华南", brand: "万风" })], + "type", + ); + assert.equal(grouped.length, 1); + assert.equal(grouped[0].cities.length, 2); + assert.notEqual(grouped[0].cities[0].key, grouped[0].cities[1].key); +}); +test("sorting reverses quantities within each city and preserves the source array", () => { + const rows = [make(), make({ quantity: 5, model: "另一型号" })]; + assert.deepEqual( + groupInventory(rows, "region")[0].cities[0].rows.map((row) => row.quantity), + [5, 2], + ); + assert.deepEqual( + groupInventory(rows, "region", true)[0].cities[0].rows.map( + (row) => row.quantity, + ), + [2, 5], + ); + assert.equal(rows[0].quantity, 2); + assert.deepEqual(groupInventory([], "region"), []); +}); +test("drill-down keeps brand, model, city, type, category and company; does not mistake model for contract batch", () => { + const request = buildVehicleModalRequest( + inventorySelection(make(), "Pending"), + "公司甲", + ); + assert.equal(request.kind, "vehicles"); + if (request.kind !== "vehicles") return; + assert.deepEqual(request.params, { + brand: "明威", + model: "挂车", + location: "嘉兴市", + source: "asset", + category: "Pending", + vehicleType: "挂车", + subject: "公司甲", + }); +}); + +test("matrix merges trailers into other while preserving all five column totals", () => { + const rows = [ + make(), + make({ type: "35T", quantity: 3 }), + make({ type: "4.5T普货", quantity: 4 }), + make({ type: "4.5T冷链", quantity: 5 }), + make({ type: "18T", quantity: 6 }), + make({ type: "49T", quantity: 7 }), + ]; + assert.deepEqual( + INVENTORY_TYPES.map( + (type) => + inventoryTotals( + rows.filter((row) => inventoryColumn(row.type) === type), + ).quantity, + ), + [4, 5, 6, 7, 5], + ); + assert.equal(inventoryTotals(rows).quantity, 27); +}); +test("composition uses independent operation status; abnormal and pending remain subsets", () => { + const total = inventoryTotals([ + make({ + quantity: 5, + available: 3, + awaiting: 2, + abnormal: 2, + pending: 1, + abnormalTypes: { 维修: 2 }, + }), + make({ + quantity: 2, + available: 0, + awaiting: 2, + abnormal: 1, + pending: 0, + abnormalTypes: { 保养: 1 }, + }), + ]); + assert.equal(total.available + total.awaiting, total.quantity); + assert.equal(total.quantity, 7); + assert.equal(total.abnormal, 3); + assert.deepEqual(total.abnormalTypes, { 维修: 2, 保养: 1 }); + assert.equal(inventoryOperation("在库"), "unclassified"); +}); +test("model tags separate operator and color while retaining a model-first name", () => { + assert.deepEqual(inventoryModel("现代4.5T普货(恒运)", "现代"), { + name: "4.5T普货", + operator: "恒运", + }); + assert.deepEqual(inventoryModel("现代18T双飞翼(羚牛)", "帕力安牌"), { + name: "18T双飞翼", + operator: "羚牛", + }); + assert.deepEqual(inventoryModel("49T宇通", "宇通"), { + name: "49T", + operator: "", + }); + assert.deepEqual(inventoryModel("飞驰18T(红车)", "飞驰"), { + name: "18T", + operator: "", + }); + assert.deepEqual(inventoryModel("49T飞驰(浙氢-红)", "飞驰"), { + name: "49T", + operator: "浙氢", + }); +}); +test("drill-down intersects exact geographic/model/brand keys with color and operational status", () => { + const row = make(); + const selection = scopedInventorySelection( + { rows: [row], label: "华东 / 嘉兴市 / 其他" }, + { inventoryColor: "白", inventoryOperation: "available" }, + ); + const vehicle = { + inventoryKey: inventoryKey(row), + color: "白", + operationStatus: "3", + } as VehicleListItem; + assert.equal(matchesInventorySelection(vehicle, selection), true); + assert.equal( + matchesInventorySelection({ ...vehicle, color: "红" }, selection), + false, + ); + assert.equal( + matchesInventorySelection({ ...vehicle, operationStatus: "4" }, selection), + false, + ); + assert.equal( + matchesInventorySelection( + { ...vehicle, inventoryKey: inventoryKey({ ...row, region: "华南" }) }, + selection, + ), + false, + ); + assert.equal( + matchesInventorySelection(vehicle, { ...selection, inventoryKeys: [] }), + false, + ); +}); + +test("known color aliases combine without merging distinct multicolor values", () => { + assert.equal(inventoryColor("红"), inventoryColor("红色")); + assert.equal(inventoryColor("白车"), "白色"); + assert.equal(inventoryColor("蓝白绿"), "蓝白绿"); + assert.equal(inventoryColor(null), "未标注颜色"); +}); diff --git a/src/modules/assets/library/inventory-view.ts b/src/modules/assets/library/inventory-view.ts new file mode 100644 index 0000000..3d2541c --- /dev/null +++ b/src/modules/assets/library/inventory-view.ts @@ -0,0 +1,164 @@ +import { + emptyInventoryCounts, + inventoryKey, + inventoryOperation, + inventoryColor, +} from "../../../shared/assets/inventory"; +import type { VehicleListItem } from "../types"; +import type { RegionalInventoryStats } from "../types"; +import type { VehicleModalCategory, VehicleModalSelection } from "../model"; + +export type InventoryGrouping = "region" | "type"; +export function groupInventory( + rows: RegionalInventoryStats[], + grouping: InventoryGrouping, + ascending = false, +) { + const groups = new Map(); + for (const row of rows) { + const key = row[grouping] || "未归属"; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(row); + } + return [...groups] + .map(([name, items]) => { + const cities = new Map(); + for (const row of items) { + // A city may be missing or share a name with a city in another region. + const key = JSON.stringify([row.region, row.city]); + if (!cities.has(key)) cities.set(key, []); + cities.get(key)!.push(row); + } + return { + name, + total: items.reduce((sum, row) => sum + row.quantity, 0), + cities: [...cities] + .map(([key, records]) => ({ + key, + name: records[0].city || "未归属城市", + region: records[0].region || "未归属", + total: records.reduce((sum, row) => sum + row.quantity, 0), + rows: [...records].sort( + (a, b) => + (ascending ? 1 : -1) * (a.quantity - b.quantity) || + a.model.localeCompare(b.model, "zh-CN"), + ), + })) + .sort( + (a, b) => + b.total - a.total || a.name.localeCompare(b.name, "zh-CN"), + ), + }; + }) + .sort((a, b) => b.total - a.total || a.name.localeCompare(b.name, "zh-CN")); +} +export function inventorySelection( + row: RegionalInventoryStats, + category: VehicleModalCategory = "Inventory", +): VehicleModalSelection { + return { + batch: "All", + source: "asset", + category, + brand: row.brand, + model: row.model, + location: row.city, + type: row.type, + }; +} + +export const INVENTORY_TYPES = [ + "4.5T普货", + "4.5T冷链", + "18T", + "49T", + "其他", +] as const; +export function inventoryColumn(type: string) { + return INVENTORY_TYPES.includes(type as (typeof INVENTORY_TYPES)[number]) + ? type + : "其他"; +} +export function inventoryTotals(rows: RegionalInventoryStats[]) { + const result = emptyInventoryCounts(); + for (const row of rows) { + for (const key of [ + "quantity", + "available", + "awaiting", + "abnormal", + "pending", + ] as const) + result[key] += row[key] || 0; + result.unclassified += + row.unclassified ?? + Math.max(0, row.quantity - (row.available || 0) - (row.awaiting || 0)); + for (const [type, count] of Object.entries(row.abnormalTypes || {})) + result.abnormalTypes[type] = (result.abnormalTypes[type] || 0) + count; + } + return result; +} +/** Display labels never replace the original model used for exact drill-down. */ +export function inventoryModel(model: string, brand: string) { + const parentheses = [...model.matchAll(/[((]([^))]+)[))]/g)].map( + (match) => match[1], + ); + const colorOnly = /^(?:红|白|蓝|绿|灰|黑|黄|银|蓝白绿)(?:色|车)?$/; + const operator = parentheses + .filter((value) => !colorOnly.test(value)) + .map((value) => value.replace(/[--](?:红|白|蓝白绿)(?:色|车)?$/, "")) + .join(" · "); + let name = model.replace(/[((][^))]*[))]/g, "").trim(); + for (const label of [ + brand, + "现代", + "帕力安牌", + "宇通", + "飞驰", + "苏龙", + "跃进", + "楚风", + ]) { + if (label && name.startsWith(label)) + name = name.slice(label.length).replace(/^-/, ""); + if (label && name.endsWith(label)) name = name.slice(0, -label.length); + } + return { name: name || model, operator }; +} +export interface InventoryScope { + rows: RegionalInventoryStats[]; + label: string; + initialCategory?: "Abnormal" | "Pending"; +} +export function scopedInventorySelection( + scope: InventoryScope, + extra: Partial = {}, +): VehicleModalSelection { + return { + batch: "All", + model: "All", + location: "All", + source: "asset", + category: "Inventory", + inventoryKeys: scope.rows.map(inventoryKey), + scopeLabel: scope.label, + ...extra, + }; +} +export function matchesInventorySelection( + vehicle: VehicleListItem, + selection: VehicleModalSelection, +) { + return ( + (!selection.inventoryKeys || + selection.inventoryKeys.includes(vehicle.inventoryKey || "")) && + (!selection.inventoryOperation || + inventoryOperation(vehicle.operationStatus) === + selection.inventoryOperation) && + (!selection.inventoryColor || + inventoryColor(vehicle.color) === + inventoryColor(selection.inventoryColor)) && + (!selection.abnormalType || + (vehicle.abnormalMove?.type || "未提供") === selection.abnormalType) + ); +} diff --git a/src/modules/assets/library/shared.tsx b/src/modules/assets/library/shared.tsx new file mode 100644 index 0000000..3868bcc --- /dev/null +++ b/src/modules/assets/library/shared.tsx @@ -0,0 +1,253 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; +import { ArrowUpRight, SlidersHorizontal } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { useDemoMode } from "../../../components/Blur"; +import type { VehicleModalSelection } from "../model"; +export type OpenVehicles = (selection: VehicleModalSelection) => void; +export const baseSelection: VehicleModalSelection = { + batch: "All", + model: "All", + location: "All", + source: "asset", +}; +export const unique = (values: (string | null | undefined)[]) => + [...new Set(values.filter((v): v is string => !!v))].sort(); + +/** 区块级 Tab 统一样式:模块级与卡片内嵌 Tab 共用,避免多套视觉。 */ +export const sectionTabsListClass = + "w-full min-h-11 bg-[var(--muted)] md:w-fit"; +export const sectionTabsTriggerClass = + "min-h-11 px-4 data-[state=active]:text-[var(--blue)] dark:data-[state=active]:text-[var(--cyan)]"; + +/** 数字滚动动画:目标值变化时从当前值缓动到新值;尊重 reduced-motion。 */ +export function useCountUp(target: number, duration = 800) { + const [value, setValue] = useState(0); + const valueRef = useRef(0); + const rafRef = useRef(0); + useEffect(() => { + const from = valueRef.current; + if (from === target) return undefined; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + valueRef.current = target; + setValue(target); + return undefined; + } + const start = performance.now(); + const tick = (now: number) => { + const t = Math.min(1, (now - start) / duration); + const eased = 1 - Math.pow(1 - t, 3); + const current = Math.round(from + (target - from) * eased); + valueRef.current = current; + setValue(current); + if (t < 1) rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafRef.current); + }, [target, duration]); + return value; +} + +/** 占比条:用于 KPI 卡片内的构成占比(如 租赁/自营)。 */ +export function ProportionBar({ + segments, + label, +}: { + segments: { value: number; className: string; title: string }[]; + label: string; +}) { + const total = segments.reduce((n, s) => n + s.value, 0); + return ( +
+ {total > 0 && + segments + .filter((s) => s.value > 0) + .map((s) => ( +
+ ))} +
+ ); +} +export function PrivateText({ children }: { children: ReactNode }) { + return <>{useDemoMode() ? "••••••" : children || "—"}; +} +export function Count({ + value, + onClick, + label, +}: { + value: number; + onClick: () => void; + label?: string; +}) { + if (!value) + return ( + + 0 + + ); + return ( + + ); +} +export function Picker({ + label, + value = "", + values, + change, +}: { + label: string; + value?: string; + values: string[]; + change: (value: string) => void; +}) { + return ( + + {label} + + + ); +} +export function FilterFields({ + fields, + onReset, +}: { + fields: Parameters[0][]; + onReset: () => void; +}) { + const active = fields.filter((field) => field.value); + return ( + + + + + + 筛选条件 + {active.length ? ( + active.map((field) => ( + + {field.label}:{field.value} + + )) + ) : ( + + 未设条件 + + )} + + + +
+ {fields.map((field) => ( + + ))} + +
+
+
+
+ ); +} +export function Failure({ + message, + retry, +}: { + message: string; + retry?: () => void; +}) { + return ( + + 数据加载失败 + + {message} + {retry && ( + + )} + + + ); +} +export function useMobile() { + const [mobile, setMobile] = useState( + () => window.matchMedia("(max-width: 767px)").matches, + ); + useEffect(() => { + const query = window.matchMedia("(max-width: 767px)"); + const update = () => setMobile(query.matches); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + return mobile; +} diff --git a/src/modules/assets/library/useAssetHash.ts b/src/modules/assets/library/useAssetHash.ts new file mode 100644 index 0000000..8091dbc --- /dev/null +++ b/src/modules/assets/library/useAssetHash.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; + +const TAB_IDS = ["overview", "department", "region", "customer"] as const; +const SECTION_IDS = ["inventory", "models", "flow"] as const; + +export type AssetTab = (typeof TAB_IDS)[number]; +export type AssetSection = (typeof SECTION_IDS)[number]; + +interface AssetHashState { + tab: AssetTab; + section: AssetSection; +} + +const DEFAULT_STATE: AssetHashState = { tab: "overview", section: "inventory" }; + +const isTab = (value: string | undefined): value is AssetTab => + Boolean(value) && (TAB_IDS as readonly string[]).includes(value as string); +const isSection = (value: string | undefined): value is AssetSection => + Boolean(value) && (SECTION_IDS as readonly string[]).includes(value as string); + +function parseHash(): AssetHashState { + const [first, second, third] = window.location.hash.slice(1).split("/"); + if (first !== "assets") return DEFAULT_STATE; + return { + tab: isTab(second) ? second : "overview", + section: isSection(third) ? third : "inventory", + }; +} + +function writeHash(tab: AssetTab, section: AssetSection) { + const parts = ["assets", tab]; + // 默认子页签不写入,保持 `#assets/` 简洁。 + if (tab === "overview" && section !== "inventory") parts.push(section); + const hash = `#${parts.join("/")}`; + window.history.replaceState( + null, + "", + `${window.location.pathname}${window.location.search}${hash}`, + ); +} + +/** + * 资产模块页签状态 ↔ URL hash 二级/三级段(`#assets/[/
]`)。 + * 默认值不写入 hash,刷新可恢复,链接可分享;与 Shell 的一级模块段兼容。 + */ +export function useAssetHash() { + const [state, setState] = useState(parseHash); + + useEffect(() => { + const onChange = () => setState(parseHash()); + window.addEventListener("hashchange", onChange); + return () => window.removeEventListener("hashchange", onChange); + }, []); + + const update = (next: Partial) => { + const merged = { ...state, ...next }; + writeHash(merged.tab, merged.section); + setState(merged); + }; + + return [state, update] as const; +} diff --git a/src/modules/assets/model.ts b/src/modules/assets/model.ts index a80ad10..42b3582 100644 --- a/src/modules/assets/model.ts +++ b/src/modules/assets/model.ts @@ -58,6 +58,12 @@ export type VehicleModalCategory = | 'Operating'; export interface VehicleModalSelection { + inventoryKeys?: string[]; + inventoryOperation?: "available" | "awaiting" | "unclassified"; + inventoryColor?: string; + abnormalType?: string; + scopeLabel?: string; + brand?: string; batch: string; model: string; location: string; @@ -75,6 +81,7 @@ export interface VehicleModalSelection { } export interface VehicleListRequestParams { + brand?: string; batch?: string; model?: string; location?: string; @@ -174,6 +181,7 @@ export function buildVehicleModalRequest( } const params: VehicleListRequestParams = {}; + if (selection.brand) params.brand = selection.brand; if (selection.vehicleType) params.vehicleType = selection.vehicleType; if (selection.batch !== 'All') params.batch = selection.batch; if (selection.model !== 'All') params.model = selection.model; diff --git a/src/modules/assets/types.ts b/src/modules/assets/types.ts index dbba45e..52c82b7 100644 --- a/src/modules/assets/types.ts +++ b/src/modules/assets/types.ts @@ -1,3 +1,4 @@ +import type { InventoryCounts } from '../../shared/assets/inventory'; import type { AbnormalMoveDetail } from '../../shared/assets/abnormal-move'; export interface SummaryData { @@ -103,6 +104,9 @@ export interface InventoryTypeSummary { } export interface VehicleListItem { + color?: string; + operationStatus?: string | null; + inventoryKey?: string; pendingDelivery?: { contractNo: string | null; customerName: string | null } | null; abnormalMove?: AbnormalMoveDetail | null; id: number; @@ -126,6 +130,11 @@ export interface VehicleListItem { } export interface RegionalInventoryStats { + available?: number; + awaiting?: number; + unclassified?: number; + abnormalTypes?: Record; + variants?: Record; abnormal?: number; pending?: number; region: string; diff --git a/src/modules/energy/SubTabs.tsx b/src/modules/energy/SubTabs.tsx index ff10646..63904d9 100644 --- a/src/modules/energy/SubTabs.tsx +++ b/src/modules/energy/SubTabs.tsx @@ -15,7 +15,7 @@ interface Props { export default function SubTabs({ tabs, active, onChange }: Props) { return ( -
+
); diff --git a/src/modules/mileage/MileageModule.tsx b/src/modules/mileage/MileageModule.tsx index 9466e5f..06a0a36 100644 --- a/src/modules/mileage/MileageModule.tsx +++ b/src/modules/mileage/MileageModule.tsx @@ -30,7 +30,7 @@ export default function MileageModule() { compactInfo hideHeader > -
+
diff --git a/src/server/routes/vehicles.ts b/src/server/routes/vehicles.ts index a51906a..9e42798 100644 --- a/src/server/routes/vehicles.ts +++ b/src/server/routes/vehicles.ts @@ -1,3 +1,4 @@ +import { inventoryKey, inventoryOperation, inventoryColor, emptyInventoryCounts, type InventoryCounts } from '../../shared/assets/inventory.js'; import { Hono } from 'hono'; import type { Vehicle, @@ -306,24 +307,8 @@ app.get('/region-stats', async (c) => { })) .sort((a, b) => b.totalAssets - a.totalAssets); - // Top 8 cities + merge rest into "其他" - const topCities = allCities.slice(0, 8); - const restCities = allCities.slice(8); - if (restCities.length > 0) { - const restVehicles = restCities.flatMap((c) => { - const key = c.city; - return cityMap.get(key) || []; - }); - topCities.push({ - city: '其他', - totalAssets: restCities.reduce((s, c) => s + c.totalAssets, 0), - operatingCount: restCities.reduce((s, c) => s + c.operatingCount, 0), - pendingCount: restCities.reduce((s, c) => s + (c.pendingCount || 0), 0), - customers: Array.from(new Set(restVehicles.map((v) => v.customerName).filter(Boolean))) as string[], - typeBreakdown: getTypeBreakdown(restVehicles), - }); - } - const cities = topCities; + // The UI uses collapsible city sections, so keep actual cities addressable for drill-down. + const cities = allCities; return { region, @@ -372,9 +357,11 @@ app.get('/customer-stats', async (c) => { // GET /api/vehicles/list — flat list with optional filters app.get('/list', async (c) => { const vehicles = await getVehiclesForUser(c); - const { batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query(); + const { brand, batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query(); - let filtered = vehicles; + let filtered = c.req.query('source') === 'region' + ? vehicles.filter(v => isOperatingVehicle(v) || isInventoryPending(v)) + : vehicles; // attendance filter: active = day_mileage > 0, idle = day_mileage = 0 (only for Operating vehicles) if (attendance === 'active' || attendance === 'idle') { @@ -389,7 +376,9 @@ app.get('/list', async (c) => { } } if (vehicleType) { - if (VEHICLE_TYPE_FILTERS[vehicleType]) { + if (vehicleType === '区域其他' && c.req.query('source') === 'region') { + filtered = filtered.filter(v => !['4.5T', '18T', '49T'].includes(v.type)); + } else if (VEHICLE_TYPE_FILTERS[vehicleType]) { filtered = filtered.filter(VEHICLE_TYPE_FILTERS[vehicleType]); } else if (vehicleType === '4.5T') { filtered = filtered.filter((v) => v.type === '4.5T'); @@ -397,6 +386,9 @@ app.get('/list', async (c) => { filtered = filtered.filter((v) => v.type === vehicleType); } } + if (brand) { + filtered = filtered.filter((v) => (v.brandLabel || '未知') === brand); + } if (batch && batch !== 'All') { filtered = filtered.filter((v) => (v.contractNo || '未知') === batch); } @@ -450,6 +442,9 @@ app.get('/list', async (c) => { type: v.type, model: v.model, location: v.location, + color: v.color, + operationStatus: v.operationStatus, + inventoryKey: inventoryKey(inventoryDimensions(v)), province: v.province, city: v.city, status: v.status, @@ -468,44 +463,34 @@ app.get('/list', async (c) => { ); }); -// GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放 +// Inventory keeps its existing aggregate visibility; vehicle details retain user permissions. +const INVENTORY_TYPE_NAMES: Record = { t4_5: '4.5T普货', t4_5c: '4.5T冷链', t18: '18T', t49: '49T', trailer: '挂车', other: '其他' }; +function inventoryDimensions(v: Vehicle) { + return { region: mapMacroRegion(v.province, v.city), city: resolveCity(v.city, v.province), brand: v.brandLabel || '未知', type: INVENTORY_TYPE_NAMES[classifyVehicleType({ ...v, model: v.model || "" })], model: v.model || "未标注型号" }; +} app.get('/inventory-stats', async (c) => { const vehicles = applySubjectFilter(c, await vehicleRepository.getVehicles()); - const inventory = vehicles.filter((v) => isInventoryVehicle(v)); - - const TYPE_NAME_MAP: Record = { - t4_5: '4.5T普货', - t4_5c: '4.5T冷链', - t18: '18T', - t49: '49T', - trailer: '挂车', - other: '其他', - }; - - const groups = new Map(); - for (const v of inventory) { - const typeCategory = classifyVehicleType(v); - const typeName = TYPE_NAME_MAP[typeCategory]; - const region = mapMacroRegion(v.province, v.city); - const city = resolveCity(v.city, v.province); - const brand = v.brandLabel || '未知'; - const model = v.model; - const key = `${region}|${city}|${brand}|${typeName}|${model}`; - const counts = groups.get(key) || { quantity: 0, abnormal: 0, pending: 0 }; - counts.quantity += 1; - if (isInventoryAbnormal(v)) counts.abnormal += 1; - if (isInventoryPending(v)) counts.pending += 1; - groups.set(key, counts); + const groups = new Map & InventoryCounts & { batch: string; variants: Record }>(); + for (const v of vehicles.filter(isInventoryVehicle)) { + const dimensions = inventoryDimensions(v); + const key = inventoryKey(dimensions); + const row = groups.get(key) || { ...dimensions, batch: v.model, ...emptyInventoryCounts(), variants: {} }; + const color = inventoryColor(v.color); + const variant = row.variants[color] || emptyInventoryCounts(); + for (const count of [row, variant]) { + count.quantity += 1; + count[inventoryOperation(v.operationStatus)] += 1; + if (isInventoryPending(v)) count.pending += 1; + if (isInventoryAbnormal(v)) { + count.abnormal += 1; + const type = v.abnormalMove?.type || '未提供'; + count.abnormalTypes[type] = (count.abnormalTypes[type] || 0) + 1; + } + } + row.variants[color] = variant; + groups.set(key, row); } - - const result = Array.from(groups.entries()) - .map(([key, counts]) => { - const [region, city, brand, type, model] = key.split('|'); - return { region, city, brand, type, model, batch: model, ...counts }; - }) - .sort((a, b) => b.quantity - a.quantity); - - return c.json(result); + return c.json([...groups.values()].sort((a, b) => b.quantity - a.quantity)); }); // GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending @@ -524,7 +509,7 @@ app.get('/weekly-detail', async (c) => { const hasModelFilter = model && model !== 'All'; const hasBatchFilter = batch && batch !== 'All'; const hasLocationFilter = location && location !== 'All'; - if (hasModelFilter || hasBatchFilter || hasLocationFilter) { + if (hasModelFilter || hasBatchFilter || hasLocationFilter || getSubjectParam(c)) { const vehicles = await getVehiclesForUser(c); let pool2 = vehicles; if (hasModelFilter) pool2 = pool2.filter((v) => v.model === model); diff --git a/src/server/routes/vehicles/drilldown.test.ts b/src/server/routes/vehicles/drilldown.test.ts new file mode 100644 index 0000000..4ef9911 --- /dev/null +++ b/src/server/routes/vehicles/drilldown.test.ts @@ -0,0 +1,80 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import app from '../vehicles.js'; +import { vehicleRepository } from './repository.js'; +import type { Vehicle } from '../../types.js'; + +// Repository stubs keep this regression entirely offline. +test('asset drill-down preserves company, region scope, vehicle type and actual cities', async (t) => { + const make = (id: string, overrides: Partial = {}): Vehicle => ({ + id, plateNumber: `TEST-${id}`, vin: id, type: '4.5T', model: '测试普货', color: '白', + location: '嘉兴', region: '嘉兴', province: '浙江省', city: `测试城市${id}`, + status: 'Operating', operationStatus: '1', ownership: 'Rental', rentCompany: '', + contractNo: null, customerName: null, orgName: null, departmentName: null, + subjectOrg: '公司甲', projectName: null, customerManager: null, managerId: null, brandLabel: null, + ...overrides, + }); + const vehicles = Array.from({ length: 9 }, (_, i) => make(String(i + 1))); + vehicles.push(make('10', { operationStatus: '3', status: 'Inventory' })); + vehicles.push(make('11', { type: '挂车', model: '测试挂车' })); + vehicles.push(make('12', { subjectOrg: '公司乙' })); + t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles); + t.mock.method(vehicleRepository, 'getWeeklyDetailRows', async () => [ + { truck_id: '1', plate_number: 'TEST-1', handover_date: null, contract_type: null, customer_name: null }, + { truck_id: '12', plate_number: 'TEST-12', handover_date: null, contract_type: null, customer_name: null }, + ]); + const read = async (path: string) => { + const response = await app.request(path); + assert.equal(response.status, 200); + return response.json(); + }; + const regions = await read('/region-stats?subject=公司甲'); + assert.equal(regions[0].totalAssets, 10); + assert.equal(regions[0].cities.length, 10, 'cities beyond the first eight stay individually addressable'); + const total = await read('/list?source=region&location=华东&subject=公司甲'); + assert.equal(total.length, regions[0].totalAssets); + assert.ok(total.every((v: Vehicle) => v.id !== '10' && v.id !== '12')); + const light = await read('/list?source=region&location=华东&subject=公司甲&vehicleType=4.5T'); + assert.equal(light.length, 9); + const other = await read('/list?source=region&location=华东&subject=公司甲&vehicleType=区域其他'); + assert.deepEqual(other.map((v: Vehicle) => v.id), ['11']); + const weekly = await read('/weekly-detail?type=delivered&subject=公司甲'); + assert.deepEqual(weekly.map((v: {truck_id: string}) => v.truck_id), ['1']); +}); + +test('inventory brand drill-down matches each same-model row, including unknown brand', async (t) => { + const make = (id: string, brandLabel: string | null, subjectOrg = '公司甲'): Vehicle => ({ + id, plateNumber: `TEST-${id}`, vin: id, type: '挂车', model: '挂车', color: '白', + location: '嘉兴', region: '嘉兴', province: '浙江省', city: '嘉兴市', + status: 'Inventory', operationStatus: '3', ownership: 'Rental', rentCompany: '', + contractNo: null, customerName: null, orgName: null, departmentName: null, + subjectOrg, projectName: null, customerManager: null, managerId: null, brandLabel, + }); + t.mock.method(vehicleRepository, 'getVehicles', async () => [make('1', '明威'), make('2', '明威'), make('3', '万风'), make('4', null), make('5', '明威', '公司乙')]); + const summary = await (await app.request('/inventory-stats?subject=公司甲')).json(); + for (const row of summary) { + const query = new URLSearchParams({ source: 'asset', subject: '公司甲', category: 'Inventory', model: row.model, location: row.city, vehicleType: row.type, brand: row.brand }); + const result = await app.request(`/list?${query}`); + assert.equal(result.status, 200); + const detail = await result.json(); + assert.equal(detail.length, row.quantity); + assert.ok(detail.every((vehicle: { brandLabel: string | null }) => (vehicle.brandLabel || '未知') === row.brand)); + } +}); + +test('inventory composition and color variants reconcile with permission-filtered list keys', async (t) => { + const make = (id: string, operationStatus: string, overrides: Partial = {}): Vehicle => ({ + id,plateNumber:`TEST-${id}`,vin:id,type:'18T',model:'现代18T双飞翼(羚牛)',color:'白',location:'嘉兴',region:'嘉兴',province:'浙江省',city:'嘉兴市',status:'Inventory',operationStatus,ownership:'Self',rentCompany:'',contractNo:null,customerName:null,orgName:null,departmentName:null,subjectOrg:'公司甲',projectName:null,customerManager:null,managerId:null,brandLabel:'帕力安牌',...overrides, + }); + t.mock.method(vehicleRepository,'getVehicles',async () => [make('1','3'),make('2','可运营',{status:'Abnormal',abnormalMove:{type:'维修'} as Vehicle['abnormalMove']}),make('3','4',{status:'Pending',color:'红'}),make('4','待运营'),make('5','1'),make('6','2'),make('7','3',{subjectOrg:'公司乙'})]); + const stats = await (await app.request('/inventory-stats?subject=公司甲')).json(); + assert.equal(stats.length,1); + const row = stats[0]; + assert.equal(row.quantity,4); assert.equal(row.available,2); assert.equal(row.awaiting,2); assert.equal(row.unclassified,0); + assert.equal(row.abnormal,1); assert.equal(row.pending,1); assert.deepEqual(row.abnormalTypes,{维修:1}); + assert.equal(row.variants['白色'].quantity,3); assert.equal(row.variants['红色'].quantity,1); + const list = await (await app.request('/list?subject=公司甲&category=Inventory')).json(); + assert.equal(list.length,4); + assert.ok(list.every((vehicle: {inventoryKey:string}) => vehicle.inventoryKey === JSON.stringify([row.region,row.city,row.brand,row.type,row.model]))); + assert.equal(list.filter((vehicle: {color:string}) => vehicle.color === '红').length,1); +}); diff --git a/src/shared/assets/inventory.ts b/src/shared/assets/inventory.ts new file mode 100644 index 0000000..65f6e6b --- /dev/null +++ b/src/shared/assets/inventory.ts @@ -0,0 +1,44 @@ +/** Stable aggregate identity shared by summary and permission-filtered detail. */ +export function inventoryKey(row: { + region: string; + city: string; + brand: string; + type: string; + model: string; +}) { + return JSON.stringify([row.region, row.city, row.brand, row.type, row.model]); +} +export function inventoryOperation( + status?: string | null, +): "available" | "awaiting" | "unclassified" { + if (["3", "可运营"].includes((status || "").trim())) return "available"; + if (["4", "待运营"].includes((status || "").trim())) return "awaiting"; + return "unclassified"; +} +export interface InventoryCounts { + quantity: number; + available: number; + awaiting: number; + unclassified: number; + abnormal: number; + pending: number; + abnormalTypes: Record; +} +export const emptyInventoryCounts = (): InventoryCounts => ({ + quantity: 0, + available: 0, + awaiting: 0, + unclassified: 0, + abnormal: 0, + pending: 0, + abnormalTypes: {}, +}); + +/** Normalize only known color aliases; preserve meaningful multi-color descriptions. */ +export function inventoryColor(value?: string | null): string { + const color = value?.trim() || ""; + if (!color) return "未标注颜色"; + return /^(红|白|黑|蓝|绿|灰|黄|银|橙)(色|车)?$/.test(color) + ? `${color[0]}色` + : color; +} diff --git a/src/vendor/apple-design/tokens.css b/src/vendor/apple-design/tokens.css new file mode 100644 index 0000000..8be959e --- /dev/null +++ b/src/vendor/apple-design/tokens.css @@ -0,0 +1,239 @@ +/* ============================================================================ + Apple Design Tokens — drop-in theme layer for the web + ---------------------------------------------------------------------------- + Real Apple system values (colors, type, spacing, radii, materials, motion) + expressed as CSS custom properties. Import once at the root of your app: + + @import "tokens.css"; + + Everything below is light-mode default; dark mode is applied automatically + via prefers-color-scheme AND an explicit [data-theme="dark"] override so you + can force a theme. Semantic tokens (--label, --bg, --fill-*) are what you + should reference in components — never hardcode a hex. + ============================================================================ */ + +:root { + /* ---- Type: system font stacks -------------------------------------- */ + /* -apple-system/BlinkMacSystemFont pull SF Pro on Apple platforms; the + rest degrade gracefully. "SF Pro Display" vs "Text" is handled by the OS + via optical sizing — we opt in with font-optical-sizing below. */ + --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", + "Helvetica Neue", "Segoe UI", system-ui, sans-serif; + --font-rounded: "SF Pro Rounded", -apple-system, "Nunito", system-ui, sans-serif; + --font-serif: "New York", ui-serif, Georgia, "Times New Roman", serif; + --font-mono: "SF Mono", ui-monospace, "JetBrains Mono", Menlo, monospace; + + /* ---- Type scale: Apple's text styles, in rem (1rem = 16px base) ----- + Each style is a *set*: size + weight + tracking + leading. See + references/typography.md. Tracking tightens as size grows; leading loosens + as size shrinks. Values are Apple's, converted pt→rem at 16px root. */ + --text-large-title: 2.125rem; /* 34pt */ + --text-title-1: 1.75rem; /* 28pt */ + --text-title-2: 1.375rem; /* 22pt */ + --text-title-3: 1.25rem; /* 20pt */ + --text-headline: 1.0625rem; /* 17pt, semibold */ + --text-body: 1.0625rem; /* 17pt */ + --text-callout: 1rem; /* 16pt */ + --text-subhead: 0.9375rem; /* 15pt */ + --text-footnote: 0.8125rem; /* 13pt */ + --text-caption-1: 0.75rem; /* 12pt */ + --text-caption-2: 0.6875rem; /* 11pt */ + + --tracking-tight: -0.022em; /* large display text */ + --tracking-snug: -0.014em; /* titles */ + --tracking-normal: 0em; /* body */ + --tracking-wide: 0.01em; /* small caps / footnotes */ + + --leading-tight: 1.05; /* headlines */ + --leading-snug: 1.2; + --leading-normal: 1.35; /* body-ish */ + --leading-relaxed: 1.5; /* long-form reading */ + + --weight-regular: 400; + --weight-medium: 500; + --weight-semibold: 600; + --weight-bold: 700; + + /* ---- Spacing: 4/8pt grid ------------------------------------------- */ + --space-1: 0.125rem; /* 2 */ + --space-2: 0.25rem; /* 4 */ + --space-3: 0.5rem; /* 8 */ + --space-4: 0.75rem; /* 12 */ + --space-5: 1rem; /* 16 */ + --space-6: 1.25rem; /* 20 */ + --space-7: 1.5rem; /* 24 */ + --space-8: 2rem; /* 32 */ + --space-9: 2.5rem; /* 40 */ + --space-10: 3rem; /* 48 */ + --space-12: 4rem; /* 64 */ + + /* ---- Radii: Apple leans on generous, continuous corners ------------ + iOS uses "continuous" curvature (a squircle), not a plain arc. On the web + you approximate it with a slightly larger border-radius; for hero elements + use the squircle recipe in references/tokens.md. Capsule = fully rounded. */ + --radius-xs: 6px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 22px; /* iOS app-icon / large card feel */ + --radius-2xl: 28px; + --radius-capsule: 999px; + + /* ---- Elevation: soft, layered, low-contrast shadows ---------------- + Apple shadows are diffuse and never harsh. Two-layer: a tight contact + shadow + a soft ambient one. Tune opacity down in dark mode. */ + --shadow-1: 0 1px 2px rgba(0,0,0,0.04), 0 1px 1px rgba(0,0,0,0.06); + --shadow-2: 0 2px 8px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.08); + --shadow-3: 0 8px 24px rgba(0,0,0,0.10), 0 2px 6px rgba(0,0,0,0.08); + --shadow-4: 0 16px 48px rgba(0,0,0,0.14), 0 4px 12px rgba(0,0,0,0.10); + --shadow-glass: 0 8px 32px rgba(0,0,0,0.18); /* floating glass chrome */ + + /* ---- Motion: easing + duration tokens ------------------------------ + Apple UI never uses the weak built-in ease-*. These curves are the ones + Apple's own interfaces and the best web recreations use. Springs cannot be + expressed in pure CSS — see references/motion-physics.md for the JS tokens + (damping/response) consumed by Motion and GSAP. NEVER use ease-in on UI: + it starts slow and feels laggy. */ + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* default UI exit-of-motion */ + --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* symmetric moves */ + --ease-ios: cubic-bezier(0.32, 0.72, 0, 1); /* sheets/drawers (iOS) */ + --ease-snappy: cubic-bezier(0.4, 0, 0.2, 1); /* quick, neutral */ + + --duration-instant: 100ms; /* button press / tap feedback */ + --duration-fast: 200ms; /* tooltips, small popovers */ + --duration-base: 300ms; /* dropdowns, most UI (keep UI under 300ms) */ + --duration-slow: 450ms; /* modals, sheets, drawers */ + + /* ---- Semantic colors: LIGHT (defaults) ----------------------------- */ + /* Accents (Apple system colors) */ + --blue: #007AFF; --green: #34C759; --indigo: #5856D6; --orange: #FF9500; + --pink: #FF2D55; --purple: #AF52DE; --red: #FF3B30; --teal: #30B0C7; + --cyan: #32ADE6; --yellow: #FFCC00; --mint: #00C7BE; --brown: #A2845E; + --accent: var(--blue); + + /* Grays */ + --gray-1: #8E8E93; --gray-2: #AEAEB2; --gray-3: #C7C7CC; + --gray-4: #D1D1D6; --gray-5: #E5E5EA; --gray-6: #F2F2F7; + + /* Backgrounds (base → elevated) */ + --bg: #FFFFFF; + --bg-secondary: #F2F2F7; + --bg-tertiary: #FFFFFF; + --bg-grouped: #F2F2F7; /* settings-style grouped lists */ + + /* Labels (text) — Apple encodes hierarchy as opacity over a base ink */ + --label: rgba(0, 0, 0, 1); + --label-secondary: rgba(60, 60, 67, 0.60); + --label-tertiary: rgba(60, 60, 67, 0.30); + --label-quaternary: rgba(60, 60, 67, 0.18); + + /* Separators & fills */ + --separator: rgba(60, 60, 67, 0.29); + --separator-opaque: #C6C6C8; + --fill-1: rgba(120, 120, 128, 0.20); /* thick control fill */ + --fill-2: rgba(120, 120, 128, 0.16); + --fill-3: rgba(120, 120, 128, 0.12); + --fill-4: rgba(120, 120, 128, 0.08); /* thin, e.g. hover */ + + /* ---- Materials: the tint + blur pair per thickness ------------------ + A "material" is a translucent tint color paired with a backdrop blur. Use + --material-*-tint as background and --material-*-blur inside backdrop-filter. + See references/liquid-glass.md for the full layered recipe (specular + rim). */ + --material-ultrathin-tint: rgba(255, 255, 255, 0.44); + --material-thin-tint: rgba(255, 255, 255, 0.60); + --material-regular-tint: rgba(255, 255, 255, 0.72); + --material-thick-tint: rgba(255, 255, 255, 0.84); + --material-blur: 20px; + --material-blur-thin: 12px; + --material-saturate: 180%; + + color-scheme: light dark; +} + +/* ============================================================================ + DARK MODE — auto via prefers-color-scheme, plus [data-theme] overrides so + you can force light/dark regardless of OS setting. + ============================================================================ */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --blue: #0A84FF; --green: #30D158; --indigo: #5E5CE6; --orange: #FF9F0A; + --pink: #FF375F; --purple: #BF5AF2; --red: #FF453A; --teal: #40C8E0; + --cyan: #64D2FF; --yellow: #FFD60A; --mint: #63E6E2; --brown: #AC8E68; + + --gray-1: #8E8E93; --gray-2: #636366; --gray-3: #48484A; + --gray-4: #3A3A3C; --gray-5: #2C2C2E; --gray-6: #1C1C1E; + + --bg: #000000; + --bg-secondary: #1C1C1E; + --bg-tertiary: #2C2C2E; + --bg-grouped: #000000; + + --label: rgba(255, 255, 255, 1); + --label-secondary: rgba(235, 235, 245, 0.60); + --label-tertiary: rgba(235, 235, 245, 0.30); + --label-quaternary: rgba(235, 235, 245, 0.16); + + --separator: rgba(84, 84, 88, 0.60); + --separator-opaque: #38383A; + --fill-1: rgba(120, 120, 128, 0.36); + --fill-2: rgba(120, 120, 128, 0.32); + --fill-3: rgba(120, 120, 128, 0.24); + --fill-4: rgba(120, 120, 128, 0.18); + + --material-ultrathin-tint: rgba(30, 30, 32, 0.44); + --material-thin-tint: rgba(30, 30, 32, 0.62); + --material-regular-tint: rgba(30, 30, 32, 0.74); + --material-thick-tint: rgba(30, 30, 32, 0.86); + + --shadow-1: 0 1px 2px rgba(0,0,0,0.30); + --shadow-2: 0 2px 8px rgba(0,0,0,0.36); + --shadow-3: 0 8px 24px rgba(0,0,0,0.44); + --shadow-4: 0 16px 48px rgba(0,0,0,0.52); + --shadow-glass: 0 8px 32px rgba(0,0,0,0.50); + } +} + +/* Explicit dark override (force dark even in a light OS) */ +[data-theme="dark"] { + --blue:#0A84FF; --green:#30D158; --indigo:#5E5CE6; --orange:#FF9F0A; + --pink:#FF375F; --purple:#BF5AF2; --red:#FF453A; --teal:#40C8E0; + --cyan:#64D2FF; --yellow:#FFD60A; --mint:#63E6E2; --brown:#AC8E68; + --gray-1:#8E8E93; --gray-2:#636366; --gray-3:#48484A; + --gray-4:#3A3A3C; --gray-5:#2C2C2E; --gray-6:#1C1C1E; + --bg:#000; --bg-secondary:#1C1C1E; --bg-tertiary:#2C2C2E; --bg-grouped:#000; + --label:rgba(255,255,255,1); --label-secondary:rgba(235,235,245,0.6); + --label-tertiary:rgba(235,235,245,0.3); --label-quaternary:rgba(235,235,245,0.16); + --separator:rgba(84,84,88,0.6); --separator-opaque:#38383A; + --fill-1:rgba(120,120,128,0.36); --fill-2:rgba(120,120,128,0.32); + --fill-3:rgba(120,120,128,0.24); --fill-4:rgba(120,120,128,0.18); + --material-ultrathin-tint:rgba(30,30,32,0.44); --material-thin-tint:rgba(30,30,32,0.62); + --material-regular-tint:rgba(30,30,32,0.74); --material-thick-tint:rgba(30,30,32,0.86); +} + +/* ============================================================================ + BASE — sensible defaults so the tokens feel Apple-native immediately. + ============================================================================ */ +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + font-family: var(--font-sans); + font-optical-sizing: auto; /* SF switches Text↔Display by size */ + -webkit-font-smoothing: antialiased; /* thinner, Apple-like rendering */ + text-rendering: optimizeLegibility; + font-size: var(--text-body); + line-height: var(--leading-normal); + letter-spacing: var(--tracking-normal); + color: var(--label); + background: var(--bg); +} + +/* Accessibility: respect the three reduced-* preferences everywhere. */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/tsconfig.json b/tsconfig.json index 1a48f18..bae31d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "noEmit": true, "allowImportingTsExtensions": true, "paths": { - "@/*": ["./*"] + "@/*": ["./src/*"] } }, "include": ["src"] diff --git a/vite.config.ts b/vite.config.ts index b12b351..1bc5fc1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,10 @@ +import { fileURLToPath, URL } from 'node:url'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ + resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, plugins: [react(), tailwindcss()], server: { proxy: { From 0c4a9bcd05170fc191c3413d7350315e354e6bc4 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Wed, 23 Sep 2026 14:16:36 +0800 Subject: [PATCH 11/13] feat(assets): add DeepSeek assistant with streaming results and voice input --- docs/asset-ai-demo.md | 41 + docs/licenses/beautiful-ui.txt | 21 + src/auth/api-client.ts | 7 +- src/modules/assets/AssetsModule.tsx | 9 +- .../assets/assistant/AssetAssistant.tsx | 640 ++++++++ src/modules/assets/assistant/ResultView.tsx | 508 +++++++ .../assets/assistant/ThinkingState.tsx | 79 + src/modules/assets/assistant/assistant.css | 1289 +++++++++++++++++ src/modules/assets/assistant/client.ts | 51 + src/server/app.ts | 2 + src/server/routes/asset-assistant/index.ts | 281 ++++ .../routes/asset-assistant/model.test.ts | 149 ++ src/server/routes/asset-assistant/model.ts | 193 +++ src/shared/assets/assistant.ts | 88 ++ 14 files changed, 3355 insertions(+), 3 deletions(-) create mode 100644 docs/asset-ai-demo.md create mode 100644 docs/licenses/beautiful-ui.txt create mode 100644 src/modules/assets/assistant/AssetAssistant.tsx create mode 100644 src/modules/assets/assistant/ResultView.tsx create mode 100644 src/modules/assets/assistant/ThinkingState.tsx create mode 100644 src/modules/assets/assistant/assistant.css create mode 100644 src/modules/assets/assistant/client.ts create mode 100644 src/server/routes/asset-assistant/index.ts create mode 100644 src/server/routes/asset-assistant/model.test.ts create mode 100644 src/server/routes/asset-assistant/model.ts create mode 100644 src/shared/assets/assistant.ts diff --git a/docs/asset-ai-demo.md b/docs/asset-ai-demo.md new file mode 100644 index 0000000..07fac16 --- /dev/null +++ b/docs/asset-ai-demo.md @@ -0,0 +1,41 @@ +# 资产 AI 问数 Demo + +入口:本地运行 `npm run dev` 后打开 `http://localhost:3000/asset#assets`,点击「AI 问数」。 + +## 已实现 + +- 采用 Beautiful UI Thinking / Prompt Bar / Chat 的视觉语言;Thinking 步骤改为后端 SSE 实际查询进度,保留 MIT 许可于 `docs/licenses/beautiful-ui.txt`。 +- DeepSeek Flash 将自然语言和上一轮查询计划转成受校验的结构化查询;后端复用资产仓储、权限过滤和业务分类;第二次模型调用只解释汇总数据。 +- 查询当前资产数量、库存、可运营/待运营、运营率、异动和待交车;按区域、城市、车型、型号、部门和运营状态分组。 +- 查询最多 93 天的交车、还车、替换趋势。交车包含替换;流转按业务车次计数,不以车辆去重。 +- 图表/统计表/明细切换、分组下钻、明细搜索及分页、CSV 导出、连续追问、取消、错误重试、新对话、深浅主题及移动端。 +- 浏览器 SpeechRecognition 语音输入:用户点击麦克风启动,转写后可编辑再发送。不支持或拒绝授权时提示文字输入。 + +## 本机配置 + +`DEEPSEEK_API_KEY` 仅位于 Git 忽略的 `.env`,后端读取。客户端构建不包含密钥。无需配置 `VITE_` 密钥。更换临时密钥后重启后端。 + +现有开发环境本身已开启本地免登录;本次未改变该开关。问答路由挂在应用的认证中间件之后,未认证直接调用被拒绝。部署环境必须沿用正常认证配置。 + +模型接收问题、维度目录(区域/城市/车型/型号)、已校验的上一轮计划和汇总统计;不会主动发送车牌、VIN、客户或车辆明细。用户在提问中自行输入的文字会发送给模型。业务数据没有伪造或演示替换。 + +Portainer / Docker 部署见 [DeepSeek 环境变量注入](./portainer-deepseek.md)。 + +## 口径和边界 + +- 当前公司从资产页面继承,变更公司请关闭面板后选择公司;服务端执行账号权限与公司范围过滤。 +- 资产来源为当前台账与现有服务端缓存,结果标记查询时间,不声称是历史库存快照。 +- 分组与明细使用同一次查询返回的数据;区域按现有省市映射。 +- 运营率 = 当前筛选车辆中的运营车辆 / 当前筛选车辆数。 +- 历史库存、金额、成本、预测等暂不支持,助手应说明能力范围或追问,不执行模型生成的 SQL。 +- 会话只保存在打开的面板内,刷新/关闭不会保存。语音依赖浏览器和其在线识别服务,尚未接专用 ASR。 + +## 验证 + +- TypeScript 检查、Vite 生产构建;构建仍有现有 ECharts/AG Grid 大块提示。 +- 自动验证个人权限与所属公司范围、库存子集计数、无认证拒绝、非法查询/日期拒绝,以及模型请求不包含车辆明细。 +- 桌面及手机浏览器实际查询:库存 291 台 → 追问可运营库存 181 台;华东库存 198 条明细与分页对应。 +- 2026-09-01 至 2026-09-23 流转:交车 63 车次、还车 69 车次,其中替换 19 车次;2026-09-22 下钻及 CSV 均为 17 条记录。 +- 深浅模式、无横向页面溢出、空明细搜索和结果展示切换已验证。真实麦克风识别未自动录制测试。 + +开发大脑回执:DB-C-003,知识索引版本 1 校验通过,未发现冲突;本次是产品 Demo 实现,未形成新的跨任务复盘模式,无知识写入或 GitHub 同步。 diff --git a/docs/licenses/beautiful-ui.txt b/docs/licenses/beautiful-ui.txt new file mode 100644 index 0000000..6635631 --- /dev/null +++ b/docs/licenses/beautiful-ui.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Shane Levine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/auth/api-client.ts b/src/auth/api-client.ts index a200605..4cee583 100644 --- a/src/auth/api-client.ts +++ b/src/auth/api-client.ts @@ -6,7 +6,7 @@ export function setTokenGetter(fn: () => string | null) { tokenGetter = fn; } -export async function fetchJson(url: string, options?: RequestInit): Promise { +export async function fetchAuthenticated(url: string, options?: RequestInit): Promise { const token = tokenGetter(); const res = await fetch(url, { ...options, @@ -19,6 +19,11 @@ export async function fetchJson(url: string, options?: RequestInit): Promise< window.dispatchEvent(new CustomEvent('auth:unauthorized')); throw new Error('Unauthorized'); } + return res; +} + +export async function fetchJson(url: string, options?: RequestInit): Promise { + const res = await fetchAuthenticated(url, options); if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`); return res.json(); } diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index 1eb71c1..3b6d125 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -1,10 +1,10 @@ import "./library/asset-ui.css"; import "./library/inventory-matrix.css"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Skeleton } from "@/components/ui/skeleton"; -import { ArrowUpRight, Moon, RefreshCw, Sun, Truck } from "lucide-react"; +import { ArrowUpRight, Moon, RefreshCw, Sparkles, Sun, Truck } from "lucide-react"; import { Picker, Failure, @@ -43,6 +43,7 @@ import { CustomerView, } from "./library/BusinessViews"; import { VehicleDetails } from "./library/VehicleDetails"; +const AssetAssistant = lazy(() => import('./assistant/AssetAssistant')); interface Data { summary: SummaryData; @@ -53,6 +54,8 @@ interface Data { inventory: RegionalInventoryStats[]; } export default function AssetsModule() { + const [assistantOpen, setAssistantOpen] = useState(false); + const assistantTrigger = useRef(null); const [appearance, setAppearance] = useState<"light" | "dark">(() => { const saved = window.localStorage.getItem("asset-appearance"); return saved === "light" || saved === "dark" @@ -202,6 +205,7 @@ export default function AssetsModule() { />
+ {updated ? `更新于 ${updated.slice(11, 16)} · 台账截至 17:00` @@ -306,6 +310,7 @@ export default function AssetsModule() { subject={subject} close={() => setSelection(null)} /> + {assistantOpen && 正在打开 AI 问数…
}> { setAssistantOpen(false); requestAnimationFrame(() => assistantTrigger.current?.focus()); }} />}
); } diff --git a/src/modules/assets/assistant/AssetAssistant.tsx b/src/modules/assets/assistant/AssetAssistant.tsx new file mode 100644 index 0000000..072b80c --- /dev/null +++ b/src/modules/assets/assistant/AssetAssistant.tsx @@ -0,0 +1,640 @@ +import { useEffect, useRef, useState } from "react"; +import { + ArrowUp, + ArrowUpRight, + AudioLines, + BarChart3, + Building2, + CircleHelp, + Database, + History, + MessageSquare, + Mic, + Moon, + Plus, + ShieldCheck, + Sparkles, + Square, + Sun, + Truck, + X, +} from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "../../../components/ui/dialog"; +import type { + AssistantResult, + QueryPlan, +} from "../../../shared/assets/assistant"; +import { + CATEGORY_LABELS, + GROUP_LABELS, +} from "../../../shared/assets/assistant"; +import { askAssetAssistant } from "./client"; +import { ThinkingState } from "./ThinkingState"; +import { ResultView } from "./ResultView"; +import "./assistant.css"; + +type Turn = { + id: number; + question: string; + started: number; + steps: string[]; + result?: AssistantResult; + error?: string; +}; +type Recognition = { + lang: string; + continuous: boolean; + interimResults: boolean; + start(): void; + stop(): void; + abort(): void; + onresult: + | ((event: { + results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }>; + }) => void) + | null; + onerror: ((event: { error: string }) => void) | null; + onend: (() => void) | null; +}; +const PROMPTS = [ + { + icon: BarChart3, + title: "库存分布", + text: "各区域的库存车辆有多少?按数量排序。", + detail: "区域对比 · 一键下钻", + }, + { + icon: Truck, + title: "运营情况", + text: "按车型统计当前资产数量和运营率。", + detail: "车型构成 · 运营率", + }, + { + icon: History, + title: "交退换趋势", + text: "本月的交车、还车和替换交车趋势怎么样?", + detail: "按日趋势 · 业务明细", + }, + { + icon: ListIcon, + title: "待交车清单", + text: "列出当前待交车车辆明细。", + detail: "车辆列表 · 导出结果", + }, +]; +function ListIcon({ size }: { size?: number }) { + return ; +} + +export default function AssetAssistant({ + subject, + close, + appearance, +}: { + subject: string | null; + close: () => void; + appearance: "light" | "dark"; +}) { + const [theme, setTheme] = useState(appearance); + const [turns, setTurns] = useState([]); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [listening, setListening] = useState(false); + const [voiceNote, setVoiceNote] = useState(""); + const [showInfo, setShowInfo] = useState(false); + const abortRef = useRef(null); + const recognitionRef = useRef(null); + const nextId = useRef(0); + const composer = useRef(null); + const scroller = useRef(null); + const lastPlan = turns + .filter((t) => t.result && t.result.plan.kind !== "clarify") + .at(-1)?.result?.plan; + useEffect( + () => () => { + abortRef.current?.abort(); + recognitionRef.current?.abort(); + }, + [], + ); + const stop = () => { + abortRef.current?.abort(); + abortRef.current = null; + setBusy(false); + setTurns((previous) => + previous.map((t) => + !t.result && !t.error + ? { ...t, error: "已停止生成,可以修改问题后重试。" } + : t, + ), + ); + }; + const ask = async (message: string, previous = lastPlan) => { + if (!message.trim() || abortRef.current) return; + recognitionRef.current?.abort(); + setListening(false); + setVoiceNote(""); + const id = ++nextId.current; + const controller = new AbortController(); + abortRef.current = controller; + setDraft(""); + setBusy(true); + setTurns((old) => [ + ...old, + { + id, + question: message.trim(), + started: Date.now(), + steps: ["连接资产分析服务"], + }, + ]); + requestAnimationFrame(() => + scroller.current?.scrollTo({ + top: scroller.current.scrollHeight, + behavior: "instant", + }), + ); + try { + await askAssetAssistant( + { message, subject, previous }, + controller.signal, + (event) => { + if (controller.signal.aborted) return; + const container = scroller.current; + const follow = + container && + container.scrollHeight - + container.scrollTop - + container.clientHeight < + 150; + if (follow && event.type === "result") + requestAnimationFrame(() => + container.scrollTo({ + top: container.scrollHeight, + behavior: "instant", + }), + ); + setTurns((old) => + old.map((turn) => + turn.id !== id + ? turn + : event.type === "step" + ? { ...turn, steps: [...turn.steps, event.label] } + : event.type === "error" + ? { ...turn, error: event.message } + : { ...turn, result: event.result }, + ), + ); + }, + ); + } catch (error) { + if (!controller.signal.aborted) + setTurns((old) => + old.map((t) => + t.id === id + ? { + ...t, + error: + error instanceof Error + ? error.message + : "查询失败,请重试。", + } + : t, + ), + ); + } finally { + if (abortRef.current === controller) { + abortRef.current = null; + setBusy(false); + composer.current?.focus({ preventScroll: true }); + } + } + }; + const toggleVoice = () => { + if (listening) { + recognitionRef.current?.stop(); + return; + } + const win = window as typeof window & { + SpeechRecognition?: new () => Recognition; + webkitSpeechRecognition?: new () => Recognition; + }; + const Constructor = win.SpeechRecognition || win.webkitSpeechRecognition; + if (!Constructor) { + setVoiceNote( + "当前浏览器不支持语音识别,请使用 Chrome / Edge 或直接输入文字。", + ); + return; + } + const recognition = new Constructor(); + recognitionRef.current = recognition; + recognition.lang = "zh-CN"; + recognition.continuous = false; + recognition.interimResults = true; + const base = draft; + recognition.onresult = (event) => + setDraft( + (base ? base + " " : "") + + Array.from(event.results) + .map((r) => r[0].transcript) + .join(""), + ); + recognition.onerror = (event) => { + setListening(false); + setVoiceNote( + event.error === "not-allowed" + ? "麦克风权限未开启,你仍可以输入文字。" + : event.error === "no-speech" + ? "没有识别到语音,请再试一次。" + : "语音识别暂时不可用,请使用文字输入。", + ); + }; + recognition.onend = () => { + setListening(false); + composer.current?.focus(); + }; + try { + recognition.start(); + setListening(true); + setVoiceNote("正在聆听,点击麦克风结束。转写后可修改再发送。"); + } catch { + setVoiceNote("无法启动语音识别,请使用文字输入。"); + } + }; + const reset = () => { + abortRef.current?.abort(); + abortRef.current = null; + recognitionRef.current?.abort(); + setBusy(false); + setListening(false); + setVoiceNote(""); + setTurns([]); + setDraft(""); + composer.current?.focus(); + }; + return ( + { + if (!open) close(); + }} + > + { + e.preventDefault(); + composer.current?.focus(); + }} + > + 资产 AI 问数 + + 通过自然语言查询当前账号可见的资产数据,支持图表、表格和明细下钻。 + + +
+
+
+ + AI 问数 + + 你的资产分析助手 +
+
+ + + + +
+
+
+ + {subject || "全部所属公司"} + · + 当前账号可见范围 + + + 已连接业务数据 + +
+ {showInfo && ( +
+ 支持的分析 +

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

+

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

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

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

+

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

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

{turn.question}

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

{turn.error}

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