Compare commits
58
Commits
dev-ljyang
...
89dca5c5a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89dca5c5a4 | ||
|
|
7d792933dd | ||
|
|
0dc7e89c1d | ||
|
|
4430fb75f1 | ||
|
|
df259fc12b | ||
|
|
df6b8201e8 | ||
|
|
a177781fe7 | ||
|
|
637a72c1e9 | ||
|
|
98efde3f75 | ||
|
|
6c91a6694a | ||
|
|
5eb38a4b05 | ||
|
|
2518d9ee54 | ||
|
|
62efef0ab9 | ||
|
|
37a9f303ec | ||
|
|
06fe75b4c7 | ||
|
|
d610e4b841 | ||
|
|
7fce6b8474 | ||
|
|
bd0627d00f | ||
|
|
17c839a35e | ||
|
|
99b4efda6d | ||
|
|
6b72f0ce3c | ||
|
|
b0c8d6a5b8 | ||
|
|
d8773ff0a0 | ||
|
|
becacecc67 | ||
|
|
67f3ec7306 | ||
|
|
3f29bed5fa | ||
|
|
4d523cbce0 | ||
|
|
2b31f8efe8 | ||
|
|
ae4b5d30e7 | ||
|
|
392a36a0ec | ||
|
|
a558db5795 | ||
|
|
b0caa5afcb | ||
|
|
5377d2c225 | ||
|
|
654573ac4b | ||
|
|
43f94ed1b8 | ||
|
|
fb5789d705 | ||
|
|
94a6e0a75e | ||
|
|
67c5f9d281 | ||
|
|
91202bdf71 | ||
|
|
c13f341d5e | ||
|
|
6962c4ff1c | ||
|
|
6b7f0eedd9 | ||
|
|
5bb3ceb47a | ||
|
|
cc778f3701 | ||
|
|
74d6efe261 | ||
|
|
a124e31fab | ||
|
|
a3dfe7ab8c | ||
|
|
3f0edfaaf5 | ||
|
|
feb950dd59 | ||
|
|
5e1c12eba2 | ||
|
|
ae24bc7647 | ||
|
|
0a372e4290 | ||
|
|
1e08d1ea62 | ||
|
|
2d82918d73 | ||
|
|
482243e052 | ||
|
|
f1a69c8271 | ||
|
|
1d2c3a0cd5 | ||
|
|
e7ba5315e1 |
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.codex
|
||||||
|
outputs
|
||||||
|
reports
|
||||||
|
data/geo
|
||||||
|
data/vehicle-heatmap/source.csv
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Copy to .env.local; never commit real credentials. Environment values win.
|
||||||
|
HYDROGEN_DB_HOST=
|
||||||
|
HYDROGEN_DB_PORT=3306
|
||||||
|
HYDROGEN_DB_USER=
|
||||||
|
HYDROGEN_DB_PASSWORD=
|
||||||
|
HYDROGEN_DB_NAME=
|
||||||
|
# 单站与外部客户数据租户;仅由服务端设置,不接受网页传入。
|
||||||
|
HYDROGEN_TENANT_ID=000000
|
||||||
|
# Optional legacy/electric business database; use a database-level read-only user.
|
||||||
|
DB_HOST=
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=
|
||||||
|
DB_PASSWORD=
|
||||||
|
DB_NAME=
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
|
.worktrees
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@ FROM node:22-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci --registry=https://registry.npmmirror.com/
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ FROM node:22-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev --registry=https://registry.npmmirror.com/
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY src/server ./src/server
|
COPY src/server ./src/server
|
||||||
COPY src/shared ./src/shared
|
COPY src/shared ./src/shared
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Energy Hydrogen Board Design QA
|
||||||
|
|
||||||
|
- Source of truth: `/tmp/energy-h2-bi-board-prototype-0818/index.html`
|
||||||
|
- Prototype archive: `/Users/kkfluous/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/wxid_1704407055112_3af8/msg/file/2026-08/energy-h2-bi-board-html.zip`
|
||||||
|
- Implementation: `http://localhost:3000/energy#hydrogen/overview`
|
||||||
|
- Desktop viewport: 1280 x 720 CSS px
|
||||||
|
- Mobile viewport checked for the daily flow: 390 x 844 CSS px
|
||||||
|
|
||||||
|
## Compared States
|
||||||
|
|
||||||
|
| State | Prototype | Implementation | Combined comparison |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Global overview | `/var/tmp/proto-global-overview.png` | `/var/tmp/impl-global-overview-final.png` | `/var/tmp/compare-global-overview-final.png` |
|
||||||
|
| Global daily | `/var/tmp/proto-global-daily.png` | `/var/tmp/impl-global-daily-final.png` | `/var/tmp/compare-global-daily-final.png` |
|
||||||
|
| All stations | `/var/tmp/proto-station.png` | `/var/tmp/impl-station-final.png` | `/var/tmp/compare-station-final.png` |
|
||||||
|
| Station detail | `/var/tmp/proto-station-detail.png` | `/var/tmp/impl-station-detail-final.png` | `/var/tmp/compare-station-detail-final.png` |
|
||||||
|
|
||||||
|
All desktop comparisons use the same viewport, pixel dimensions, navigation state, and side-by-side source/implementation input.
|
||||||
|
|
||||||
|
## Interaction Evidence
|
||||||
|
|
||||||
|
- Global overview KPI, month, region, station, customer, and insight entries open real ledger drill-down data.
|
||||||
|
- Global daily supports date presets, custom dates, all/Lingniu/external vehicle scopes, chart-to-date focus, and date -> station -> customer -> vehicle/source drill-down.
|
||||||
|
- Daily and station evidence tables provide Excel export where the prototype exposes an export command.
|
||||||
|
- All-station mode supports all/single station display, station selection, clickable daily KPI dialogs, and zero-activity stations.
|
||||||
|
- Station detail includes a continuous 10-day series with zero-filled late days, recent 7-day table, range trend, and 12-month customer quantity/fee matrices.
|
||||||
|
- Entering and leaving a station detail resets the page scroll position so the header and notice remain visible.
|
||||||
|
- A clean browser tab loaded the final overview with no console warnings or errors.
|
||||||
|
- Daily mobile flow has no body-level horizontal overflow; wide analytical tables retain intentional internal horizontal scrolling.
|
||||||
|
|
||||||
|
## Iteration History
|
||||||
|
|
||||||
|
1. P1: Station detail inherited the long station-list scroll position. Added deterministic scroll reset on enter and return.
|
||||||
|
2. P1: Daily drill stopped at station level. Implemented the prototype's full four-level drill and real ledger rows.
|
||||||
|
3. P1: Station KPI cards were static. Added the three prototype daily detail dialogs using continuous server-side daily summaries.
|
||||||
|
4. P2: Prototype state matrix was incomplete. Added global overview, global daily, all-station, single-station selector, and station detail states.
|
||||||
|
5. P2: Icons differed from the prototype. Replaced approximate icons with matching Lucide Fuel, Zap, Wallet, Calendar, Truck, Refresh, Download, and navigation icons.
|
||||||
|
6. P2: Recharts could measure a transitioning container at a negative size. Added minimum and initial dimensions and verified a clean reload.
|
||||||
|
7. P2: Station detail omitted dates without records. Zero-filled the requested range so delayed or absent reporting is visible rather than silently skipped.
|
||||||
|
|
||||||
|
## Residual P3
|
||||||
|
|
||||||
|
- Prototype screenshots contain fixed mock dates and values; implementation screenshots use the current read-only database values.
|
||||||
|
- The prototype warning says opening balances are still calibrating. The implementation uses a truthful traceability notice because the live ledger does not expose that calibration state.
|
||||||
|
- Native date-picker popovers vary by operating system; the closed controls match the prototype hierarchy and icon roles.
|
||||||
|
|
||||||
|
final result: passed
|
||||||
+22
-4
@@ -5,14 +5,32 @@ services:
|
|||||||
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
|
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
|
||||||
network_mode: host
|
network_mode: host
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: "47.101.148.99"
|
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
|
||||||
DB_PORT: "3306"
|
DB_PORT: "3306"
|
||||||
DB_USER: "root"
|
DB_USER: "oneos_db_prod"
|
||||||
DB_PASSWORD: "LN#Passw0rd@2026"
|
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
|
||||||
DB_NAME: "lingniu_prod"
|
DB_NAME: "ln_asset_management"
|
||||||
|
MILEAGE_DB_HOST: "101.133.130.65"
|
||||||
|
MILEAGE_DB_PORT: "3306"
|
||||||
|
MILEAGE_DB_USER: "bi_reader_02"
|
||||||
|
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
|
||||||
|
MILEAGE_DB_NAME: "hydrogen_energy"
|
||||||
|
# ECS production accesses OneOS over the private network. Configure the key
|
||||||
|
# as a Portainer stack environment variable; never commit it to this file.
|
||||||
|
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
|
||||||
|
ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY}"
|
||||||
|
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
|
||||||
SERVER_PORT: "8111"
|
SERVER_PORT: "8111"
|
||||||
EXTERNAL_API_BASE: "https://lnh2e.com"
|
EXTERNAL_API_BASE: "https://lnh2e.com"
|
||||||
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
||||||
|
AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
|
||||||
|
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
|
||||||
|
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
|
||||||
|
HEATMAP_DB_PORT: "${HEATMAP_DB_PORT:-36453}"
|
||||||
|
HEATMAP_DB_USER: "${HEATMAP_DB_USER}"
|
||||||
|
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
|
||||||
|
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
|
||||||
|
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
|
||||||
deploy:
|
deploy:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
restart_policy:
|
restart_policy:
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 登录方式与 Portainer 配置
|
||||||
|
|
||||||
|
不设置 `BI_AUTH_MODE` 时默认 `sso`,沿用业务系统 jumpToken 登录。
|
||||||
|
设置 `BI_AUTH_MODE=password` 时只开放固定密码登录,关闭 SSO 换票入口。
|
||||||
|
浏览器从 `/api/auth/config` 获取模式,密码不会打包到前端,不使用 VITE_ 密码变量。
|
||||||
|
|
||||||
|
## Portainer Stack
|
||||||
|
|
||||||
|
在现有 BI 服务的 `environment` 中合并以下配置,保留原镜像、端口、数据库等配置:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
BI_AUTH_MODE: ${BI_AUTH_MODE:-sso}
|
||||||
|
BI_AUTH_PASSWORD: ${BI_AUTH_PASSWORD:-}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:?必须配置独立的随机签名密钥}
|
||||||
|
DEV_BYPASS_AUTH: "0"
|
||||||
|
NODE_ENV: production
|
||||||
|
```
|
||||||
|
|
||||||
|
在 Stack 的 Environment variables 区域录入以下名称和值:
|
||||||
|
|
||||||
|
| 名称 | 固定密码模式 | SSO 模式 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| BI_AUTH_MODE | password | sso |
|
||||||
|
| BI_AUTH_PASSWORD | 非空密码,允许短密码,推荐独立随机密码 | 留空 |
|
||||||
|
| JWT_SECRET | 独立随机密钥,至少 32 字符 | 保留部署专用密钥 |
|
||||||
|
|
||||||
|
不要把真实密码提交进 Git。Portainer 中填写的 Stack 变量必须通过上面的 environment 映射才能进入容器。
|
||||||
|
更新 Stack 并重新创建容器;单纯 Restart 不会更新容器环境变量。
|
||||||
|
容器方式部署:Duplicate/Edit → Advanced container settings → Env,填写同样变量,再重新部署。
|
||||||
|
需要先构建包含本功能的新镜像;旧镜像不认识这些变量。
|
||||||
|
|
||||||
|
## 权限、安全和验证
|
||||||
|
|
||||||
|
- 固定密码身份是共享只读身份,具有全量看板数据读取范围及能源访问权限,无调度/反馈管理角色;服务端拒绝写入方法。
|
||||||
|
- 会话有效期 8 小时。更换密码、JWT_SECRET 或切换模式后,原密码会话失效。
|
||||||
|
- 每个服务实例 15 分钟最多 20 次密码验证请求;多副本需在网关配置共享限流。共享限流可能被恶意请求耗尽,建议只在内网或受控网络开放。
|
||||||
|
- 外网必须使用 HTTPS,避免明文传输密码和令牌。密码/环境变量对拥有容器管理权限的人可见。
|
||||||
|
- 密码为空或全为空白,或签名密钥少于 32 字符时拒绝登录;无默认访问密码。不要沿用镜像中的旧默认 JWT_SECRET。
|
||||||
|
- 短密码容易被猜中,仅建议在内网或受限访问环境使用;登录限流保持开启。
|
||||||
|
- DB_READ_ONLY=1 时密码登录仍可用,业务写入仍被禁止。
|
||||||
|
- 测试未登录访问、错误密码、正确密码、刷新恢复会话;切回 sso 后检查跳转登录。
|
||||||
|
- 开发免登录仅 SSO 开发模式生效,生产必须保持 DEV_BYPASS_AUTH=0。
|
||||||
|
|
||||||
|
Portainer 官方说明:https://docs.portainer.io/user/docker/containers/advanced
|
||||||
|
以及 https://docs.portainer.io/sts/user/docker/stacks/add
|
||||||
@@ -0,0 +1,671 @@
|
|||||||
|
# LN-BI 项目移交交接说明
|
||||||
|
|
||||||
|
> 文档版本:1.0
|
||||||
|
> 更新日期:2026-08-21
|
||||||
|
> 对应代码版本:`1.1.15`
|
||||||
|
> 对应分支 / 提交:`main` / `2518d9ee5440c39138427ce76d1f6e0faf8d9f29`
|
||||||
|
> 项目仓库:`https://gitea.lnh2e.com/shishengliang/ln-bi.git`
|
||||||
|
|
||||||
|
## 1. 文档目的与交接边界
|
||||||
|
|
||||||
|
本文用于帮助新的研发、测试、运维和产品人员快速接管 LN-BI 项目,覆盖:
|
||||||
|
|
||||||
|
- 系统定位、代码结构和运行拓扑;
|
||||||
|
- 登录认证、角色鉴权和数据权限;
|
||||||
|
- 本地开发与免登录调试;
|
||||||
|
- 前后端技术栈;
|
||||||
|
- 数据对接方、数据库、外部服务及对应 BI / 报表;
|
||||||
|
- 当前功能、后台任务、接口边界和部署流程;
|
||||||
|
- 已知技术债、安全风险和交接检查清单。
|
||||||
|
|
||||||
|
本文依据当前仓库代码和配置编写,不记录数据库密码、JWT 密钥、API Key、OSS 密钥或 CI 仓库凭据。此类信息应通过密码管理器、Portainer / Docker 环境变量或 CI Secret 单独移交。
|
||||||
|
|
||||||
|
## 2. 项目概览
|
||||||
|
|
||||||
|
### 2.1 系统定位
|
||||||
|
|
||||||
|
LN-BI 是羚牛业务数据的统一 BI Web 应用。目前包含两组主入口:
|
||||||
|
|
||||||
|
| 主入口 | 主要模块 | 默认路由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 资产 BI | 资产管理、里程管理、车辆 / 加氢热力图、智能调度 | `/asset` |
|
||||||
|
| 能源 BI | 氢能、电能、ETC | `/energy` |
|
||||||
|
|
||||||
|
应用还保留两个不在主导航中展示的后台入口:
|
||||||
|
|
||||||
|
| 隐藏入口 | 用途 | 路由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 充电记录导入 | 上传并管理电费 XLSX 数据 | `/ele/import` |
|
||||||
|
| 用户反馈管理 | 查看、回复和推进用户反馈 | `/admin/feedback` |
|
||||||
|
|
||||||
|
页面内部主要使用 Hash 保存模块和子页面状态,例如 `/energy#hydrogen/overview`。入口兼容逻辑位于 `src/app/routing.ts`。
|
||||||
|
|
||||||
|
### 2.2 总体架构
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[羚牛业务系统] -->|一次性 jumpToken| B[LN-BI React 前端]
|
||||||
|
B -->|/api/auth/exchange| C[Hono API]
|
||||||
|
C -->|校验 jumpToken / 获取用户角色| A
|
||||||
|
C -->|签发 8 小时本地 JWT| B
|
||||||
|
B -->|Bearer JWT| C
|
||||||
|
|
||||||
|
C --> D[(主业务 MySQL / 跨 Schema)]
|
||||||
|
C --> E[(氢能 MySQL 连接\n默认复用主库)]
|
||||||
|
C --> F[(车辆位置 PostgreSQL)]
|
||||||
|
C --> G[OneOS 里程 API]
|
||||||
|
C --> H[阿里云 OSS]
|
||||||
|
B --> I[高德地图 JS API]
|
||||||
|
```
|
||||||
|
|
||||||
|
生产环境由一个 Node.js 进程同时提供 REST API 和 Vite 构建后的静态页面。开发环境中,Vite 前端和 Hono 后端分别运行,由 Vite 将 `/api` 代理到后端。
|
||||||
|
|
||||||
|
### 2.3 关键目录
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/App.tsx` | 应用入口、认证门禁、主路由和隐藏页面装配 |
|
||||||
|
| `src/app/modules.ts` | 资产 / 能源导航模块注册与角色可见性 |
|
||||||
|
| `src/auth/` | 前端认证状态、JWT 注入、未授权页面 |
|
||||||
|
| `src/shared/auth/` | 前后端共用角色常量和模块访问判断 |
|
||||||
|
| `src/server/` | Hono 服务、认证中间件、数据库连接、API 路由和后台任务 |
|
||||||
|
| `src/modules/` | 各 BI 页面和交互实现 |
|
||||||
|
| `src/modules/energy/hydrogen-bi-v2/` | 当前氢能 BI 原型迁入版、真实数据适配器和下钻实现 |
|
||||||
|
| `docs/` | 接口、重构和交接文档 |
|
||||||
|
| `Dockerfile` | 前端构建 + Node 运行时镜像 |
|
||||||
|
| `docker-compose.yml` | 当前容器部署参数样例 |
|
||||||
|
| `woodpecker.yml` | Woodpecker CI 构建、测试、镜像推送流程 |
|
||||||
|
|
||||||
|
### 2.4 当前质量基线
|
||||||
|
|
||||||
|
2026-08-21 在当前提交和本地依赖环境完成以下验证:
|
||||||
|
|
||||||
|
| 检查 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm run lint` | 通过 |
|
||||||
|
| `npm test` | 113 项通过,0 项失败 |
|
||||||
|
| `npm run build` | 通过,Vite 成功生成生产构建 |
|
||||||
|
|
||||||
|
测试和构建会输出 Node.js `DEP0205`(`module.register()` 已弃用)警告,目前不阻断运行;后续升级 `tsx` / Node 工具链时应处理。
|
||||||
|
|
||||||
|
## 3. 登录认证方式
|
||||||
|
|
||||||
|
### 3.1 正常登录链路
|
||||||
|
|
||||||
|
项目自身没有用户名 / 密码登录表单,正常入口依赖羚牛业务系统单点跳转:
|
||||||
|
|
||||||
|
1. 用户先登录羚牛业务系统。
|
||||||
|
2. 业务系统跳转到 LN-BI,并在 URL 中携带一次性 `jumpToken`。
|
||||||
|
3. 前端调用 `GET /api/auth/exchange?jumpToken=...`。
|
||||||
|
4. LN-BI 后端调用业务系统的 `issueTokenByJump` 接口校验一次性令牌并取得用户、部门编码和角色。
|
||||||
|
5. 后端根据角色计算数据权限级别,并通过主业务库 `tab_department` 补全部门名称。
|
||||||
|
6. 后端使用 `JWT_SECRET` 签发有效期 8 小时的 LN-BI JWT。
|
||||||
|
7. 前端将 JWT 和用户信息保存到当前标签页的 `sessionStorage`:
|
||||||
|
- `bi_jwt`
|
||||||
|
- `bi_user`
|
||||||
|
8. 前端从地址栏移除 `jumpToken`,之后所有受保护 API 均发送 `Authorization: Bearer <JWT>`。
|
||||||
|
9. API 返回 `401` 时,前端清空本地会话并显示“会话已过期”。
|
||||||
|
|
||||||
|
关键实现:
|
||||||
|
|
||||||
|
- 前端:`src/auth/AuthProvider.tsx`
|
||||||
|
- Token 注入:`src/auth/api-client.ts`
|
||||||
|
- Token 换取:`src/server/auth/login.ts`
|
||||||
|
- API 认证:`src/server/auth/middleware.ts`
|
||||||
|
|
||||||
|
### 3.2 公开接口与受保护接口
|
||||||
|
|
||||||
|
不要求 JWT 的接口:
|
||||||
|
|
||||||
|
- `/api/health`
|
||||||
|
- `/api/auth/*`
|
||||||
|
|
||||||
|
其余 `/api/*` 默认经过统一认证中间件。能源、智能调度、加氢热力图和反馈管理在统一认证之上还有模块级或接口级角色校验。
|
||||||
|
|
||||||
|
### 3.3 会话特征
|
||||||
|
|
||||||
|
| 项目 | 当前实现 |
|
||||||
|
| --- | --- |
|
||||||
|
| Token 类型 | HS256 JWT(`jsonwebtoken` 默认签名算法) |
|
||||||
|
| 有效期 | 8 小时 |
|
||||||
|
| 浏览器存储 | `sessionStorage`,关闭标签页后失效 |
|
||||||
|
| 用户来源 | 羚牛业务系统 jumpToken 换取 |
|
||||||
|
| 部门名称来源 | 主业务库 `tab_department` |
|
||||||
|
| 退出方式 | 当前无独立退出接口;清除标签页会话或收到 401 后清理 |
|
||||||
|
|
||||||
|
## 4. 角色鉴权与数据权限
|
||||||
|
|
||||||
|
权限分为两层,接手时必须分别理解:
|
||||||
|
|
||||||
|
1. **模块访问权限**:决定能否进入能源、智能调度或反馈后台。
|
||||||
|
2. **数据范围权限**:决定用户可查看全量、部门或个人负责的数据。
|
||||||
|
|
||||||
|
### 4.1 数据范围角色
|
||||||
|
|
||||||
|
| 业务系统角色 | JWT `permissionLevel` | 数据范围 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `所有权限`、`数智中心`、`BI-Leader` | `full` | 全量数据 |
|
||||||
|
| `BI-Leader-Dep` | `department` | `departmentName / department = user.depName` |
|
||||||
|
| 其他角色或无上述角色 | `personal` | `managerId = user.userId` |
|
||||||
|
|
||||||
|
数据过滤和客户名称脱敏由 `src/server/auth/permissions.ts` 提供。目前明确接入该过滤的主要范围包括:
|
||||||
|
|
||||||
|
- 资产车辆统计与列表;
|
||||||
|
- 里程实时监控;
|
||||||
|
- 里程考核车辆明细;
|
||||||
|
- 智能调度建议。
|
||||||
|
|
||||||
|
注意:数据权限不是数据库行级安全,而是路由读取数据后在服务端过滤。新增 API 时必须主动调用统一过滤逻辑或实现等价的 SQL 过滤,不能只依赖前端隐藏。
|
||||||
|
|
||||||
|
### 4.2 模块访问角色
|
||||||
|
|
||||||
|
| 模块 / 能力 | 允许角色 | 前端控制 | 后端控制 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 资产管理、里程管理、车辆热力图 | 已认证用户 | 导航默认展示 | 统一 JWT 中间件 |
|
||||||
|
| 能源 BI(氢能 / 电能 / ETC) | `BI-LEADER-ENERGY` 或 `所有权限` | `/energy` 门禁 | `/api/energy/*` 守卫 |
|
||||||
|
| 加氢热力图 | `BI-LEADER-ENERGY` 或 `所有权限` | 无权限时不出现在热力图子导航 | `/api/hydrogen-heatmap/*` 守卫 |
|
||||||
|
| 智能调度 | `BI-SCHEDULE-OPT` | 无角色时不显示模块 | `/api/scheduling/*` 守卫 |
|
||||||
|
| 反馈管理 | `BI-ADMIN-FEEDBACK` 或任一全量数据角色 | 隐藏页面,需直接访问 | 管理列表与更新接口校验 |
|
||||||
|
| 用户反馈提交 / 我的反馈 | 已认证用户 | 页面反馈入口 | 统一 JWT 中间件 |
|
||||||
|
|
||||||
|
角色常量和判断位于 `src/shared/auth/roles.ts`。
|
||||||
|
|
||||||
|
### 4.3 容易误解的权限边界
|
||||||
|
|
||||||
|
- `full` 只表示数据范围,不代表自动拥有所有模块权限。
|
||||||
|
- `数智中心`、`BI-Leader` 虽会获得全量数据,但当前不会自动进入能源模块;能源仅额外接受 `所有权限`。
|
||||||
|
- 智能调度当前仅接受 `BI-SCHEDULE-OPT`,不会因为拥有 `full` 数据权限自动放行。
|
||||||
|
- 前端隐藏不是安全措施;任何新增受限能力都必须增加后端守卫。
|
||||||
|
|
||||||
|
## 5. 本地免登录调试环境
|
||||||
|
|
||||||
|
### 5.1 环境要求
|
||||||
|
|
||||||
|
- Node.js 22(Docker 和 CI 均使用 Node 22);
|
||||||
|
- npm,依赖版本以 `package-lock.json` 为准;
|
||||||
|
- 能访问所需数据库和外部 API 的网络环境;
|
||||||
|
- 本地配置文件 `.env`。该文件已被 `.gitignore` 忽略,禁止提交。
|
||||||
|
|
||||||
|
### 5.2 最小启动步骤
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后:
|
||||||
|
|
||||||
|
| 服务 | 地址 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Vite 前端 | `http://localhost:3000` | 对局域网开放,`/api` 代理到 3001 |
|
||||||
|
| Hono 后端 | `http://localhost:3001` | `SERVER_PORT` 未设置时的默认端口 |
|
||||||
|
| 健康检查 | `http://localhost:3001/api/health` | 只验证进程可访问,不验证数据库 |
|
||||||
|
|
||||||
|
### 5.3 免登录开关
|
||||||
|
|
||||||
|
本地前后端需要同时开启免登录:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
# 仅用于本地开发,生产环境严禁设置为 1
|
||||||
|
VITE_DEV_BYPASS_AUTH=1
|
||||||
|
DEV_BYPASS_AUTH=1
|
||||||
|
```
|
||||||
|
|
||||||
|
- `VITE_DEV_BYPASS_AUTH=1`:前端直接构造“本地开发”全权限用户,仅在 Vite dev 模式生效。
|
||||||
|
- `DEV_BYPASS_AUTH=1`:后端认证中间件注入本地开发用户,否则前端虽然进入页面,API 仍会返回 401。
|
||||||
|
- 本地用户包含 `所有权限`、`BI-SCHEDULE-OPT`、`BI-ADMIN-FEEDBACK`、`BI-LEADER-ENERGY`。
|
||||||
|
- 免登录只绕过认证,不会替代数据库、OneOS API、高德地图或 OSS 配置。
|
||||||
|
|
||||||
|
### 5.4 `.env` 配置模板
|
||||||
|
|
||||||
|
以下仅列变量名和占位符,不得把真实值写入本文或提交到 Git:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
# 认证
|
||||||
|
EXTERNAL_API_BASE=https://<业务系统域名>
|
||||||
|
JWT_SECRET=<高强度随机密钥>
|
||||||
|
DEV_BYPASS_AUTH=1
|
||||||
|
VITE_DEV_BYPASS_AUTH=1
|
||||||
|
|
||||||
|
# 主业务 MySQL
|
||||||
|
DB_HOST=<host>
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=<user>
|
||||||
|
DB_PASSWORD=<password>
|
||||||
|
DB_NAME=<database>
|
||||||
|
|
||||||
|
# 氢能 MySQL;不填时复用 DB_*
|
||||||
|
HYDROGEN_DB_HOST=<host>
|
||||||
|
HYDROGEN_DB_PORT=3306
|
||||||
|
HYDROGEN_DB_USER=<user>
|
||||||
|
HYDROGEN_DB_PASSWORD=<password>
|
||||||
|
HYDROGEN_DB_NAME=<database>
|
||||||
|
|
||||||
|
# 历史里程 MySQL 连接配置
|
||||||
|
# 当前 src/server/mileage-db.ts 未被运行时代码引用;不要误认为修改后会影响里程页面
|
||||||
|
MILEAGE_DB_HOST=<host>
|
||||||
|
MILEAGE_DB_PORT=3306
|
||||||
|
MILEAGE_DB_USER=<user>
|
||||||
|
MILEAGE_DB_PASSWORD=<password>
|
||||||
|
MILEAGE_DB_NAME=<database>
|
||||||
|
|
||||||
|
# 车辆位置 PostgreSQL
|
||||||
|
HEATMAP_DB_HOST=<host>
|
||||||
|
HEATMAP_DB_PORT=5432
|
||||||
|
HEATMAP_DB_USER=<read-only-user>
|
||||||
|
HEATMAP_DB_PASSWORD=<password>
|
||||||
|
HEATMAP_DB_NAME=<database>
|
||||||
|
HEATMAP_DB_SSL=false
|
||||||
|
|
||||||
|
# OneOS 里程 API
|
||||||
|
ONEOS_MILEAGE_API_BASE_URL=https://<api-host>
|
||||||
|
ONEOS_MILEAGE_API_KEY=<api-key>
|
||||||
|
ONEOS_MILEAGE_API_TIMEOUT_MS=20000
|
||||||
|
|
||||||
|
# 高德地图
|
||||||
|
AMAP_WEB_KEY=<web-key>
|
||||||
|
AMAP_SECURITY_JS_CODE=<security-code>
|
||||||
|
|
||||||
|
# 用户反馈截图 OSS
|
||||||
|
OSS_REGION=<region>
|
||||||
|
OSS_ENDPOINT=<endpoint>
|
||||||
|
OSS_BUCKET=<bucket>
|
||||||
|
OSS_ACCESS_KEY_ID=<access-key-id>
|
||||||
|
OSS_ACCESS_KEY_SECRET=<access-key-secret>
|
||||||
|
OSS_BASE_DIR=<directory>
|
||||||
|
|
||||||
|
# 运行参数
|
||||||
|
SERVER_PORT=3001
|
||||||
|
MILEAGE_REPORT_AUTO_ARCHIVE=0
|
||||||
|
```
|
||||||
|
|
||||||
|
本地调试建议将 `MILEAGE_REPORT_AUTO_ARCHIVE=0`,避免开发进程在 06:30 自动生成正式日报快照。
|
||||||
|
|
||||||
|
## 6. 技术栈
|
||||||
|
|
||||||
|
### 6.1 前端
|
||||||
|
|
||||||
|
| 分类 | 技术 |
|
||||||
|
| --- | --- |
|
||||||
|
| 语言 | TypeScript、TSX、CSS |
|
||||||
|
| 框架 | React 19 |
|
||||||
|
| 构建工具 | Vite 6 |
|
||||||
|
| 样式 | Tailwind CSS 4 + 模块专用 CSS |
|
||||||
|
| 图表 | Recharts 3;氢能原型包含自定义 DOM / CSS 图表 |
|
||||||
|
| 动效 | Motion for React |
|
||||||
|
| 图标 | Lucide React |
|
||||||
|
| Excel | SheetJS `xlsx` |
|
||||||
|
| 地图 | 高德地图 JS API |
|
||||||
|
| 路由 | 浏览器 Path + Hash 自研轻量路由,无 React Router |
|
||||||
|
|
||||||
|
### 6.2 后端
|
||||||
|
|
||||||
|
| 分类 | 技术 |
|
||||||
|
| --- | --- |
|
||||||
|
| 语言 / 运行时 | TypeScript、Node.js 22、ES Module |
|
||||||
|
| Web 框架 | Hono 4 + `@hono/node-server` |
|
||||||
|
| TypeScript 运行 | `tsx` |
|
||||||
|
| 认证 | `jsonwebtoken` |
|
||||||
|
| MySQL | `mysql2/promise` |
|
||||||
|
| PostgreSQL | `pg` |
|
||||||
|
| 对象存储 | `ali-oss` |
|
||||||
|
| 配置 | `dotenv` / 容器环境变量 |
|
||||||
|
| 测试 | Node.js 内置 Test Runner |
|
||||||
|
|
||||||
|
### 6.3 构建与部署
|
||||||
|
|
||||||
|
- `npm run lint`:TypeScript 静态检查;
|
||||||
|
- `npm test`:运行 `src/**/*.test.ts`;
|
||||||
|
- `npm run build`:生成 Vite `dist`;
|
||||||
|
- `npm run start`:Node 直接通过 `tsx` loader 启动服务;
|
||||||
|
- Docker:Node 22 Alpine 多阶段构建;
|
||||||
|
- CI:Woodpecker 执行安装、静态检查、测试、构建、Docker 镜像构建和 Harbor 推送;
|
||||||
|
- 镜像标签:`<分支名>-<package.json version>`;
|
||||||
|
- 当前 Compose 使用 host 网络,容器服务端口由 `SERVER_PORT` 注入,现有部署样例为 `8111`。
|
||||||
|
|
||||||
|
## 7. 数据对接与数据源映射
|
||||||
|
|
||||||
|
### 7.1 运行时数据源总表
|
||||||
|
|
||||||
|
| 数据源 / 对接方 | 连接方式 | 配置入口 | 主要数据 | 消费模块 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 羚牛业务系统认证服务 | HTTPS API | `EXTERNAL_API_BASE` | jumpToken、用户、部门编码、角色 | 登录认证 |
|
||||||
|
| 主业务 MySQL | MySQL | `DB_*` | 车辆、合同、客户、部门、资产流转、电费、ETC、反馈、调度记录等 | 资产、里程关联信息、调度、电能、ETC、反馈 |
|
||||||
|
| 氢能业务库 | MySQL | `HYDROGEN_DB_*`;未配置则复用 `DB_*` | 加氢流水、加氢站、付款 / 结算、站点余额等 | 氢能 BI、单站、按日、下钻 |
|
||||||
|
| 里程考核数据 | 主业务 MySQL / 跨 Schema 查询 | 当前实际使用 `DB_*` | 考核目标、考核车辆、日报快照 | 里程统计、日报、智能调度 |
|
||||||
|
| OneOS 里程服务 | HTTPS API | `ONEOS_MILEAGE_API_*` | 按日 / 区间车辆里程、来源协议 | 里程监控、日报、调度 |
|
||||||
|
| 车辆位置分析库 | PostgreSQL 只读 | `HEATMAP_DB_*` | 每车每日首个有效定位点 | 车辆热力图 |
|
||||||
|
| 高德地图 | 浏览器 JS API | `AMAP_WEB_KEY`、`AMAP_SECURITY_JS_CODE` | 地图底图、空间展示 | 车辆 / 加氢热力图 |
|
||||||
|
| 阿里云 OSS | OSS SDK | `OSS_*` | 用户反馈截图 | 反馈提交、反馈管理 |
|
||||||
|
| 本地地区映射 | JSON | `src/server/routes/mileage/region-map.json` | 城市到运营区域映射 | 里程筛选和区域聚合 |
|
||||||
|
|
||||||
|
`src/server/mileage-db.ts` 仍定义了一组 `MILEAGE_DB_*` MySQL 连接,但当前没有任何运行时代码导入该连接池。因此它属于历史遗留配置,不计入当前有效运行时数据源;接手人不应通过修改 `MILEAGE_DB_*` 来排查现有里程页面。
|
||||||
|
|
||||||
|
### 7.2 主业务 MySQL 的主要表 / Schema
|
||||||
|
|
||||||
|
当前部署样例中 `DB_NAME` 指向业务库,同时代码存在 `lingniu_prod.*` 跨 Schema 查询。数据库账号必须具备实际所需 Schema 的最小权限。
|
||||||
|
|
||||||
|
| 数据域 | 代表表 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 车辆主数据 | `vehicle_info`、`vehicle_status`、`vehicle_model` | 资产、车辆归属、车型、运营状态 |
|
||||||
|
| 租赁 / 客户 | `vehicle_lease_order_record`、`vehicle_lease_contract_info`、`customer_info` | 客户、部门、经理、项目 |
|
||||||
|
| 资产流转 | `delivery_vehicle`、`return_vehicle_task`、`vehicle_replacement` | 交车、退车、换车周统计及明细 |
|
||||||
|
| 实时车辆 | `tab_truck_remote_sync_realtime_info` | 当日里程、车辆省份 |
|
||||||
|
| 里程考核 | `lingniu_prod.tab_mileage_assessment_target`、`lingniu_prod.tab_mileage_assessment_vehicle` | 目标、车辆、完成率 |
|
||||||
|
| 里程日报 | `lingniu_prod.tab_mileage_daily_report` | 每日归档快照 |
|
||||||
|
| 电费 | `bi_ele_charge_record` | XLSX 导入后的充电记录 |
|
||||||
|
| ETC | `etc_toll_record`、`energy_etc_bill` | 通行明细、应收和已收 |
|
||||||
|
| 调度 | `tab_scheduling_notifications` | 调度干预、执行状态和历史 |
|
||||||
|
| 用户反馈 | `bi_user_feedback` | 反馈、截图地址、回复和状态 |
|
||||||
|
|
||||||
|
### 7.3 氢能 BI 主要数据表
|
||||||
|
|
||||||
|
| 表 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `hydrogen_fuel_ledger` | 加氢主流水,统计加氢量、成本、对客金额、车辆、客户、核对状态和数据来源 |
|
||||||
|
| `hydrogen_station` | 历史加氢站主数据 |
|
||||||
|
| `new_hydrogen_site` | 当前业务站点主数据及省市区等属性 |
|
||||||
|
| `hydrogen_station_payment` | 加氢站付款 / 结算记录 |
|
||||||
|
| `new_hydrogen_site_balance_record` | 站点预充值余额记录 |
|
||||||
|
| `tab_outside_hydrogen_site` | 外部站点与内部站点、坐标的映射,用于热力图 |
|
||||||
|
| `common_district` | 行政区划名称映射 |
|
||||||
|
|
||||||
|
氢能 V2 API 统一提供总览、按日树、下钻等视图。旧 `/hydrogen/*` 接口仍保留用于兼容和回滚,但当前氢能页面入口只装配 `hydrogen-bi-v2/PrototypeBoard.tsx`。
|
||||||
|
|
||||||
|
### 7.4 数据口径注意事项
|
||||||
|
|
||||||
|
- 氢能“车辆归属”和“成本承担方”是两个独立维度,不得互相替代。
|
||||||
|
- 氢能成本承担方按业务字段归纳为“我司承担、客户承担、其他”;利润口径是客户承担订单的“对客总价 - 等量订单成本总价”。
|
||||||
|
- 电能导入按 `order_no` 去重,并根据车辆信息匹配内部 / 外部 / 未知车辆。
|
||||||
|
- ETC 金额、应收和已收直接读取台账,不做推算;没有数据时前端展示空状态。
|
||||||
|
- 里程按自然日和上海时区处理;OneOS API 支持单日和区间查询,并按协议优先级归一化来源。
|
||||||
|
- 客户名称会根据数据权限在服务端进行脱敏。
|
||||||
|
|
||||||
|
## 8. 当前功能清单
|
||||||
|
|
||||||
|
### 8.1 资产管理 BI
|
||||||
|
|
||||||
|
| 功能域 | 当前能力 |
|
||||||
|
| --- | --- |
|
||||||
|
| 资产总览 | 总资产、运营、库存、待交付、周交车 / 退车 / 换车 |
|
||||||
|
| 车型分析 | 按车辆类型、车型、批次逐级统计和展开 |
|
||||||
|
| 部门分析 | 按部门 / 经理统计、展开车辆明细 |
|
||||||
|
| 区域分析 | 按大区、省市、客户筛选与统计 |
|
||||||
|
| 客户分析 | 客户多选、品牌、部门、经理、区域筛选 |
|
||||||
|
| 库存分析 | 按区域 / 车型切换,支持多级展开和筛选 |
|
||||||
|
| 资产流转 | 自定义日期范围,交车 / 退车 / 换车趋势与明细下钻 |
|
||||||
|
| 车辆明细 | 车牌、车型、批次、客户、状态、位置等筛选和弹窗 |
|
||||||
|
| 导出 | 资产相关列表和明细 Excel 导出 |
|
||||||
|
| 自动刷新 | 主页面数据每 60 秒刷新 |
|
||||||
|
|
||||||
|
### 8.2 里程管理 BI
|
||||||
|
|
||||||
|
| 子页面 | 当前能力 |
|
||||||
|
| --- | --- |
|
||||||
|
| 实时监控 | 当日 / 指定日 / 区间里程,来源协议优先级、部门、客户、项目、主体、状态、区域、品牌、里程区间等筛选 |
|
||||||
|
| 实时监控 | 当日 / 累计 / 统计时间排序,异常和在线状态识别,车辆详情 |
|
||||||
|
| 统计报表 | 考核目标、目标车辆、累计完成率、当年完成率、日均要求、趋势下钻 |
|
||||||
|
| 每日汇报 | 运营 / 库存车辆、当日里程、环比、车型与地区分组、近 7 日趋势、历史快照 |
|
||||||
|
| 导出 | 监控区间和车辆汇总 Excel 导出 |
|
||||||
|
| 缓存 | 服务启动立即刷新,之后每分钟刷新实时监控缓存 |
|
||||||
|
| 自动归档 | 上海时间每天 06:30 归档上一自然日日报,可通过环境变量关闭 |
|
||||||
|
|
||||||
|
### 8.3 智能调度
|
||||||
|
|
||||||
|
| 功能 | 当前能力 |
|
||||||
|
| --- | --- |
|
||||||
|
| 建议生成 | 根据考核里程、剩余天数、车辆状态和车型生成高低里程调度建议 |
|
||||||
|
| 筛选 | 按建议类型、部门、车型等条件筛选和搜索 |
|
||||||
|
| 详情 | 查看当前车辆、候选车辆及差距 |
|
||||||
|
| 操作 | 单条 / 批量登记调度干预 |
|
||||||
|
| 历史 | 通知 / 干预记录查询、状态更新、取消或完成 |
|
||||||
|
| 导出 | 建议列表 CSV 导出 |
|
||||||
|
|
||||||
|
### 8.4 车辆热力图
|
||||||
|
|
||||||
|
- 按日期范围、车牌 / VIN、考核批次筛选;
|
||||||
|
- 定位活跃度 / 车辆覆盖度切换;
|
||||||
|
- 网格聚合、全国概览、区域下钻和附近车辆;
|
||||||
|
- 地图和详情面板可独立展开,支持全屏;
|
||||||
|
- PostgreSQL 连接配置为只读事务默认值。
|
||||||
|
|
||||||
|
### 8.5 加氢热力图
|
||||||
|
|
||||||
|
- 按日期、站点搜索、承担维度筛选;
|
||||||
|
- 加氢量、加氢频次、车辆覆盖三种热力指标;
|
||||||
|
- 加氢站排名、区域下钻、附近站点和 GPS 覆盖提示;
|
||||||
|
- 仅对有有效坐标的数据进行地图聚合;
|
||||||
|
- 受能源角色控制。
|
||||||
|
|
||||||
|
### 8.6 氢能经营 BI(当前 V2 页面)
|
||||||
|
|
||||||
|
| 页面 / 维度 | 当前能力 |
|
||||||
|
| --- | --- |
|
||||||
|
| 全局总览 | 年份、核对状态、车辆归属筛选;累计加氢量、累计成本、利润、本月、本日 KPI |
|
||||||
|
| 趋势 | 月度加氢量、月度收支、站点 Top5、区域省 / 市占比 |
|
||||||
|
| 汇总 | 加氢站汇总、客户账单汇总、列表折叠、排序和筛选 |
|
||||||
|
| KPI 下钻 | 支持先站点后客户或先客户后站点,再到车辆 / 流水 |
|
||||||
|
| 图表下钻 | 月份、站点、区域、客户等上下文穿透 |
|
||||||
|
| 账单下钻 | 客户 → 日期 → 车辆流水;站点 → 日期 → 客户 / 流水 |
|
||||||
|
| 按日页面 | 日期范围、车辆归属、核对状态;日期 → 站点 → 客户 → 车辆 / 数据源多层展开 |
|
||||||
|
| 单站页面 | 站点日期筛选、日汇总、趋势、流水和导出 |
|
||||||
|
| 导出 | KPI 穿透、客户账单、站点账单、每日明细 Excel |
|
||||||
|
| 接口 | `/api/energy/h2/v2/meta`、`overview`、`daily`、`daily-tree`、`drill` |
|
||||||
|
|
||||||
|
说明:原型源码中仍有演示常量和兼容组件;真实运行数据应以 `prototype-adapter.ts`、`prototype-real-daily.tsx`、`prototype-real-drills.tsx` 和 V2 API 返回为准。修改页面时必须同时检查 Web 与移动端,不能只以测试通过代替原型截图验收。
|
||||||
|
|
||||||
|
### 8.7 电能经营 BI
|
||||||
|
|
||||||
|
| 页面 / 能力 | 当前实现 |
|
||||||
|
| --- | --- |
|
||||||
|
| 按日 | 日充电量、费用、订单、内部 / 外部车辆和趋势 |
|
||||||
|
| 总览 | 汇总 KPI、月份趋势、车辆归属统计 |
|
||||||
|
| 数据导入 | 隐藏入口上传 `.xlsx`,按订单号去重、批次管理、记录搜索和归属汇总 |
|
||||||
|
| 数据表 | `bi_ele_charge_record`,接口首次调用时自动确保建表 |
|
||||||
|
|
||||||
|
### 8.8 ETC 看板
|
||||||
|
|
||||||
|
- 通行明细笔数;
|
||||||
|
- 涉及车辆去重数;
|
||||||
|
- 通行费金额;
|
||||||
|
- ETC 账单数、应收和已收;
|
||||||
|
- 最新通行时间;
|
||||||
|
- 无台账时展示“暂无数据”,不生成模拟数据。
|
||||||
|
|
||||||
|
### 8.9 用户反馈闭环
|
||||||
|
|
||||||
|
- 普通用户提交新维度、Bug、体验或其他反馈;
|
||||||
|
- 支持最多 6 张截图,单张最大 5 MB,上传至 OSS;
|
||||||
|
- 用户查看自己的反馈历史;
|
||||||
|
- 管理员按状态查看、回复和更新为待处理 / 处理中 / 已完成 / 已忽略;
|
||||||
|
- 反馈表由接口首次调用时自动创建并兼容补列。
|
||||||
|
|
||||||
|
## 9. API 路由概览
|
||||||
|
|
||||||
|
| 路由前缀 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `/api/auth` | jumpToken 换 JWT、查看当前 JWT 用户 |
|
||||||
|
| `/api/vehicles` | 资产总览、车型 / 部门 / 区域 / 客户 / 库存、流转、车辆明细 |
|
||||||
|
| `/api/mileage` | 实时监控、目标、趋势、车辆近期里程、日报及历史 |
|
||||||
|
| `/api/scheduling` | 调度建议、通知和执行记录 |
|
||||||
|
| `/api/energy` | 氢能 V2 / 兼容接口、电能经营、ETC |
|
||||||
|
| `/api/ele` | 电费 XLSX 导入、列表、批次、聚合 |
|
||||||
|
| `/api/vehicle-heatmap` | 车辆位置热力图配置、元数据、点位和附近车辆 |
|
||||||
|
| `/api/hydrogen-heatmap` | 加氢热力图配置、元数据、点位和附近站点 |
|
||||||
|
| `/api/feedback` | 反馈提交、截图上传、我的反馈、管理列表和更新 |
|
||||||
|
|
||||||
|
## 10. 后台任务与自动建表
|
||||||
|
|
||||||
|
### 10.1 服务启动动作
|
||||||
|
|
||||||
|
`src/server/bootstrap.ts` 在正式服务进程启动时执行:
|
||||||
|
|
||||||
|
1. 确保 `tab_scheduling_notifications` 存在;
|
||||||
|
2. 立即刷新里程监控缓存;
|
||||||
|
3. 每 60 秒刷新里程监控缓存;
|
||||||
|
4. 启动里程日报 06:30 自动归档调度器。
|
||||||
|
|
||||||
|
### 10.2 按需自动建表
|
||||||
|
|
||||||
|
以下接口会在首次使用时执行 `CREATE TABLE IF NOT EXISTS`:
|
||||||
|
|
||||||
|
- 电费:`bi_ele_charge_record`;
|
||||||
|
- 用户反馈:`bi_user_feedback`;
|
||||||
|
- 里程日报:`lingniu_prod.tab_mileage_daily_report`;
|
||||||
|
- 智能调度:`tab_scheduling_notifications`(服务启动时)。
|
||||||
|
|
||||||
|
生产数据库账号若严格只读,这些功能会启动或调用失败。交接时应明确“读数据账号”和“应用写表账号”的权限边界。
|
||||||
|
|
||||||
|
## 11. 开发、测试和发布流程
|
||||||
|
|
||||||
|
### 11.1 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装锁定依赖
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
# 前后端开发模式
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 仅后端 / 仅前端
|
||||||
|
npm run dev:server
|
||||||
|
npm run dev:client
|
||||||
|
|
||||||
|
# 质量检查
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 生产式本地启动(需先 build)
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 发版步骤
|
||||||
|
|
||||||
|
1. 确认工作区只包含本次变更,排除 `.DS_Store`、临时脚本和本地数据文件。
|
||||||
|
2. 执行 `npm run lint && npm test && npm run build`。
|
||||||
|
3. 按语义版本更新 `package.json` 和 `package-lock.json`。
|
||||||
|
4. 提交并推送到目标分支。
|
||||||
|
5. Woodpecker 根据分支和版本生成镜像标签并推送 Harbor。
|
||||||
|
6. 在 Portainer / Compose 中更新镜像版本,保留原镜像标签用于回滚。
|
||||||
|
7. 验证健康检查、登录跳转、关键 API、Web 页面和移动端页面。
|
||||||
|
|
||||||
|
版本只在 Git 中更新不代表已部署;必须分别确认:
|
||||||
|
|
||||||
|
- 代码已推送;
|
||||||
|
- CI 已通过;
|
||||||
|
- 镜像已推送;
|
||||||
|
- 部署已更新;
|
||||||
|
- 页面和数据已验收。
|
||||||
|
|
||||||
|
## 12. 日志与排障入口
|
||||||
|
|
||||||
|
| 现象 | 优先检查 |
|
||||||
|
| --- | --- |
|
||||||
|
| 无法从业务系统登录 | 浏览器 URL 是否有 `jumpToken`;`/api/auth/exchange` 响应;`EXTERNAL_API_BASE`;后端认证日志 |
|
||||||
|
| 页面能进但 API 401 | 前后端免登录是否同时开启;`bi_jwt` 是否存在;JWT_SECRET 是否一致 |
|
||||||
|
| 页面 403 | 业务系统角色、JWT 中 `roles`、模块白名单,不要只看 `permissionLevel` |
|
||||||
|
| 健康检查正常但页面无数据 | `/api/health` 不检查数据库;继续检查具体 API、数据库连接和 SQL 权限 |
|
||||||
|
| 里程无数据 | OneOS API 地址 / Key、网络、traceId、协议兼容降级日志、主库车辆关联信息 |
|
||||||
|
| 热力图无底图 | 高德 Key 和安全码、域名白名单、浏览器控制台 |
|
||||||
|
| 车辆热力图无点位 | PostgreSQL 连接、只读权限、日期范围、`is_heatmap_eligible` |
|
||||||
|
| 氢能数据慢 | V2 API 查询耗时、筛选范围、数据库索引、是否误走兼容接口 |
|
||||||
|
| 反馈图片失败 | OSS 配置、Bucket 权限、文件类型和 5 MB 限制 |
|
||||||
|
| 日报未归档 | `MILEAGE_REPORT_AUTO_ARCHIVE`、容器时区、06:30 日志、日报表写权限 |
|
||||||
|
|
||||||
|
## 13. 已知风险与优先整改项
|
||||||
|
|
||||||
|
### P0:凭据管理
|
||||||
|
|
||||||
|
1. 当前仓库的部署配置和部分数据库连接代码存在硬编码凭据或默认口令。交接后应立即:
|
||||||
|
- 将数据库、JWT、Harbor、OSS、API Key 全部迁移到 CI Secret / Portainer Secret / 环境变量;
|
||||||
|
- 删除代码和配置中的真实 fallback;
|
||||||
|
- 对已经进入 Git 历史的凭据执行轮换;
|
||||||
|
- 不要只删除当前文件内容而忽略历史提交。
|
||||||
|
2. `Dockerfile` 和认证代码存在可预测的 JWT 默认密钥。生产必须显式注入随机 `JWT_SECRET`,并在缺失时让应用拒绝启动。
|
||||||
|
|
||||||
|
### P1:认证与权限
|
||||||
|
|
||||||
|
1. 前端当前用公开的 `/api/health` 判断缓存 JWT 是否有效,因此该请求并未真正校验 Token;过期 Token 会在第一次访问受保护 API 时才被清理。应改为调用 `/api/auth/me`。
|
||||||
|
2. `jumpToken` 通过查询参数传递,可能进入代理访问日志;业务条件允许时应改为 POST body,并限制日志记录。
|
||||||
|
3. `cors()` 当前未限制来源;生产应配置允许域名白名单。
|
||||||
|
4. `full` 数据角色与模块角色不是同一套白名单,容易产生“能看全量数据但进不了模块”的误解。变更角色策略前应由产品 / 管理员确认。
|
||||||
|
5. 隐藏页面不是授权控制。充电导入接口目前仅依赖“已登录”,若属于管理能力,应补独立角色和后端守卫。
|
||||||
|
|
||||||
|
### P1:数据与运行稳定性
|
||||||
|
|
||||||
|
1. 主库、跨 Schema 查询和专用数据库连接并存,部署账号权限和 Schema 默认值需形成正式清单。
|
||||||
|
2. `src/server/mileage-db.ts` 是当前未被引用的历史连接文件,`docker-compose.yml` 仍保留相应变量,容易误导排障;确认无外部依赖后应删除或重新接入。
|
||||||
|
3. 应用启动和部分接口会自动建表;数据库权限收紧前需先迁移为正式数据库变更脚本。
|
||||||
|
4. 氢能兼容接口和 V2 接口同时存在,新增功能应明确修改当前 V2 路径,避免只修旧接口。
|
||||||
|
5. 氢能原型目录仍包含演示常量;新增展示必须确认数据来自真实 API,禁止把演示数据带入经营口径。
|
||||||
|
6. 车辆和加氢热力图的默认日期目前为代码常量,长期运行应改为根据数据水位或当前日期计算。
|
||||||
|
|
||||||
|
### P2:工程治理
|
||||||
|
|
||||||
|
1. 仓库缺少统一根 README,本交接文档可作为后续 README 的基础。
|
||||||
|
2. 本地 `.DS_Store` 容易进入工作区,建议补充全局 / 项目忽略规则。
|
||||||
|
3. 目前使用 Path + Hash 自研路由;新增页面时必须同时验证直达、刷新、前进后退和移动端底栏状态。
|
||||||
|
4. 氢能页面属于严格原型复刻范围,CSS / DOM 调整必须做桌面与移动端截图对比验收。
|
||||||
|
|
||||||
|
## 14. 交接资料与权限清单
|
||||||
|
|
||||||
|
以下内容不应写入 Git,需要由原负责人通过安全渠道单独移交:
|
||||||
|
|
||||||
|
- Gitea 项目成员权限及分支保护规则;
|
||||||
|
- Woodpecker 项目、流水线和 Secret 管理权限;
|
||||||
|
- Harbor 项目及镜像拉取 / 推送账号;
|
||||||
|
- Portainer / ECS / Docker 服务管理权限;
|
||||||
|
- 主业务、氢能、里程和位置数据库的只读 / 读写账号;
|
||||||
|
- OneOS 里程 API 地址、API Key、调用方白名单和联系人;
|
||||||
|
- 羚牛业务系统认证接口联系人和 jumpToken 协议;
|
||||||
|
- 高德地图 Key、安全码及域名白名单;
|
||||||
|
- OSS Bucket、RAM 用户和目录权限;
|
||||||
|
- 生产域名、反向代理、证书和 DNS 管理权限;
|
||||||
|
- 数据口径负责人:资产、里程、调度、氢能、电能、ETC 各一名。
|
||||||
|
|
||||||
|
## 15. 接手验收清单
|
||||||
|
|
||||||
|
### 15.1 代码与环境
|
||||||
|
|
||||||
|
- [ ] 能拉取 `main` 并确认目标版本;
|
||||||
|
- [ ] Node.js 22、`npm ci`、lint、test、build 均通过;
|
||||||
|
- [ ] 已获得不含明文传播的本地 / 测试环境变量;
|
||||||
|
- [ ] 本地前后端和免登录调试正常;
|
||||||
|
- [ ] `.env`、数据库导出、截图和临时文件不会进入 Git。
|
||||||
|
|
||||||
|
### 15.2 认证与权限
|
||||||
|
|
||||||
|
- [ ] 从业务系统 jumpToken 跳转登录成功;
|
||||||
|
- [ ] 普通、部门、全量三类数据权限分别验收;
|
||||||
|
- [ ] 能源、智能调度、反馈管理员角色分别验收;
|
||||||
|
- [ ] 无权限用户前端不可见且后端返回 403;
|
||||||
|
- [ ] Token 过期后能正确退出并重新登录。
|
||||||
|
|
||||||
|
### 15.3 数据与功能
|
||||||
|
|
||||||
|
- [ ] 资产、里程、调度、氢能、电能、ETC 的主要 API 都能访问真实数据;
|
||||||
|
- [ ] BI 汇总值与最小粒度明细守恒;
|
||||||
|
- [ ] 氢能成本承担方、对客金额、成本金额和利润口径已由业务负责人确认;
|
||||||
|
- [ ] 里程 OneOS API 的单日、区间和来源协议正常;
|
||||||
|
- [ ] 车辆 / 加氢热力图可加载地图、点位和详情;
|
||||||
|
- [ ] Excel / CSV 导入导出可用;
|
||||||
|
- [ ] 用户反馈和截图上传可用;
|
||||||
|
- [ ] 06:30 日报归档在测试环境完成一次验证。
|
||||||
|
|
||||||
|
### 15.4 发布与回滚
|
||||||
|
|
||||||
|
- [ ] Woodpecker 流水线通过;
|
||||||
|
- [ ] Harbor 中存在对应版本镜像;
|
||||||
|
- [ ] Portainer / Compose 使用目标镜像标签和正确 Secret;
|
||||||
|
- [ ] 部署后验证健康检查、登录、API、Web 和移动端;
|
||||||
|
- [ ] 记录上一稳定镜像标签、数据库变更和回滚步骤。
|
||||||
|
|
||||||
|
## 16. 维护原则
|
||||||
|
|
||||||
|
1. **先确认真实数据口径,再改页面**:经营指标必须能从汇总下钻到真实流水。
|
||||||
|
2. **前端隐藏不代替后端鉴权**:任何管理或敏感能力都要有 API 守卫。
|
||||||
|
3. **配置与凭据分离**:代码只保留变量名和安全默认行为,不保留真实密码。
|
||||||
|
4. **原型页面以截图验收**:氢能模块必须对照原型验证布局、样式、交互、下钻层级和移动端。
|
||||||
|
5. **变更必须可回滚**:发版保留旧镜像,数据库变更先备份、再迁移、再核对。
|
||||||
|
6. **区分可达与可用**:健康检查正常不代表数据库、外部 API 和经营数据正常。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 本地只读预览
|
||||||
|
|
||||||
|
使用 Node.js 22+ 和 npm,在本项目内执行 `npm ci`,避免依赖其他工作目录的 node_modules 软链接。
|
||||||
|
|
||||||
|
将 `.env.local.example` 复制为 `.env.local` 并填写连接配置,或通过进程环境变量提供配置。使用数据库侧只读账号;不要提交真实凭据。
|
||||||
|
|
||||||
|
执行 `npm run dev:local`,浏览器访问 http://127.0.0.1:8115/energy#hydrogen 。前后端固定绑定本机 8115 / 3001;端口占用时退出,不自动换端口。停止终端进程即可停止服务;此命令不配置开机自启。
|
||||||
|
|
||||||
|
本地入口关闭 mock、启用本地免登录,禁用后台建表与定时归档,并拒绝 API 的 POST / PUT / PATCH / DELETE。请勿把免登录预览暴露到公网。氢能查询启用只读 SQL 检查;这不能替代数据库账号的只读权限。
|
||||||
|
|
||||||
|
`npm run dev` / `npm start` 保留原部署方式;设置 `DB_READ_ONLY=1` 时同样不启动后台任务,且 API 禁止写入。生产登录流程需要写请求,因此不要把本地只读模式当作生产部署配置。
|
||||||
|
|
||||||
|
验证:`npm test`、`npm run lint`、`npm run build`;健康检查为 `GET http://127.0.0.1:3001/api/health`。健康检查成功仅代表进程可用,还需检查氢能 meta / overview 的真实数据响应。
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# OneOS 车辆里程接口需求
|
||||||
|
|
||||||
|
当前协议选源联调阻塞项和 OneOS 修改清单见:
|
||||||
|
[OneOS 车辆里程数据源协议改造清单](./oneos-mileage-source-protocol-gaps.md)。
|
||||||
|
|
||||||
|
## 开发原则
|
||||||
|
|
||||||
|
1. 新增区间查询接口;车辆里程汇总能力在现有
|
||||||
|
`POST /api/v1/vehicles/mileage/query` 上兼容扩展。
|
||||||
|
2. 现有接口的路径、鉴权、已有请求参数、已有响应字段、字段语义和默认行为必须完全兼容。
|
||||||
|
3. 公网 `https://open.d.lnoneos.com` 与 ECS 内网 `http://172.17.111.55:20310` 使用相同路径、鉴权和数据口径。
|
||||||
|
4. 鉴权继续使用 `Authorization: Bearer APP_KEY`。
|
||||||
|
5. 日期采用 `YYYY-MM-DD`,时区统一为 `Asia/Shanghai`,里程单位统一为 km。
|
||||||
|
|
||||||
|
## 1. 车辆区间日里程批量查询
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/vehicles/mileage/range/query
|
||||||
|
```
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"startDate": "2026-07-01",
|
||||||
|
"endDate": "2026-07-23",
|
||||||
|
"plateNumbers": ["沪A00001", "沪A00002"],
|
||||||
|
"protocolPriority": ["GB32960", "MQTT", "JT808"],
|
||||||
|
"cursor": null,
|
||||||
|
"pageSize": 5000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- `startDate`、`endDate` 必填,最长支持 366 天;
|
||||||
|
- `plateNumbers` 可省略,省略时返回授权范围内全部车辆;
|
||||||
|
- `protocolPriority` 可省略;提供时只允许 `GB32960`、`MQTT`、`JT808`,
|
||||||
|
数组顺序表示逐车逐日的数据源优先级,未列出的协议视为禁用;
|
||||||
|
- BI 只会使用以下四种组合:
|
||||||
|
- 仪表数据优先:`["GB32960", "MQTT", "JT808"]`;
|
||||||
|
- GPS 数据优先:`["JT808", "GB32960", "MQTT"]`;
|
||||||
|
- 仅仪表数据:`["GB32960", "MQTT"]`;
|
||||||
|
- 仅 GPS 数据:`["JT808"]`;
|
||||||
|
- BI 页面默认只启用“仪表数据”,即默认发送
|
||||||
|
`["GB32960", "MQTT"]`;用户主动启用 GPS 后才发送包含 `JT808` 的组合;
|
||||||
|
- 仪表数据内部优先级固定为 `GB32960 > MQTT`,不提供反向切换;
|
||||||
|
- 数据量过大时使用 `cursor` 分页,同一次查询使用相同 `snapshotId`;
|
||||||
|
- 每辆车每天返回一条记录;
|
||||||
|
- 无数据返回 `status: "NO_DATA"` 且 `dailyMileageKm: null`,真实零里程返回 `status: "NORMAL"` 且 `dailyMileageKm: 0`。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"message": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"vin": "LMRK...",
|
||||||
|
"plateNumber": "沪A00001",
|
||||||
|
"date": "2026-07-01",
|
||||||
|
"dailyMileageKm": 182.437,
|
||||||
|
"sourceProtocol": "GB32960",
|
||||||
|
"dataTime": "2026-07-01T23:58:45+08:00",
|
||||||
|
"updatedAt": "2026-07-02T05:10:00+08:00",
|
||||||
|
"status": "NORMAL"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"snapshotId": "mileage-20260723-001",
|
||||||
|
"nextCursor": null,
|
||||||
|
"traceId": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 兼容扩展现有单日车辆里程接口
|
||||||
|
|
||||||
|
用于一次获取指定日期每辆车的日里程、累计里程和数据时间。
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/vehicles/mileage/query
|
||||||
|
```
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "2026-07-23",
|
||||||
|
"plateNumbers": ["沪A00001", "沪A00002"],
|
||||||
|
"protocolPriority": ["GB32960", "MQTT", "JT808"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- `date` 和 `plateNumbers` 沿用现有接口规则;
|
||||||
|
- `protocolPriority` 的枚举、顺序和禁用规则与区间接口完全一致;
|
||||||
|
- `plateNumbers` 省略时返回授权范围内全部车辆;
|
||||||
|
- 接口默认在现有车辆结果中返回 `totalMileageKm`、`dataTime` 和 `updatedAt`;
|
||||||
|
- 只增加响应字段,不删除或修改任何现有字段及其语义;
|
||||||
|
- 历史日期返回该自然日最终有效结果;
|
||||||
|
- 查询当天返回当前最新结果;
|
||||||
|
- `dataTime` 必须是本次统计实际采用的最后一条车辆源数据时间,不能使用接口请求时间或响应时间代替;
|
||||||
|
- 无数据时相关里程字段返回 `null`,不能使用 0 代替;
|
||||||
|
- 接口应支持当前应用授权范围内的全部车辆。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"message": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"vin": "LMRK...",
|
||||||
|
"plateNumber": "沪A00001",
|
||||||
|
"date": "2026-07-23",
|
||||||
|
"dailyMileageKm": 182.437,
|
||||||
|
"totalMileageKm": 12345.678,
|
||||||
|
"sourceProtocol": "GB32960",
|
||||||
|
"dataTime": "2026-07-23T10:35:42+08:00",
|
||||||
|
"updatedAt": "2026-07-23T10:35:46+08:00",
|
||||||
|
"status": "NORMAL"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"traceId": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
现有响应外层结构及已有字段保持不变,调用方无需增加任何请求参数。
|
||||||
|
|
||||||
|
## 通用要求
|
||||||
|
|
||||||
|
- `dataTime`、`updatedAt` 使用带 `+08:00` 的 ISO 8601 格式;
|
||||||
|
- `sourceProtocol` 必须返回本条结果实际采用的协议:
|
||||||
|
`GB32960`、`MQTT` 或 `JT808`;无数据时返回 `null`;
|
||||||
|
- OneOS 必须按 `protocolPriority` 选择第一份有效数据,不得返回已禁用协议的数据;
|
||||||
|
- `sourceProtocol=GB32960/MQTT` 在 BI 显示为“仪表数据”,
|
||||||
|
`sourceProtocol=JT808` 显示为“GPS数据”;
|
||||||
|
- 每个响应返回 `traceId`;
|
||||||
|
- 接口返回值保留原始精度;
|
||||||
|
- 负里程不得作为正常数据返回;
|
||||||
|
- 公网和 ECS 内网使用同一 AppKey 时,授权范围和查询结果保持一致;
|
||||||
|
- 区间接口及现有接口的汇总扩展不得影响现有调用方和默认行为。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
1. 现有 `/api/v1/vehicles/mileage/query` 请求方式保持不变,回归测试全部通过。
|
||||||
|
2. 区间接口不传车牌可完整查询全部授权车辆,分页无重复、无漏行。
|
||||||
|
3. 现有接口不传车牌时,默认返回全部授权车辆的日里程、累计里程和数据时间。
|
||||||
|
4. 正确区分无数据与真实零里程。
|
||||||
|
5. `dataTime` 能反映每辆车实际数据的新鲜度。
|
||||||
|
6. 四种 `protocolPriority` 组合均能按顺序选源,且响应中的
|
||||||
|
`sourceProtocol` 与实际采用协议一致。
|
||||||
|
7. 禁用某类协议后,响应不得回退到该协议。
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# OneOS 车辆里程数据源协议改造清单
|
||||||
|
|
||||||
|
## 0. 发布后复测结论
|
||||||
|
|
||||||
|
复测日期:2026-07-23
|
||||||
|
|
||||||
|
开放平台发布后,BI 已重新连接 `https://open.d.lnoneos.com` 并完成复测:
|
||||||
|
|
||||||
|
- 单日接口和区间接口均接受 `protocolPriority`,不再返回
|
||||||
|
`INVALID_REQUEST`;
|
||||||
|
- 成功响应已返回实际使用的 `sourceProtocol`;
|
||||||
|
- 仪表优先、GPS 优先、仅仪表、仅 GPS 四种模式均返回 HTTP 200;
|
||||||
|
- “仅仪表数据”结果未出现 `JT808`;
|
||||||
|
- “仅 GPS 数据”结果未出现 `GB32960` 或 `MQTT`;
|
||||||
|
- 同一车辆四天区间查询能够随优先级在 `GB32960` 和 `JT808`
|
||||||
|
之间正确切换;
|
||||||
|
- BI 页面已显示真实“仪表数据”或“GPS数据”,不再显示“来源待接口”。
|
||||||
|
|
||||||
|
复测时的单日协议分布如下,数据量会随当天车辆上报变化:
|
||||||
|
|
||||||
|
| BI 模式 | GB32960 | MQTT | JT808 | 无数据 | 禁用协议混入 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | --- |
|
||||||
|
| 仪表数据优先 | 387 | 49 | 134 | 454 | 无 |
|
||||||
|
| GPS 数据优先 | 69 | 30 | 471 | 454 | 无 |
|
||||||
|
| 仅仪表数据 | 387 | 49 | 0 | 588 | 无 |
|
||||||
|
| 仅 GPS 数据 | 0 | 0 | 471 | 553 | 无 |
|
||||||
|
|
||||||
|
结论:本清单中的协议选源阻塞项已关闭,当前接口满足 BI 对接要求。
|
||||||
|
以下内容保留为接口契约和后续回归验收依据。
|
||||||
|
|
||||||
|
## 1. 历史联调结论(发布前)
|
||||||
|
|
||||||
|
联调地址:`https://open.d.lnoneos.com`
|
||||||
|
|
||||||
|
涉及接口:
|
||||||
|
|
||||||
|
- `POST /api/v1/vehicles/mileage/query`
|
||||||
|
- `POST /api/v1/vehicles/mileage/range/query`
|
||||||
|
|
||||||
|
发布前接口不满足 BI 对车辆里程数据源切换和来源标注的要求,阻塞项如下:
|
||||||
|
|
||||||
|
| 序号 | 当前表现 | 期望表现 | 影响 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | 请求增加 `protocolPriority` 后返回 HTTP 400,业务码为 `INVALID_REQUEST` | 两个里程接口均接受可选字段 `protocolPriority` | BI 无法切换仪表数据和 GPS 数据的优先级,也无法单独禁用某类数据 |
|
||||||
|
| 2 | 成功响应中没有 `sourceProtocol` | 每辆车、每天的结果返回实际采用的协议 | BI 无法准确标注“仪表数据”或“GPS数据” |
|
||||||
|
| 3 | 无法验证接口是否排除了已禁用协议 | 未列入 `protocolPriority` 的协议不得参与选源 | “仅仪表数据”和“仅 GPS 数据”无法生效 |
|
||||||
|
| 4 | 无法验证仪表数据内部的降级顺序 | 仪表数据固定按 `GB32960 > MQTT` 选源 | 32960 缺失时无法确认是否正确降级到 MQTT |
|
||||||
|
|
||||||
|
BI 保留旧请求格式的兼容回退,用于开放平台异常或版本回滚时保证现有里程查询可用。回退结果不会伪造数据来源,页面会显示“来源待接口”。正常情况下新接口已不触发该回退。
|
||||||
|
|
||||||
|
## 2. OneOS 必须修改的请求字段
|
||||||
|
|
||||||
|
两个接口都需要增加以下可选字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"protocolPriority": ["GB32960", "MQTT", "JT808"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段规则:
|
||||||
|
|
||||||
|
1. 类型为非空字符串数组。
|
||||||
|
2. 仅允许 `GB32960`、`MQTT`、`JT808`,协议名区分以此处定义为准。
|
||||||
|
3. 数组顺序表示逐车、逐自然日的数据源优先级。
|
||||||
|
4. 未出现在数组中的协议视为禁用,不得参与选源或作为兜底数据返回。
|
||||||
|
5. `protocolPriority` 省略时保持现有接口的默认行为,确保旧调用方兼容。
|
||||||
|
6. 非法枚举、空数组或重复协议应返回 HTTP 400,并在响应中给出明确的错误信息和 `traceId`。
|
||||||
|
|
||||||
|
BI 只会发送以下四种组合:
|
||||||
|
|
||||||
|
| BI 模式 | `protocolPriority` | 选源规则 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 仪表数据优先 | `["GB32960", "MQTT", "JT808"]` | 先查 32960,再查 MQTT,最后查 JT808 |
|
||||||
|
| GPS 数据优先 | `["JT808", "GB32960", "MQTT"]` | 先查 JT808,再查 32960,最后查 MQTT |
|
||||||
|
| 仅仪表数据 | `["GB32960", "MQTT"]` | 只允许 32960 和 MQTT,禁止使用 JT808 |
|
||||||
|
| 仅 GPS 数据 | `["JT808"]` | 只允许 JT808,禁止使用 32960 和 MQTT |
|
||||||
|
|
||||||
|
BI 页面默认只启用“仪表数据”,即默认使用
|
||||||
|
`["GB32960", "MQTT"]`;GPS 数据需要用户主动启用。
|
||||||
|
|
||||||
|
仪表数据内部优先级固定为 `GB32960 > MQTT`,BI 不会发送 `MQTT > GB32960`。
|
||||||
|
|
||||||
|
## 3. OneOS 必须增加的响应字段
|
||||||
|
|
||||||
|
两个接口的每条车辆日里程结果都需要增加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sourceProtocol": "GB32960"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段规则:
|
||||||
|
|
||||||
|
1. 有有效里程数据时,只能返回 `GB32960`、`MQTT` 或 `JT808`。
|
||||||
|
2. 返回值必须是本条结果实际采用的协议,不能返回车辆支持的协议列表,也不能返回请求中的第一项作为固定值。
|
||||||
|
3. 无有效数据、`status="NO_DATA"` 时返回 `null`。
|
||||||
|
4. `sourceProtocol` 对应关系:
|
||||||
|
- `GB32960`、`MQTT`:BI 显示为“仪表数据”;
|
||||||
|
- `JT808`:BI 显示为“GPS数据”。
|
||||||
|
5. 如果 `sourceProtocol` 不在本次请求的 `protocolPriority` 中,应视为接口错误。
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "SUCCESS",
|
||||||
|
"message": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"vin": "LMRK...",
|
||||||
|
"plateNumber": "粤A00001F",
|
||||||
|
"date": "2026-07-23",
|
||||||
|
"dailyMileageKm": 182.437,
|
||||||
|
"totalMileageKm": 12345.678,
|
||||||
|
"sourceProtocol": "GB32960",
|
||||||
|
"dataTime": "2026-07-23T10:35:42+08:00",
|
||||||
|
"updatedAt": "2026-07-23T10:35:46+08:00",
|
||||||
|
"status": "NORMAL"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"traceId": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 逐车逐日选源规则
|
||||||
|
|
||||||
|
OneOS 需要针对每辆车、每个自然日独立执行以下逻辑:
|
||||||
|
|
||||||
|
1. 按 `protocolPriority` 从前到后检查数据源。
|
||||||
|
2. 找到第一份有效数据后立即使用,不再混用低优先级协议的数据。
|
||||||
|
3. 有效数据必须满足现有里程质量规则,包括里程非空、非负且数据状态有效。
|
||||||
|
4. 高优先级协议无有效数据时,才能降级到下一个已启用协议。
|
||||||
|
5. 所有已启用协议均无有效数据时,返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dailyMileageKm": null,
|
||||||
|
"sourceProtocol": null,
|
||||||
|
"status": "NO_DATA"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. 真实零里程必须返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dailyMileageKm": 0,
|
||||||
|
"sourceProtocol": "实际采用的协议",
|
||||||
|
"status": "NORMAL"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
区间查询必须逐日选源,不能先按一个协议汇总整个区间后再切换协议。
|
||||||
|
|
||||||
|
## 5. 验收用例
|
||||||
|
|
||||||
|
OneOS 修改完成后,至少提供一辆在同一天同时存在 32960、MQTT、JT808 数据的测试车辆,以及一辆只有 MQTT 数据的测试车辆。
|
||||||
|
|
||||||
|
| 用例 | 请求优先级 | 必须满足的结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 仪表优先 | `GB32960, MQTT, JT808` | 同时有三种数据时采用 GB32960,`sourceProtocol="GB32960"` |
|
||||||
|
| GPS 优先 | `JT808, GB32960, MQTT` | 同时有三种数据时采用 JT808,`sourceProtocol="JT808"` |
|
||||||
|
| 仅仪表 | `GB32960, MQTT` | 不得返回 JT808;32960 缺失时允许降级到 MQTT |
|
||||||
|
| 仅 GPS | `JT808` | 不得返回 GB32960 或 MQTT |
|
||||||
|
| 32960 降级 | `GB32960, MQTT` | 只有 MQTT 有效时返回 MQTT,`sourceProtocol="MQTT"` |
|
||||||
|
| 禁用验证 | `JT808` | 即使仪表数据存在,也不能回退到仪表数据 |
|
||||||
|
| 无数据 | 任一合法组合 | 返回 `dailyMileageKm=null`、`sourceProtocol=null`、`status="NO_DATA"` |
|
||||||
|
| 真实零里程 | 任一合法组合 | 返回 `dailyMileageKm=0`、实际 `sourceProtocol`、`status="NORMAL"` |
|
||||||
|
| 单日接口 | 四种组合分别测试 | 请求成功且选源、来源字段符合规则 |
|
||||||
|
| 区间接口 | 四种组合分别测试 | 每辆车每天独立选源,分页无重复、无漏行 |
|
||||||
|
| 兼容性 | 不传 `protocolPriority` | 原有调用方式和响应语义保持不变 |
|
||||||
|
|
||||||
|
## 6. 交付和联调要求
|
||||||
|
|
||||||
|
1. 公网 `https://open.d.lnoneos.com` 和 ECS 内网地址同时发布相同能力。
|
||||||
|
2. 发布后提供接口版本或发布日期、测试车辆、测试日期及对应协议数据情况。
|
||||||
|
3. 提供四种合法组合的请求和响应样例。
|
||||||
|
4. 提供一组禁用协议的验证结果,证明未启用协议不会被回退使用。
|
||||||
|
5. 每个成功和失败响应均返回 `traceId`,便于双方定位问题。
|
||||||
|
|
||||||
|
完成标准:两个接口均支持 `protocolPriority`,每条结果准确返回 `sourceProtocol`,四种 BI 模式全部通过上述验收用例。
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# LN-BI 渐进式重构方案
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
本轮只调整代码结构,不改变业务功能。以下内容视为冻结契约:
|
||||||
|
|
||||||
|
- 页面文案、布局、交互和 URL 行为
|
||||||
|
- API 路径、请求参数、状态码和响应字段
|
||||||
|
- SQL、数据源优先级、权限与脱敏顺序
|
||||||
|
- 里程、资产、能源和调度的统计口径
|
||||||
|
- 缓存刷新频率与日报归档时间
|
||||||
|
|
||||||
|
## 当前结构
|
||||||
|
|
||||||
|
项目已经按业务模块组织,但部分文件仍承担了过多职责:
|
||||||
|
|
||||||
|
| 热点 | 规模 | 主要职责混合 |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| `modules/assets/AssetsModule.tsx` | 3240 行 | 请求、53 组状态、筛选、图表、弹窗、导出和四个视图 |
|
||||||
|
| `modules/mileage/MonitoringView.tsx` | 1611 行 | 查询参数、44 组状态、列表、全屏、导出和统计 |
|
||||||
|
| `server/routes/vehicles.ts` | 1426 行 | SQL、缓存、权限、领域映射、聚合和 15 个接口 |
|
||||||
|
| `modules/mileage/DailyReportView.tsx` | 1192 行 | 日期、领域计算、图表、表格和页面编排 |
|
||||||
|
|
||||||
|
目标结构遵循同一方向:
|
||||||
|
|
||||||
|
```text
|
||||||
|
route / page
|
||||||
|
-> controller / hook # 流程与状态
|
||||||
|
-> service / model # 业务规则与纯计算
|
||||||
|
-> repository / api # 数据访问
|
||||||
|
```
|
||||||
|
|
||||||
|
React 组件负责展示和交互编排;纯函数负责排序、筛选、分组和口径;服务端路由只解析请求并组织响应;数据库查询集中在 repository 层。
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
|
||||||
|
1. **建立回归门禁**:类型检查、领域测试和生产构建必须同时通过。
|
||||||
|
2. **提取纯模型**:先移动无副作用的日期、映射、筛选、排序和聚合逻辑,并补特征测试。
|
||||||
|
3. **拆分前端组件**:日期选择器、表格、展开详情等独立,页面保留状态和组合。
|
||||||
|
4. **拆分后端边界**:服务器应用创建与进程启动分离;大型路由按 model、service、repository、routes 拆分。
|
||||||
|
5. **清理重复与废弃代码**:只有在引用和行为测试都证明无影响后执行。
|
||||||
|
|
||||||
|
每一批只处理一个边界,不把现存 Bug 修复、新功能或视觉调整混入重构提交。
|
||||||
|
|
||||||
|
## 模块迭代路线
|
||||||
|
|
||||||
|
| 批次 | 模块 | 主要边界 | 风险控制 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 第一轮 | 应用入口、服务启动、日报、资产模型、监控查询、车辆模型 | 路由装配、启动副作用、纯计算 | 特征测试锁定路由、日期、口径和请求参数 |
|
||||||
|
| 第二轮 | 资产页、里程监控、车辆接口、统计报表 | 展示组件、数据访问、弹层和图表 | 保持 DOM、SQL、缓存覆盖和请求时机不变 |
|
||||||
|
| 第三轮 | 氢能展示、能源查询路由 | 图表展示、日期模型、数据库查询 | 固定金额/重量单位、客户范围和日期边界 |
|
||||||
|
| 第四轮 | 智能调度 | 筛选模型、建议详情、通知历史 | 写操作与只读派生分开,通知接口单独回归 |
|
||||||
|
| 第五轮 | OneOS 接入、里程缓存 | 外部 API、协议降级、缓存与合并 | 使用响应夹具验证降级顺序、TTL 和数据源优先级 |
|
||||||
|
|
||||||
|
高风险模块不会与普通页面组件放在同一批:能源路由依赖两套数据库,调度包含通知写入,OneOS 层还包含协议兼容、超时和缓存淘汰。拆分这些模块前必须先建立可重复的输入夹具。
|
||||||
|
|
||||||
|
## 当前进度
|
||||||
|
|
||||||
|
截至 2026-08-13,前五轮可独立验证的边界已经完成:
|
||||||
|
|
||||||
|
- 应用路由配置与服务器启动副作用已经从入口文件分离。
|
||||||
|
- 资产、里程监控、每日汇报、统计报表和智能调度已改为“页面协调层 + 展示组件 + 纯模型”。
|
||||||
|
- 车辆接口已改为“路由 + repository + model”,原 SQL、缓存范围和接口顺序由特征测试锁定。
|
||||||
|
- 能源接口已按氢能总览、氢能每日、电能总览和电能每日拆分,入口只保留权限守卫与装配。
|
||||||
|
- 氢能与电能每日页共用自然日范围模型和筛选控件;金额、重量、环比及 30% 波动边界已有测试。
|
||||||
|
- 反馈入口和电能导入页已收敛为“副作用协调层 + 无状态展示组件”;上传、筛选和请求时机仍由主组件负责。
|
||||||
|
- OneOS 响应归一化、连续日期分组和请求键已提取为纯模型;协议别名、去重和区间边界由固定样例锁定。
|
||||||
|
- 里程缓存中的车辆合并、筛选项、考核映射和自然日工具已与刷新流程分离;数据源分类和累计里程空值规则保持不变。
|
||||||
|
- 调度通知的数据库行映射、请求校验和更新字段生成已有契约测试;数据库写入顺序和单车有效干预约束仍留在路由层。
|
||||||
|
- 车辆与加氢热力图已把参数解析、距离计算、网格/排名和序列化提取为纯模型;原 SQL 及参数顺序由契约测试锁定。
|
||||||
|
- CI 已把类型检查、全量测试和生产构建设为同一发布门禁。
|
||||||
|
|
||||||
|
下一批继续只处理能够独立回归的边界:
|
||||||
|
|
||||||
|
1. 继续缩小 `AssetsModule.tsx` 的状态协调职责,但不改请求、筛选、导出和弹窗时序。
|
||||||
|
2. 拆分车辆详情、调度建议详情和通知历史中的纯展示区块,主组件继续持有写操作。
|
||||||
|
3. 为电能导入服务端路由补 SQL 与去重契约后,再拆解析、repository 和路由装配。
|
||||||
|
4. OneOS 网络重试、协议降级和缓存 TTL 暂不移动;只有在可注入请求层覆盖 400 降级和重试后再处理。
|
||||||
|
|
||||||
|
## 注释约定
|
||||||
|
|
||||||
|
注释解释“为什么”,不复述语句本身。优先说明:
|
||||||
|
|
||||||
|
- 统计边界和历史口径
|
||||||
|
- 时区、日期范围和特殊状态映射
|
||||||
|
- 外部 API 的兼容处理
|
||||||
|
- 看似可合并但必须保持差异的流程
|
||||||
|
|
||||||
|
命名和类型能够表达清楚的代码不额外添加注释。
|
||||||
|
|
||||||
|
## 已识别但不在本轮修复的问题
|
||||||
|
|
||||||
|
- 统计页实时车辆与历史日期车辆共用同一个缓存槽,存在状态覆盖风险。
|
||||||
|
- 监控页不同入口的请求参数目前不完全一致。
|
||||||
|
- 数据库连接配置与部署凭据需要独立安全治理。
|
||||||
|
|
||||||
|
这些问题需要单独建缺陷、明确期望行为后再修复,避免借重构改变线上结果。
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>羚牛氢能车辆资产</title>
|
<title>羚牛氢能</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+160
-2
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"version": "1.1.5",
|
"version": "1.1.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"version": "1.1.5",
|
"version": "1.1.15",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"ali-oss": "^6.23.0",
|
"ali-oss": "^6.23.0",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"mysql2": "^3.11.0",
|
"mysql2": "^3.11.0",
|
||||||
|
"pg": "^8.22.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@types/ali-oss": "^6.23.3",
|
"@types/ali-oss": "^6.23.3",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
@@ -36,6 +39,12 @@
|
|||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@amap/amap-jsapi-loader": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@amap/amap-jsapi-loader/-/amap-jsapi-loader-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -1620,6 +1629,18 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pg": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"pg-protocol": "*",
|
||||||
|
"pg-types": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@@ -3521,6 +3542,95 @@
|
|||||||
"through": "~2.3"
|
"through": "~2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||||
|
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.14.0",
|
||||||
|
"pg-pool": "^3.14.0",
|
||||||
|
"pg-protocol": "^1.15.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||||
|
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -3576,6 +3686,45 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/process-nextick-args": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
@@ -3969,6 +4118,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sql-escaper": {
|
"node_modules/sql-escaper": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||||
|
|||||||
+10
-2
@@ -1,17 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "ln-bi",
|
"name": "ln-bi",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.5",
|
"version": "1.1.15",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"dev:local": "concurrently -k -n server,client \"npm run dev:local:server\" \"npm run dev:local:client\"",
|
||||||
|
"dev:local:server": "node --import tsx src/server/local.ts",
|
||||||
|
"dev:local:client": "DEV_MOCK_API=0 VITE_DEV_BYPASS_AUTH=1 vite --host 127.0.0.1 --port 8115 --strictPort",
|
||||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||||
"dev:server": "tsx watch src/server/index.ts",
|
"dev:server": "tsx watch src/server/index.ts",
|
||||||
"dev:client": "vite --port=3000 --host=0.0.0.0",
|
"dev:client": "vite --port=3000 --host=0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"start": "node --import tsx src/server/index.ts",
|
"start": "node --import tsx src/server/index.ts",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "node --import tsx --test $(find src -type f -name '*.test.ts' -print | sort)",
|
||||||
|
"test:mileage-report": "node --import tsx --test src/server/routes/mileage/daily-report-model.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"ali-oss": "^6.23.0",
|
"ali-oss": "^6.23.0",
|
||||||
@@ -21,6 +27,7 @@
|
|||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"mysql2": "^3.11.0",
|
"mysql2": "^3.11.0",
|
||||||
|
"pg": "^8.22.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
@@ -31,6 +38,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@types/ali-oss": "^6.23.3",
|
"@types/ali-oss": "^6.23.3",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<svg width="150" height="36" viewBox="0 0 150 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18.0459 4.69253L24.4084 4.6875L17.3427 16.9334C16.0108 19.2369 14.67 21.5335 13.357 23.8492C12.8279 24.6836 12.7967 25.5367 14.0379 25.5663C17.6611 25.6526 21.3268 25.5356 24.9495 25.6042C24.4224 26.7246 23.2874 28.5534 22.6381 29.6706L21.6984 31.3061L13.8032 31.3074C11.2823 31.3084 7.42295 31.774 6.15339 28.9623C5.10923 26.6498 6.73472 24.2701 7.86608 22.3109L10.1604 18.3408L18.0459 4.69253Z" fill="#2F2828"/>
|
||||||
|
<path d="M30.7029 4.69293L38.1664 4.6931C40.8704 4.68656 45.5224 4.1044 46.4284 7.56236C47.0408 9.9001 45.3543 12.286 44.21 14.218C42.2951 14.125 39.809 14.1965 37.8457 14.1908C38.3321 13.3838 40.2747 10.8182 38.8199 10.5324C37.4531 10.2639 35.1947 10.4548 33.7653 10.4209C32.0072 13.3847 30.3349 16.4036 28.5844 19.3723C28.4964 19.5215 28.3602 19.7716 28.257 19.9038L21.9204 19.9023L30.7029 4.69293Z" fill="#2F2828"/>
|
||||||
|
<path d="M89.1232 4.67032C89.7835 4.62775 90.9099 4.66078 91.6053 4.65867L90.978 7.75816L98.7808 7.75999C98.6607 8.46499 98.5297 9.16816 98.3877 9.86921L90.5769 9.86774C90.1934 11.0614 89.853 13.7315 89.4731 15.1689L97.9522 15.1851C97.8321 15.8946 97.6872 16.5697 97.5364 17.2721C94.9175 17.1811 91.7297 17.2538 89.0954 17.2677C88.594 19.3603 88.1541 21.9308 87.7303 24.0635C86.9061 24.0335 86.0812 24.0264 85.2563 24.0421C85.7525 22.0904 86.18 19.3243 86.6177 17.2638L85.2372 17.257L77.0092 17.2648C77.1446 16.5758 77.2727 15.8857 77.3957 15.1944C79.8624 15.1112 82.3687 15.2077 84.839 15.1714C85.5417 15.161 86.2942 15.1588 86.9939 15.2057C87.447 13.5537 87.6608 11.5742 88.1087 9.87872C86.2986 9.82772 84.3142 9.86591 82.4909 9.86569C81.7604 11.226 81.0028 12.4816 80.2657 13.8163L77.5362 13.8114C79.6816 10.4975 80.6808 8.44223 82.2311 4.86287C83.0545 4.83579 84.0075 4.85801 84.8405 4.85771C84.4152 5.82928 83.998 6.81499 83.512 7.75669C85.1516 7.77967 86.8351 7.76247 88.4769 7.76452C88.7163 6.8407 88.9417 5.61995 89.1232 4.67032Z" fill="#2F2828"/>
|
||||||
|
<path d="M70.2503 4.65824L73.7308 4.65625C74.7336 7.08713 76.1309 9.57515 77.5099 11.8054C76.5818 11.7752 75.527 11.798 74.5887 11.7967C73.5397 9.99962 72.6069 8.13727 71.7957 6.22117C71.2298 6.89947 70.7149 7.63591 70.1079 8.35496L72.1324 8.35415C72.359 9.56198 72.4332 11.2339 72.4619 12.4558L70.2398 12.4478C70.2372 11.0171 70.1331 9.90113 70.0025 8.48344C69.1539 9.57259 67.962 10.814 66.9868 11.7877L65.7182 11.7932L64.2958 11.7963C64.2202 12.4282 64.0755 13.0643 63.9444 13.6881L60.6789 13.6869C60.5363 14.5168 60.3766 15.3436 60.1998 16.1668L63.9171 16.1664L63.5281 18.0826C62.277 18.0555 60.9445 18.0784 59.687 18.0782C58.9474 20.2403 57.828 22.2532 56.3813 24.0224C55.6509 24.0768 54.3913 24.03 53.5917 24.0442C55.3706 21.7397 56.0946 20.7987 57.3125 18.0802L53.6008 18.0731C53.7608 17.396 53.882 16.8655 53.9824 16.1718C55.2848 16.1587 56.5873 16.1591 57.8898 16.1732L58.3652 13.6896L54.9231 13.6853C55.1139 13.0172 55.1958 12.5027 55.3015 11.8164C56.2519 11.7504 57.7795 11.7881 58.7587 11.8109C58.8945 11.0981 59.037 10.3867 59.1861 9.67657C57.9106 9.6642 56.6351 9.66427 55.3597 9.67657C55.4544 9.05996 55.5544 8.44415 55.66 7.8293C57.5354 7.80764 59.4484 7.82827 61.3266 7.83003C61.9933 6.86852 62.6551 5.74668 63.0825 4.66341L65.5379 4.65888C64.9966 5.88817 64.5907 6.67983 63.9019 7.83288L65.5427 7.82645C65.4452 8.4301 65.2987 9.07927 65.1744 9.68205C63.9717 9.64854 62.7013 9.67152 61.4934 9.67532C61.3775 10.3714 61.2146 11.1007 61.0712 11.7947L63.9987 11.7913C66.5788 9.20798 68.0943 7.61586 70.2503 4.65824Z" fill="#2F2828"/>
|
||||||
|
<path d="M122.532 11.8166C125.798 11.7231 129.441 11.7977 132.734 11.7984C132.466 13.5695 131.978 15.624 131.64 17.4038C131.42 18.5691 130.997 20.9151 130.604 21.9327C130.313 22.7085 129.869 23.4177 129.299 24.0182C128.475 24.0895 127.218 24.0195 126.289 24.0612C126.736 23.701 127.014 23.4316 127.42 23.0228C128.176 22.1519 128.359 21.6669 128.744 20.5722C126.97 20.5048 124.822 20.5601 123.018 20.5584C122.945 21.3158 122.507 23.1882 122.339 24.0641C121.646 24.031 120.793 24.0186 120.111 24.0547C120.247 23.0506 120.522 21.8389 120.724 20.8256L121.799 15.4428C121.945 14.7076 122.299 12.3722 122.532 11.8166ZM129.104 18.8694C129.21 18.2708 129.304 17.7024 129.465 17.1139C127.646 17.0946 125.48 17.1746 123.726 17.1053C123.609 17.6915 123.487 18.2767 123.359 18.8607C125.251 18.8633 127.22 18.8364 129.104 18.8694ZM124.059 15.3809C124.989 15.3848 129.088 15.5282 129.805 15.3507C129.899 14.7602 130.003 14.261 130.135 13.6774C128.223 13.6522 126.311 13.6515 124.399 13.6752C124.302 14.1834 124.187 14.895 124.059 15.3809Z" fill="#2F2828"/>
|
||||||
|
<path d="M28.257 19.9042C30.3408 19.8373 32.5067 19.9107 34.6003 19.8799C33.5304 21.7989 32.4375 23.705 31.3217 25.5977L37.6027 25.587C37.1076 26.6547 36.2454 28.0061 35.655 29.0623C35.3804 29.5534 34.6405 30.8847 34.3403 31.3077L28.0049 31.3022C28.8125 29.6956 29.8532 28.1298 30.7172 26.5471C30.8889 26.2323 31.0858 25.9059 31.2849 25.6084C29.1922 25.6072 27.049 25.5816 24.9604 25.61C25.6026 24.4466 26.26 23.2918 26.9324 22.1456C27.3144 21.4843 27.8407 20.5151 28.257 19.9042Z" fill="#007143"/>
|
||||||
|
<path d="M99.7206 10.1076C106.203 9.98573 113.088 10.0971 119.599 10.1018C118.777 13.8389 118.005 17.7955 117.477 21.5875C117.37 22.3581 118.045 23.5899 118.033 24.042C117.125 24.0338 116.218 24.0361 115.311 24.0488C114.92 21.1109 115.519 19.0866 116.068 16.2425C116.357 14.7439 116.652 13.209 116.981 11.7202L99.4205 11.7137C99.5633 11.1642 99.6343 10.6691 99.7206 10.1076Z" fill="#2F2828"/>
|
||||||
|
<path d="M64.5255 12.945C68.4291 12.8445 72.7488 12.9376 76.6835 12.9394C76.5327 13.651 76.4317 14.5237 75.9691 15.0919C75.4018 15.7882 74.7928 16.4582 74.197 17.1312L70.7419 21.0305L69.7438 21.059C70.2176 22.0474 70.6693 23.0462 71.0985 24.0548C70.1752 24.0352 69.2515 24.0344 68.3282 24.0525C67.9964 23.2947 67.6583 22.5397 67.3138 21.7875C66.4548 19.874 65.5159 17.9972 64.5 16.1621C65.3591 16.1484 66.2391 16.156 67.1001 16.1542C67.8294 17.3343 68.5019 18.5485 69.1155 19.7926C70.6881 18.1842 72.2203 16.5366 73.7103 14.8514L64.1427 14.8496C64.2745 14.2155 64.402 13.5807 64.5255 12.945Z" fill="#2F2828"/>
|
||||||
|
<path d="M100.218 12.4635C104.946 12.4535 109.674 12.4727 114.401 12.5212C114.273 12.9715 114.112 13.3776 113.946 13.814C112.657 14.4639 111.179 15.1061 109.857 15.7056C111.599 15.949 113.275 15.9778 115.027 16.028C114.844 16.6716 114.645 17.3102 114.427 17.943C112.64 17.957 110.455 17.6924 108.705 17.3225C108.285 17.2407 107.508 16.991 107.072 16.8654C104.084 18.0184 101.851 17.949 98.7192 17.9316C98.791 17.2822 98.9052 16.6738 99.0208 16.0327C102.633 15.9161 105.599 15.8462 108.933 14.123C106.178 14.2266 102.668 14.1367 99.8604 14.135C99.9731 13.5764 100.092 13.0192 100.218 12.4635Z" fill="#2F2828"/>
|
||||||
|
<path d="M98.7625 18.3504C100.425 18.3036 102.282 18.3451 103.963 18.3452L114.077 18.3459C113.979 18.982 113.891 19.4244 113.733 20.0481C111.681 19.9793 109.274 20.0318 107.196 20.0488C107.14 20.5854 106.873 21.7822 106.759 22.3601C108.872 22.2731 111.751 22.3361 113.883 22.3568C113.718 22.9049 113.8 23.5534 113.504 23.9553C113.406 24.0871 113.154 24.042 112.96 24.0414C107.731 23.9823 102.283 24.0035 97.0497 24.0421C97.15 23.4629 97.2678 22.9333 97.3996 22.3599C99.6372 22.3158 102.265 22.3081 104.496 22.3613C104.62 21.6 104.787 20.8055 104.936 20.0457C102.829 19.9936 100.54 20.0389 98.4199 20.0394L98.7625 18.3504Z" fill="#2F2828"/>
|
||||||
|
<path d="M133.256 13.9166C133.997 13.9023 134.788 13.9161 135.533 13.9168C135.386 14.8072 135.174 15.7896 135.001 16.6831C137.384 16.4394 139.769 16.2153 142.155 16.0109C141.98 16.6792 141.965 17.2281 141.716 17.9362C141.421 17.9321 141.131 17.9513 140.839 17.9823C138.756 18.2028 136.663 18.3336 134.585 18.5982C134.457 19.6086 134.11 21.1394 133.89 22.1662L135.596 22.1591H140.893C140.792 22.7924 140.684 23.4247 140.569 24.0557C137.586 23.9698 134.244 24.0318 131.246 24.0396C131.9 20.9278 132.738 17.0234 133.256 13.9166Z" fill="#2F2828"/>
|
||||||
|
<path d="M135.098 4.62931C135.853 4.62166 136.607 4.62419 137.363 4.6369C137.162 5.41299 137.017 6.28154 136.868 7.07464C139.102 6.84738 141.908 6.69114 144.053 6.37755C143.943 7.01342 143.835 7.73358 143.657 8.34683L136.465 8.98722C136.314 9.81163 136.157 10.6349 135.994 11.4569C136.445 11.4309 137.106 11.4499 137.573 11.4482C139.397 11.4591 141.222 11.4559 143.046 11.4386C142.935 12.1076 142.839 12.6758 142.674 13.3374C139.624 13.2772 136.387 13.3227 133.323 13.3214C133.552 12.5671 133.793 11.1594 133.955 10.3413C134.324 8.43507 134.706 6.53103 135.098 4.62931Z" fill="#2F2828"/>
|
||||||
|
<path d="M102.916 4.63357C103.716 4.61473 104.573 4.6323 105.378 4.63596L104.934 5.48128L121.832 5.48318C121.739 6.10161 121.672 6.52446 121.507 7.12456C119.818 7.05448 117.769 7.10604 116.053 7.10625L103.984 7.11772C103.38 7.9858 102.762 8.89904 102.054 9.68109C101.302 9.67604 100.124 9.63799 99.419 9.71446C99.8816 9.06719 100.543 8.33907 101.04 7.66743C101.837 6.59222 102.296 5.78928 102.916 4.63357Z" fill="#2F2828"/>
|
||||||
|
<path d="M126 4.67433C126.546 4.62029 128.007 4.65657 128.619 4.65502C127.72 6.37437 126.984 7.53498 125.875 9.09798C127.492 9.05503 129.258 9.08605 130.886 9.08474C130.822 8.136 130.773 7.13368 130.53 6.2156C131.306 6.15771 132.166 6.17288 132.949 6.17043C133.021 7.06163 133.221 8.13834 133.316 9.09988C133.309 9.63037 133.157 10.3841 133.057 10.9028C129.465 10.8026 125.423 10.8945 121.795 10.8945C123.602 8.78078 124.778 7.14823 126 4.67433Z" fill="#2F2828"/>
|
||||||
|
<path d="M37.8456 14.1928C39.809 14.1984 42.2951 14.127 44.2099 14.22L41.9937 18.0919C41.7788 18.4647 41.1512 19.6063 40.9155 19.9025L34.5791 19.8957C35.6336 17.9833 36.7911 16.1017 37.8456 14.1928Z" fill="#007143"/>
|
||||||
|
<path d="M40.9155 19.902C43.0195 19.8499 45.1502 19.9224 47.2506 19.8789C46.4721 21.4168 44.8928 24.105 43.9715 25.602L37.6138 25.6077C38.4831 23.9831 39.9311 21.447 40.9155 19.902Z" fill="#007143"/>
|
||||||
|
<path d="M104.415 7.76391L120.496 7.76172C120.35 8.3135 120.238 8.87355 120.16 9.43887C118.084 9.37345 115.82 9.41552 113.733 9.4153L104.085 9.42408C104.155 8.93055 104.312 8.26469 104.415 7.76391Z" fill="#2F2828"/>
|
||||||
|
<path d="M96.4304 26.879C99.2507 26.6667 98.9579 30.7714 95.9312 31.3474C92.5488 31.3098 93.6973 27.1881 96.4304 26.879ZM95.9883 30.5709C96.9304 30.2786 97.7955 29.3384 97.4662 28.3229C97.3944 28.096 97.2327 27.9087 97.0175 27.8056C96.7964 27.6975 96.4978 27.6448 96.2518 27.6614C95.3083 27.9393 94.49 28.8604 94.7842 29.8668C94.8501 30.099 95.0075 30.2944 95.2205 30.4079C95.4752 30.5427 95.7065 30.5599 95.9883 30.5709Z" fill="#2F2828"/>
|
||||||
|
<path d="M120.579 26.8756C123.424 26.7179 123.034 30.7691 120.086 31.3456C116.806 31.3978 117.733 27.1994 120.579 26.8756ZM120.22 30.5368C122.019 29.9774 122.223 27.4751 120.314 27.6674C119.642 27.9156 119.439 28.0285 119.093 28.7081C118.616 29.6484 119.009 30.7721 120.22 30.5368Z" fill="#2F2828"/>
|
||||||
|
<path d="M116.66 26.9238L117.992 26.9219C117.747 28.2834 117.347 29.9417 117.049 31.3153L116.157 31.3083C116.347 30.6023 116.495 29.7902 116.644 29.0672C116.688 28.9313 116.767 28.502 116.801 28.3408C116.395 29.0335 115.459 30.9511 114.675 31.3444C114.566 31.399 114.465 31.3038 114.371 31.2359C114.165 30.71 113.925 28.8772 113.836 28.242L113.198 31.3117L112.31 31.3165L113.23 26.9279L114.491 26.9225C114.605 27.7811 114.833 28.7942 114.973 29.6896C115.472 28.9993 116.215 27.6823 116.66 26.9238Z" fill="#2F2828"/>
|
||||||
|
<path d="M86.5921 26.9211C86.9917 26.9147 87.4287 26.9264 87.8225 26.9095C90.4759 26.7956 90.1867 29.6754 88.5223 30.9111C87.5305 31.4374 86.7699 31.3173 85.6661 31.3024C85.7957 30.2878 86.3578 28.007 86.5921 26.9211ZM86.7231 30.4522C87.1359 30.4503 87.7347 30.4871 88.1007 30.316C88.6401 29.8127 89.3033 28.7778 88.6848 28.0956C88.3158 27.6887 87.7976 27.7551 87.2911 27.7533C87.1257 28.5612 86.9295 29.669 86.7231 30.4522Z" fill="#2F2828"/>
|
||||||
|
<path d="M123.457 26.9226C124.213 26.9143 125.634 26.8007 126.186 27.3128C126.71 27.7978 125.896 28.5423 125.68 28.9376C125.595 29.0947 126.087 29.5814 126.078 29.8632C126.065 30.2604 125.746 30.6635 125.492 30.9391C124.737 31.4476 123.501 31.3147 122.564 31.3117C122.81 29.8751 123.187 28.3774 123.457 26.9226ZM123.593 30.5161C124.141 30.5116 124.358 30.5255 124.891 30.3884C125.067 30.151 125.129 30.0825 125.184 29.7933C125.024 29.2759 124.325 29.3801 123.862 29.3789L123.593 30.5161ZM123.999 28.6156C124.282 28.6098 124.866 28.6145 125.123 28.5796C125.245 28.3715 125.336 28.252 125.328 28.0082C125.082 27.6121 124.658 27.6901 124.214 27.6711C124.144 27.9762 124.058 28.3111 123.999 28.6156Z" fill="#2F2828"/>
|
||||||
|
<path d="M90.735 26.9215C92.024 26.9243 94.8238 26.5442 93.2493 29.0441C93.0876 29.3009 92.7831 29.446 92.5137 29.583C92.7384 30.1413 92.9478 30.7419 93.1556 31.3095C92.8314 31.3198 92.3036 31.3998 92.0979 31.1551C91.9179 30.6395 91.7971 30.0956 91.3931 29.741C90.8792 29.6812 90.7592 30.8999 90.6765 31.3136L89.8201 31.3149C90.1114 29.8477 90.4166 28.3831 90.735 26.9215ZM91.2167 28.8258C91.4736 28.8227 92.2707 28.8268 92.4815 28.7911C92.6447 28.5511 92.7304 28.4484 92.7926 28.1652C92.6755 27.6152 91.9171 27.7496 91.4458 27.7535C91.366 28.1101 91.2891 28.4675 91.2167 28.8258Z" fill="#2F2828"/>
|
||||||
|
<path d="M69.3081 26.9222L70.1771 26.9141C69.9698 28.186 69.5337 30.0383 69.2388 31.3177L68.1065 31.3098C67.8398 30.3494 67.4692 29.2815 67.1696 28.3128C67.0189 29.2546 66.7757 30.3753 66.5616 31.3076L65.6787 31.3079L66.6075 26.9221L67.7409 26.9223C68.0639 27.8733 68.37 28.898 68.6717 29.8604C68.873 28.8787 69.0851 27.8992 69.3081 26.9222Z" fill="#2F2828"/>
|
||||||
|
<path d="M108.881 29.8868C109.027 29.2478 109.289 27.5001 109.54 27.0002C109.723 26.889 110.12 26.9136 110.352 26.9105C110.263 27.8288 109.679 30.3103 109.457 31.3037L108.341 31.3085C108.011 30.3531 107.719 29.367 107.384 28.4004L106.772 31.3134L105.9 31.3077C106.155 29.911 106.523 28.3126 106.832 26.9175L107.966 26.9206C108.2 27.8185 108.6 28.9795 108.881 29.8868Z" fill="#2F2828"/>
|
||||||
|
<path d="M61.0197 26.9225L61.8629 26.9102C61.7079 28.0991 61.2332 30.0627 60.9722 31.3099L59.8503 31.3086C59.519 30.3592 59.2069 29.3429 58.8931 28.3815L58.2722 31.3125L57.3802 31.3049C57.7123 30.0129 58.0673 28.24 58.3208 26.9184L59.4601 26.922C59.6904 27.8033 60.1149 29.0009 60.4008 29.8927C60.5467 28.9635 60.7999 27.8383 61.0197 26.9225Z" fill="#2F2828"/>
|
||||||
|
<path d="M78.6518 26.9215L79.5294 26.9146C79.4255 27.4595 79.2959 28.0172 79.1766 28.5603C79.689 28.553 80.216 28.5592 80.7298 28.5592L81.0951 26.923L81.9493 26.9141C81.8519 27.9269 81.2576 30.0868 81.0526 31.1643C81.0343 31.2615 80.9867 31.26 80.8865 31.3094C80.6281 31.3129 80.44 31.3262 80.1889 31.262C80.2658 30.6683 80.4253 30.0042 80.5534 29.4127L78.998 29.4118C78.867 30.0925 78.7586 30.6456 78.5669 31.3166L77.7339 31.3159C77.9893 29.9039 78.3363 28.3198 78.6518 26.9215Z" fill="#2F2828"/>
|
||||||
|
<path d="M103.391 26.918L106.201 26.9222L106.032 27.7871C105.48 27.7389 104.623 27.7531 104.059 27.7493C104.008 28.0771 103.939 28.4261 103.88 28.7546L105.576 28.7521L105.445 29.4786C105.093 29.4693 104.014 29.408 103.727 29.5427L103.677 29.7236L103.488 30.4471L105.46 30.4415C105.391 30.7111 105.332 31.0336 105.273 31.3098C104.347 31.2889 103.359 31.3061 102.428 31.3067C102.714 29.9523 103.028 28.2233 103.391 26.918Z" fill="#2F2828"/>
|
||||||
|
<path d="M101.23 26.8806C101.741 26.8186 102.269 26.978 102.715 27.2175C102.67 27.4649 102.637 27.8569 102.466 28.0164C102.325 28.0441 101.618 27.7141 101.272 27.6162C100.683 27.7595 100.181 27.9029 99.8684 28.4645C99.2265 29.6163 99.5405 30.8072 101.032 30.4857C101.475 30.0635 101.026 29.4324 101.687 29.1473C101.888 29.1663 102.02 29.1878 102.216 29.2257C102.312 29.5647 101.987 30.9407 101.742 31.0531C97.9931 32.7738 97.5708 27.2933 101.23 26.8806Z" fill="#2F2828"/>
|
||||||
|
<path d="M74.9385 26.9214C75.1712 26.9118 75.4808 26.8744 75.6821 26.9707C75.7905 27.2226 75.7246 27.2838 75.6726 27.6035C75.3345 28.829 75.382 30.2578 74.2211 31.0737C73.6941 31.4969 72.454 31.4555 72.0957 30.8256C71.57 29.9017 72.2194 27.9607 72.4365 26.9236L73.3245 26.923C73.2315 27.4568 72.5294 29.9806 72.8643 30.2786C74.3522 31.6025 74.7181 27.8186 74.9385 26.9214Z" fill="#2F2828"/>
|
||||||
|
<path d="M64.4934 26.8798C65.0658 26.8499 65.4038 26.9622 65.9183 27.1614C65.9004 27.4821 65.8231 27.8782 65.7694 28.201C65.3237 27.8267 65.0369 27.7669 64.4898 27.6111C64.0346 27.7299 63.4555 27.898 63.1815 28.3015C62.8659 28.7125 62.5666 30.01 63.1207 30.2988C64.5127 31.0242 64.4083 30.3038 64.6865 29.2341C64.6957 29.1988 65.0789 29.1547 65.1737 29.1403L65.443 29.2169C65.5798 29.5824 65.2474 30.6016 65.1325 31.0264C64.7903 31.1835 64.4256 31.2859 64.0516 31.3298C60.9761 31.6856 61.3322 27.1955 64.4934 26.8798Z" fill="#2F2828"/>
|
||||||
|
<path d="M85.1552 26.9215L86.2297 26.918C85.7693 27.4771 85.3097 28.0605 84.8551 28.6258C84.028 29.5226 84.0163 30.1406 83.7762 31.3103L82.8773 31.3089C83.4446 29.0983 83.1291 29.0171 82.3298 26.9199L83.3107 26.9197C83.512 27.4507 83.7059 27.9844 83.8926 28.5204L85.1552 26.9215Z" fill="#2F2828"/>
|
||||||
|
<path d="M139.373 26.9247L140.422 26.918C140.11 27.4009 139.441 28.178 139.059 28.6479C138.207 29.6578 138.305 30.0537 137.985 31.3112L137.113 31.3094C137.262 30.7606 137.396 30.0375 137.516 29.4702C137.197 28.6188 136.873 27.7696 136.543 26.9227L137.516 26.921C137.728 27.4476 137.932 27.9767 138.13 28.5083C138.556 27.9893 138.97 27.4613 139.373 26.9247Z" fill="#2F2828"/>
|
||||||
|
<path d="M57.3993 4.67527C58.1061 4.65383 58.8637 4.66377 59.5748 4.66016C59.8347 5.63537 59.8946 6.41442 59.9652 7.40979C59.3798 7.36896 58.4268 7.39837 57.8139 7.39757C57.7331 6.3945 57.6463 5.64817 57.3993 4.67527Z" fill="#2F2828"/>
|
||||||
|
<path d="M133.406 26.9219L136.447 26.9253L136.333 27.7751C135.986 27.7619 135.585 27.7671 135.234 27.7638C135.054 28.5778 134.891 29.4212 134.722 30.2397L134.468 31.3148L133.589 31.3145C133.858 30.1379 134.11 28.9573 134.343 27.7731C134.007 27.7561 133.613 27.7725 133.273 27.7783L133.406 26.9219Z" fill="#2F2828"/>
|
||||||
|
<path d="M53.4287 26.918L54.3232 26.9254L53.5875 30.453L55.5179 30.4498L55.3763 31.3123C54.4204 31.2926 53.4227 31.3031 52.4631 31.2988C52.7142 30.6151 53.2746 27.7241 53.4287 26.918Z" fill="#2F2828"/>
|
||||||
|
<path d="M128.859 26.924L129.753 26.9219C129.475 28.0093 129.226 29.3489 128.993 30.4563L130.915 30.4496L130.763 31.3133C129.848 31.2883 128.851 31.3078 127.93 31.3087L128.859 26.924Z" fill="#2F2828"/>
|
||||||
|
<path d="M127.308 26.9204L128.164 26.918L127.239 31.3152L126.351 31.3151C126.65 29.8952 126.949 28.3149 127.308 26.9204Z" fill="#2F2828"/>
|
||||||
|
<path d="M56.7724 26.918L57.6656 26.9141C57.3133 28.3287 57.0132 29.8795 56.7123 31.3129L55.8318 31.3086C56.1293 29.8417 56.4429 28.3781 56.7724 26.918Z" fill="#2F2828"/>
|
||||||
|
<path d="M70.8595 26.9214L71.7381 26.918C71.567 28.0722 71.0941 30.1443 70.8119 31.3022L69.9707 31.3105C70.1852 29.9437 70.5812 28.3064 70.8595 26.9214Z" fill="#2F2828"/>
|
||||||
|
<path d="M132.146 26.9187L133.056 26.918C132.809 28.082 132.418 30.2402 132.087 31.3152L131.25 31.3052C131.533 29.8444 131.902 28.3846 132.146 26.9187Z" fill="#2F2828"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,78 @@
|
|||||||
|
import type { RowDataPacket } from 'mysql2';
|
||||||
|
import pool from '../src/server/db.js';
|
||||||
|
import type {
|
||||||
|
DailyMileageReport,
|
||||||
|
MileageReportTrendPoint,
|
||||||
|
} from '../src/shared/mileage/daily-report.js';
|
||||||
|
import { fetchOneOsMileageDates } from '../src/server/routes/mileage/oneos-api.js';
|
||||||
|
import { DEFAULT_ONEOS_PROTOCOL_PRIORITY } from '../src/server/routes/mileage/source-policy.js';
|
||||||
|
import { roundMileage } from '../src/server/routes/mileage/daily-report-model.js';
|
||||||
|
import { updateDailyReportSnapshotPayload } from '../src/server/routes/mileage/daily-report-store.js';
|
||||||
|
|
||||||
|
interface SnapshotRow extends RowDataPacket {
|
||||||
|
report_date: string;
|
||||||
|
source: string;
|
||||||
|
report_payload: DailyMileageReport | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportDates(reportDate: string): string[] {
|
||||||
|
const end = new Date(`${reportDate}T00:00:00Z`);
|
||||||
|
return Array.from({ length: 7 }, (_, index) => {
|
||||||
|
const date = new Date(end);
|
||||||
|
date.setUTCDate(end.getUTCDate() - (6 - index));
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReport(value: DailyMileageReport | string): DailyMileageReport {
|
||||||
|
return typeof value === 'string' ? JSON.parse(value) as DailyMileageReport : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trendByPlate(report: DailyMileageReport): Promise<Map<string, MileageReportTrendPoint[]>> {
|
||||||
|
const dates = reportDates(report.reportDate);
|
||||||
|
const plates = report.groups.flatMap(group => group.vehicles.map(vehicle => vehicle.plate));
|
||||||
|
const rowsByDate = await fetchOneOsMileageDates(dates, plates, DEFAULT_ONEOS_PROTOCOL_PRIORITY);
|
||||||
|
const result = new Map<string, MileageReportTrendPoint[]>();
|
||||||
|
for (const plate of plates) {
|
||||||
|
result.set(plate, dates.map(date => {
|
||||||
|
const mileage = (rowsByDate.get(date) || [])
|
||||||
|
.filter(row => row.status === 'NORMAL' && row.plateNumber === plate)
|
||||||
|
.reduce((maximum, row) => Math.max(maximum, Number(row.dailyMileageKm) || 0), 0);
|
||||||
|
return { date, totalMileage: roundMileage(Math.max(0, mileage)) };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apply = process.argv.includes('--apply');
|
||||||
|
const [rows] = await pool.query<SnapshotRow[]>(`
|
||||||
|
SELECT DATE_FORMAT(report_date, '%Y-%m-%d') AS report_date, source, report_payload
|
||||||
|
FROM lingniu_prod.tab_mileage_daily_report
|
||||||
|
WHERE source <> 'XLSX_IMPORT'
|
||||||
|
ORDER BY report_date
|
||||||
|
`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const row of rows) {
|
||||||
|
const report = parseReport(row.report_payload);
|
||||||
|
if (report.groups.every(group => group.vehicles.every(vehicle => vehicle.trend?.length === 7))) {
|
||||||
|
console.log(`${row.report_date}: already complete`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const trends = await trendByPlate(report);
|
||||||
|
const updated: DailyMileageReport = {
|
||||||
|
...report,
|
||||||
|
groups: report.groups.map(group => ({
|
||||||
|
...group,
|
||||||
|
vehicles: group.vehicles.map(vehicle => ({
|
||||||
|
...vehicle,
|
||||||
|
trend: trends.get(vehicle.plate) || [],
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if (apply) await updateDailyReportSnapshotPayload(updated);
|
||||||
|
console.log(`${row.report_date}: ${apply ? 'updated' : 'would update'} ${trends.size} vehicles`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import XLSX from 'xlsx';
|
||||||
|
import type { WorkBook } from 'xlsx';
|
||||||
|
import type { RowDataPacket } from 'mysql2';
|
||||||
|
import pool from '../src/server/db.js';
|
||||||
|
import type {
|
||||||
|
MileageReportBreakdown,
|
||||||
|
MileageReportGroup,
|
||||||
|
MileageReportVehicle,
|
||||||
|
} from '../src/shared/mileage/daily-report.js';
|
||||||
|
import {
|
||||||
|
buildDailyMileageReport,
|
||||||
|
mileageBand,
|
||||||
|
roundMileage,
|
||||||
|
} from '../src/server/routes/mileage/daily-report-model.js';
|
||||||
|
import { saveDailyReportSnapshot } from '../src/server/routes/mileage/daily-report-store.js';
|
||||||
|
|
||||||
|
interface TargetRow extends RowDataPacket { id: number; target_name: string }
|
||||||
|
|
||||||
|
interface ImportSpec {
|
||||||
|
date: string;
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHEET_SPECS = [
|
||||||
|
{ sheet: '40台普货', targetMatch: '交投40', summaryRow: 2, displayName: '40台普货' },
|
||||||
|
{ sheet: '190台冷藏车', targetMatch: '交投190', summaryRow: 3, displayName: '190台冷链' },
|
||||||
|
{ sheet: '136台冷藏车', targetMatch: '羚牛136', summaryRow: 4, displayName: '136台冷链' },
|
||||||
|
{ sheet: '100台双飞翼', targetMatch: '羚牛100', summaryRow: 5, displayName: '100台18T双飞翼' },
|
||||||
|
{ sheet: '50台普货', targetMatch: '恒运', summaryRow: 6, displayName: '26台恒运普货' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const INVENTORY_REGIONS: Record<string, Record<string, Record<string, number>>> = {
|
||||||
|
'2026-03-30': {
|
||||||
|
'40台普货': { 广东: 2 },
|
||||||
|
'190台冷链': { 嘉兴: 2, 广东: 38 },
|
||||||
|
'136台冷链': { 新疆: 1, 嘉兴: 13, 广东: 31 },
|
||||||
|
'100台18T双飞翼': { 嘉兴: 9, 广东: 29 },
|
||||||
|
'26台恒运普货': { 广东: 11, 嘉兴: 13 },
|
||||||
|
},
|
||||||
|
'2026-03-31': {
|
||||||
|
'40台普货': { 广东: 2 },
|
||||||
|
'190台冷链': { 嘉兴: 2, 广东: 36 },
|
||||||
|
'136台冷链': { 新疆: 1, 嘉兴: 13, 广东: 32 },
|
||||||
|
'100台18T双飞翼': { 嘉兴: 8, 广东: 29 },
|
||||||
|
'26台恒运普货': { 广东: 11, 嘉兴: 12 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArgs(values: string[]): ImportSpec[] {
|
||||||
|
return values.map(value => {
|
||||||
|
const split = value.indexOf(':');
|
||||||
|
if (split < 10) throw new Error(`参数格式应为 YYYY-MM-DD:/path/file.xlsx,收到 ${value}`);
|
||||||
|
const date = value.slice(0, split);
|
||||||
|
const file = value.slice(split + 1);
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !file) throw new Error(`参数不合法:${value}`);
|
||||||
|
return { date, file };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueNumber(value: unknown): number {
|
||||||
|
return Number(value) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sheetRows(workbook: WorkBook, name: string): unknown[][] {
|
||||||
|
const sheet = workbook.Sheets[name];
|
||||||
|
if (!sheet) throw new Error(`工作表不存在:${name}`);
|
||||||
|
return XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, raw: true, defval: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
function trendDate(reportDate: string, label: unknown): string {
|
||||||
|
const match = /^(\d{1,2})\.(\d{1,2})日/.exec(String(label || ''));
|
||||||
|
if (!match) throw new Error(`无法识别趋势日期:${String(label)}`);
|
||||||
|
let year = Number(reportDate.slice(0, 4));
|
||||||
|
const candidate = `${year}-${String(Number(match[1])).padStart(2, '0')}-${String(Number(match[2])).padStart(2, '0')}`;
|
||||||
|
if (candidate > reportDate) year -= 1;
|
||||||
|
return `${year}-${String(Number(match[1])).padStart(2, '0')}-${String(Number(match[2])).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function breakdown(rows: MileageReportVehicle[], key: 'department'): MileageReportBreakdown[] {
|
||||||
|
const grouped = new Map<string, MileageReportVehicle[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const name = row[key] || '未标注';
|
||||||
|
const items = grouped.get(name) || [];
|
||||||
|
items.push(row);
|
||||||
|
grouped.set(name, items);
|
||||||
|
}
|
||||||
|
return Array.from(grouped.entries()).map(([name, items]) => ({
|
||||||
|
name,
|
||||||
|
vehicleCount: items.length,
|
||||||
|
totalMileage: roundMileage(items.reduce((total, item) => total + item.dailyMileage, 0)),
|
||||||
|
averageMileage: items.length > 0
|
||||||
|
? roundMileage(items.reduce((total, item) => total + item.dailyMileage, 0) / items.length)
|
||||||
|
: 0,
|
||||||
|
})).sort((a, b) => b.vehicleCount - a.vehicleCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inventoryBreakdown(date: string, groupName: string): MileageReportBreakdown[] {
|
||||||
|
return Object.entries(INVENTORY_REGIONS[date]?.[groupName] || {}).map(([name, count]) => ({
|
||||||
|
name,
|
||||||
|
vehicleCount: count,
|
||||||
|
totalMileage: 0,
|
||||||
|
averageMileage: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatedAt(date: string): string {
|
||||||
|
const next = new Date(`${date}T00:00:00Z`);
|
||||||
|
next.setUTCDate(next.getUTCDate() + 1);
|
||||||
|
return `${next.toISOString().slice(0, 10)}T09:30:00+08:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importWorkbook(spec: ImportSpec, targets: TargetRow[]): Promise<void> {
|
||||||
|
const workbook = XLSX.readFile(spec.file, { cellDates: true });
|
||||||
|
const summary = sheetRows(workbook, '汇总');
|
||||||
|
const groups: MileageReportGroup[] = SHEET_SPECS.map(sheetSpec => {
|
||||||
|
const rows = sheetRows(workbook, sheetSpec.sheet);
|
||||||
|
const headerIndex = rows.findIndex(row => row[0] === '序号');
|
||||||
|
if (headerIndex < 0) throw new Error(`${sheetSpec.sheet} 未找到明细表头`);
|
||||||
|
const header = rows[headerIndex];
|
||||||
|
const currentDayIndex = header.findIndex(value => String(value || '').includes(`${Number(spec.date.slice(5, 7))}.${Number(spec.date.slice(8, 10))}日行驶里程`));
|
||||||
|
if (currentDayIndex < 0) throw new Error(`${sheetSpec.sheet} 未找到 ${spec.date} 日里程列`);
|
||||||
|
const trendStartIndex = currentDayIndex - 6;
|
||||||
|
const trendDates = header.slice(trendStartIndex, currentDayIndex + 1).map(label => trendDate(spec.date, label));
|
||||||
|
const detailRows = rows.slice(headerIndex + 1).filter(row => typeof row[0] === 'number');
|
||||||
|
const vehicles: MileageReportVehicle[] = detailRows.map(row => {
|
||||||
|
const rawStatus = String(row[1] || '在库');
|
||||||
|
const dailyMileage = roundMileage(valueNumber(row[currentDayIndex]));
|
||||||
|
const status = rawStatus === '租赁' || rawStatus === '自营' ? rawStatus : '在库';
|
||||||
|
return {
|
||||||
|
plate: String(row[2] || ''),
|
||||||
|
status,
|
||||||
|
customer: row[3] ? String(row[3]) : null,
|
||||||
|
department: status === '在库' || !row[4] ? null : String(row[4]),
|
||||||
|
inventoryRegion: null,
|
||||||
|
dailyMileage,
|
||||||
|
cumulativeMileage: roundMileage(valueNumber(row[13])),
|
||||||
|
completionRate: Math.round(valueNumber(row[17]) * 10000) / 100,
|
||||||
|
mileageBand: mileageBand(status, dailyMileage),
|
||||||
|
trend: trendDates.map((date, index) => ({
|
||||||
|
date,
|
||||||
|
totalMileage: roundMileage(valueNumber(row[trendStartIndex + index])),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const operating = vehicles.filter(vehicle => vehicle.mileageBand !== 'INVENTORY');
|
||||||
|
const inventory = vehicles.filter(vehicle => vehicle.mileageBand === 'INVENTORY');
|
||||||
|
const summaryRow = summary[sheetSpec.summaryRow];
|
||||||
|
const target = targets.find(item => item.target_name.includes(sheetSpec.targetMatch));
|
||||||
|
if (!target) throw new Error(`未找到考核目标:${sheetSpec.targetMatch}`);
|
||||||
|
const periods = String(summaryRow[5] || '').split(/\n+|\s{2,}/).map(value => value.trim()).filter(Boolean);
|
||||||
|
const trend = trendDates.map((date, index) => ({
|
||||||
|
date,
|
||||||
|
totalMileage: roundMileage(detailRows.reduce((total, row) => total + valueNumber(row[trendStartIndex + index]), 0)),
|
||||||
|
}));
|
||||||
|
const dailyMileage = roundMileage(vehicles.reduce((total, vehicle) => total + vehicle.dailyMileage, 0));
|
||||||
|
const yesterdayMileage = trend[trend.length - 2]?.totalMileage || 0;
|
||||||
|
const currentYearTarget = roundMileage(valueNumber(summaryRow[10]));
|
||||||
|
const currentYearCompleted = roundMileage(valueNumber(summaryRow[11]));
|
||||||
|
return {
|
||||||
|
targetId: target.id,
|
||||||
|
targetName: target.target_name,
|
||||||
|
displayName: sheetSpec.displayName,
|
||||||
|
vehicleCount: vehicles.length,
|
||||||
|
operatingCount: operating.length,
|
||||||
|
inventoryCount: inventory.length,
|
||||||
|
dailyMileage,
|
||||||
|
operatingMileage: roundMileage(operating.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)),
|
||||||
|
inventoryMileage: roundMileage(inventory.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)),
|
||||||
|
yesterdayMileage,
|
||||||
|
dayOverDayDelta: 0,
|
||||||
|
dayOverDayRate: null,
|
||||||
|
lowMileageCount: operating.filter(vehicle => vehicle.mileageBand === 'LOW').length,
|
||||||
|
highMileageCount: operating.filter(vehicle => vehicle.mileageBand === 'HIGH').length,
|
||||||
|
cumulativeMileage: roundMileage(valueNumber(summaryRow[3])),
|
||||||
|
totalMileageTarget: roundMileage(valueNumber(summaryRow[2])),
|
||||||
|
completionRate: Math.round(valueNumber(summaryRow[4]) * 10000) / 100,
|
||||||
|
qualifiedCount: valueNumber(summaryRow[7]),
|
||||||
|
halfQualifiedCount: valueNumber(summaryRow[8]),
|
||||||
|
currentYearTarget,
|
||||||
|
currentYearCompleted,
|
||||||
|
remainingMileage: roundMileage(valueNumber(summaryRow[12])),
|
||||||
|
dailyRequiredMileage: roundMileage(valueNumber(summaryRow[14])),
|
||||||
|
assessmentPeriods: periods,
|
||||||
|
trend,
|
||||||
|
inventoryRegions: inventoryBreakdown(spec.date, sheetSpec.displayName),
|
||||||
|
departments: breakdown(operating, 'department'),
|
||||||
|
vehicles: vehicles.sort((a, b) => b.dailyMileage - a.dailyMileage),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const qualityNotes = [
|
||||||
|
{ level: 'info' as const, message: '历史日报由已审核人工台账导入,车辆规模与当日台账保持一致。' },
|
||||||
|
];
|
||||||
|
if (spec.date === '2026-03-30') {
|
||||||
|
qualityNotes.push({ level: 'info', message: '136台冷链库存按车辆明细修正为45台;100km车辆按低里程口径统计。' });
|
||||||
|
}
|
||||||
|
qualityNotes.push({ level: 'info', message: '190台冷链累计里程采用190辆明细求和及汇总表值,忽略明细页失效的手工合计单元格。' });
|
||||||
|
|
||||||
|
const report = buildDailyMileageReport({
|
||||||
|
reportDate: spec.date,
|
||||||
|
status: 'ARCHIVED',
|
||||||
|
source: 'XLSX_IMPORT',
|
||||||
|
generatedAt: generatedAt(spec.date),
|
||||||
|
sourceUpdatedAt: null,
|
||||||
|
groups,
|
||||||
|
qualityNotes,
|
||||||
|
});
|
||||||
|
const inserted = await saveDailyReportSnapshot(report, 'XLSX_IMPORT', { replace });
|
||||||
|
console.log(`${spec.date}: ${inserted ? (replace ? 'replaced' : 'imported') : 'already exists'}; ${report.totals.vehicleCount} vehicles; ${report.totals.dailyMileage} km`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const replace = args.includes('--replace');
|
||||||
|
const specs = parseArgs(args.filter(value => value !== '--replace'));
|
||||||
|
if (specs.length === 0) throw new Error('请提供至少一个 YYYY-MM-DD:/path/file.xlsx 参数');
|
||||||
|
const [targets] = await pool.query<TargetRow[]>(
|
||||||
|
'SELECT id, target_name FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
for (const spec of specs) await importWorkbook(spec, targets);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
+88
-55
@@ -1,81 +1,114 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import { Truck, Route, Activity, Zap } from 'lucide-react';
|
import { Shell } from "./components/Shell";
|
||||||
import { Shell, type ModuleConfig } from './components/Shell';
|
import AuthProvider from "./auth/AuthProvider";
|
||||||
import AssetsModule from './modules/assets/AssetsModule';
|
import { useAuth } from "./auth/useAuth";
|
||||||
import MileageModule from './modules/mileage/MileageModule';
|
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
||||||
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
import PasswordLogin from "./auth/PasswordLogin";
|
||||||
import EnergyModule from './modules/energy/EnergyModule';
|
import { canAccessEnergy } from "./shared/auth/roles";
|
||||||
import EleImportPage from './modules/ele/EleImportPage';
|
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
|
||||||
import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
|
import { buildModules } from "./app/modules";
|
||||||
import AuthProvider from './auth/AuthProvider';
|
import {
|
||||||
import { useAuth } from './auth/useAuth';
|
normalizeBrowserPath,
|
||||||
import UnauthorizedPage from './auth/UnauthorizedPage';
|
readBrowserRoute,
|
||||||
import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles';
|
} from "./app/routing";
|
||||||
|
|
||||||
const BASE_MODULES: ModuleConfig[] = [
|
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
|
||||||
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
|
||||||
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
const HydrogenPrototypeBoard = lazy(
|
||||||
];
|
() =>
|
||||||
|
import("./vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiBoardApp").then(
|
||||||
const ENERGY_MODULE: ModuleConfig = {
|
({ EnergyBiBoardApp }) => ({ default: EnergyBiBoardApp }),
|
||||||
id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule,
|
),
|
||||||
};
|
);
|
||||||
|
normalizeBrowserPath();
|
||||||
const SCHEDULING_MODULE: ModuleConfig = {
|
|
||||||
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getRouteKey(): string {
|
|
||||||
if (typeof window === 'undefined') return '';
|
|
||||||
const path = window.location.pathname;
|
|
||||||
const hash = window.location.hash;
|
|
||||||
if (path === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') return 'ele/import';
|
|
||||||
if (path === '/admin/feedback' || hash === '#/admin/feedback' || hash === '#admin/feedback') return 'admin/feedback';
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthGate() {
|
function AuthGate() {
|
||||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
const { isLoading, isAuthenticated, error, user, mode } = useAuth();
|
||||||
const [routeKey, setRouteKey] = useState(getRouteKey);
|
const [route, setRoute] = useState(readBrowserRoute);
|
||||||
|
const { routeKey, pathSet } = route;
|
||||||
|
|
||||||
// 监听 hashchange / popstate,让 a href="#/..." 跳转能即时生效
|
// 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const update = () => setRouteKey(getRouteKey());
|
const update = () => {
|
||||||
window.addEventListener('hashchange', update);
|
setRoute(readBrowserRoute());
|
||||||
window.addEventListener('popstate', update);
|
};
|
||||||
|
window.addEventListener("hashchange", update);
|
||||||
|
window.addEventListener("popstate", update);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('hashchange', update);
|
window.removeEventListener("hashchange", update);
|
||||||
window.removeEventListener('popstate', update);
|
window.removeEventListener("popstate", update);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const modules = useMemo(() => {
|
useEffect(() => {
|
||||||
const result = [...BASE_MODULES];
|
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
|
||||||
if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE);
|
}, [pathSet]);
|
||||||
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
|
|
||||||
return result;
|
const modules = useMemo(
|
||||||
}, [user?.roles]);
|
() => buildModules(pathSet, user?.roles),
|
||||||
|
[pathSet, user?.roles],
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center">
|
<div className="min-h-screen bg-[var(--app-bg)] p-4 text-slate-800 md:p-8">
|
||||||
<div className="text-center">
|
<div className="mx-auto flex max-w-5xl flex-col gap-4">
|
||||||
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
|
<div className="rounded-2xl border border-white/70 bg-white/86 p-5 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
|
||||||
<p className="text-xs text-slate-400 font-bold">正在验证身份...</p>
|
<div className="mb-3 text-[11px] font-black text-blue-600">LN BI ACCESS</div>
|
||||||
|
<div className="text-xl font-black text-slate-950">正在进入羚牛氢能 BI</div>
|
||||||
|
<div className="mt-2 text-xs font-bold text-slate-400">校验登录态、权限与本地开发环境配置</div>
|
||||||
|
</div>
|
||||||
|
<SurfaceCard className="p-4">
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
{[0, 1, 2, 3].map(item => <SkeletonBlock key={item} className="h-24" />)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<LoadingState label="正在验证身份" />
|
||||||
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
if (mode === 'password') return <PasswordLogin />;
|
||||||
return <UnauthorizedPage message={error || undefined} />;
|
return <UnauthorizedPage message={error || undefined} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
||||||
if (routeKey === 'ele/import') return <EleImportPage />;
|
if (routeKey === "ele/import") {
|
||||||
if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
|
return (
|
||||||
|
<Suspense fallback={<LoadingState label="正在加载导入工作台" />}>
|
||||||
|
<EleImportPage />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (routeKey === "admin/feedback") {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingState label="正在加载反馈后台" />}>
|
||||||
|
<FeedbackAdminPage />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (routeKey === "energy/hydrogen-board") {
|
||||||
|
if (!canAccessEnergy(user?.roles)) {
|
||||||
|
return <UnauthorizedPage message="无能源管理模块访问权限" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingState label="正在加载氢能经营看板" />}>
|
||||||
|
<HydrogenPrototypeBoard />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return <Shell modules={modules} />;
|
// /energy 整组按能源权限控制
|
||||||
|
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
|
||||||
|
return <UnauthorizedPage message="无能源管理模块访问权限" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// key={pathSet} 让两套底栏切换时 Shell 内部 state 重置,避免残留旧 activeModule
|
||||||
|
return <Shell key={pathSet} modules={modules} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildModules } from './modules.js';
|
||||||
|
|
||||||
|
function moduleIds(roles?: string[]): string[] {
|
||||||
|
return buildModules('asset', roles).map(module => module.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function heatmapChildIds(roles?: string[]): string[] {
|
||||||
|
const heatmap = buildModules('asset', roles).find(module => module.id === 'heatmap');
|
||||||
|
return heatmap?.children?.map(module => module.id) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
test('keeps the base asset navigation and role-gated scheduling module', () => {
|
||||||
|
assert.deepEqual(moduleIds(), ['assets', 'mileage', 'heatmap']);
|
||||||
|
assert.deepEqual(moduleIds(['BI-SCHEDULE-OPT']), [
|
||||||
|
'assets',
|
||||||
|
'mileage',
|
||||||
|
'heatmap',
|
||||||
|
'scheduling',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps energy heatmap visibility and the independent energy navigation', () => {
|
||||||
|
assert.deepEqual(heatmapChildIds(), ['vehicle-heatmap']);
|
||||||
|
assert.deepEqual(heatmapChildIds(['BI-LEADER-ENERGY']), [
|
||||||
|
'hydrogen-heatmap',
|
||||||
|
'vehicle-heatmap',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(heatmapChildIds(['所有权限']), [
|
||||||
|
'hydrogen-heatmap',
|
||||||
|
'vehicle-heatmap',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
buildModules('energy', []).map(module => module.id),
|
||||||
|
['hydrogen', 'electric', 'etc'],
|
||||||
|
);
|
||||||
|
assert.equal(buildModules('energy', [])[0]?.label, '氢费BI');
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Fuel,
|
||||||
|
MapPinned,
|
||||||
|
Route,
|
||||||
|
Truck,
|
||||||
|
Wallet,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { ModuleConfig } from '../components/Shell';
|
||||||
|
import { canAccessEnergy, canAccessScheduling } from '../shared/auth/roles';
|
||||||
|
import type { PathSet } from './routing';
|
||||||
|
|
||||||
|
const AssetsModule = lazy(() => import('../modules/assets/AssetsModule'));
|
||||||
|
const MileageModule = lazy(() => import('../modules/mileage/MileageModule'));
|
||||||
|
const SchedulingModule = lazy(() => import('../modules/scheduling/SchedulingModule'));
|
||||||
|
const HydrogenModule = lazy(() => import('../modules/energy/HydrogenModule'));
|
||||||
|
const ElectricModule = lazy(() => import('../modules/energy/ElectricModule'));
|
||||||
|
const EtcModule = lazy(() => import('../modules/energy/EtcModule'));
|
||||||
|
const VehicleHeatmapModule = lazy(() => import('../modules/vehicle-heatmap/VehicleHeatmapModule'));
|
||||||
|
const HydrogenHeatmapModule = lazy(() => import('../modules/hydrogen-heatmap/HydrogenHeatmapModule'));
|
||||||
|
|
||||||
|
const ASSET_MODULES: ModuleConfig[] = [
|
||||||
|
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
|
||||||
|
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VEHICLE_HEATMAP_MODULE: ModuleConfig = {
|
||||||
|
id: 'vehicle-heatmap',
|
||||||
|
label: '车辆',
|
||||||
|
icon: MapPinned,
|
||||||
|
component: VehicleHeatmapModule,
|
||||||
|
};
|
||||||
|
|
||||||
|
const HYDROGEN_HEATMAP_MODULE: ModuleConfig = {
|
||||||
|
id: 'hydrogen-heatmap',
|
||||||
|
label: '加氢',
|
||||||
|
icon: MapPinned,
|
||||||
|
component: HydrogenHeatmapModule,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCHEDULING_MODULE: ModuleConfig = {
|
||||||
|
id: 'scheduling',
|
||||||
|
label: '智能调度',
|
||||||
|
icon: Activity,
|
||||||
|
component: SchedulingModule,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENERGY_MODULES: ModuleConfig[] = [
|
||||||
|
{ id: 'hydrogen', label: '氢费BI', icon: Fuel, component: HydrogenModule },
|
||||||
|
{ id: 'electric', label: '电能', icon: Zap, component: ElectricModule },
|
||||||
|
{ id: 'etc', label: 'ETC', icon: Wallet, component: EtcModule },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 根据主路径和角色生成导航;返回新数组,避免调用方修改静态注册表。 */
|
||||||
|
export function buildModules(
|
||||||
|
pathSet: PathSet,
|
||||||
|
roles: readonly string[] | null | undefined,
|
||||||
|
): ModuleConfig[] {
|
||||||
|
if (pathSet === 'energy') return [...ENERGY_MODULES];
|
||||||
|
|
||||||
|
const heatmapChildren = canAccessEnergy(roles)
|
||||||
|
? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE]
|
||||||
|
: [VEHICLE_HEATMAP_MODULE];
|
||||||
|
const modules: ModuleConfig[] = [
|
||||||
|
...ASSET_MODULES,
|
||||||
|
{ id: 'heatmap', label: '热力图', icon: MapPinned, children: heatmapChildren },
|
||||||
|
];
|
||||||
|
if (canAccessScheduling(roles)) modules.push(SCHEDULING_MODULE);
|
||||||
|
return modules;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { getPathSet, getRouteKey, resolveCanonicalUrl } from './routing.js';
|
||||||
|
|
||||||
|
test('normalizes legacy asset paths without losing search parameters', () => {
|
||||||
|
assert.equal(resolveCanonicalUrl({ pathname: '/mileage', search: '?date=2026-08-12', hash: '' }), '/asset?date=2026-08-12#mileage');
|
||||||
|
assert.equal(resolveCanonicalUrl({ pathname: '/unknown', search: '?x=1', hash: '#mileage' }), '/asset?x=1#mileage');
|
||||||
|
});
|
||||||
|
test('keeps canonical and hidden administration paths unchanged', () => {
|
||||||
|
assert.equal(resolveCanonicalUrl({ pathname: '/asset', search: '', hash: '#assets' }), null);
|
||||||
|
assert.equal(resolveCanonicalUrl({ pathname: '/admin/feedback', search: '', hash: '' }), null);
|
||||||
|
assert.equal(resolveCanonicalUrl({ pathname: '/energy/hydrogen-board', search: '', hash: '' }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('derives path groups and hidden routes from path or hash', () => {
|
||||||
|
assert.equal(getPathSet('/energy'), 'energy');
|
||||||
|
assert.equal(getPathSet('/energy/hydrogen-board'), 'energy');
|
||||||
|
assert.equal(getPathSet('/asset'), 'asset');
|
||||||
|
assert.equal(getRouteKey('/energy/hydrogen-board', ''), 'energy/hydrogen-board');
|
||||||
|
assert.equal(getRouteKey('/asset', '#/ele/import'), 'ele/import');
|
||||||
|
assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback');
|
||||||
|
assert.equal(getRouteKey('/asset', '#mileage'), '');
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export type PathSet = 'asset' | 'energy';
|
||||||
|
|
||||||
|
interface BrowserLocation {
|
||||||
|
pathname: string;
|
||||||
|
search: string;
|
||||||
|
hash: string;
|
||||||
|
}
|
||||||
|
const ROOT_PATHS = new Set([
|
||||||
|
'/asset',
|
||||||
|
'/energy',
|
||||||
|
'/energy/hydrogen-board',
|
||||||
|
'/ele/import',
|
||||||
|
'/admin/feedback',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const LEGACY_PATHS: Record<string, { path: string; hash?: string }> = {
|
||||||
|
'/': { path: '/asset' },
|
||||||
|
'/vehicle': { path: '/asset', hash: 'assets' },
|
||||||
|
'/assets': { path: '/asset', hash: 'assets' },
|
||||||
|
'/mileage': { path: '/asset', hash: 'mileage' },
|
||||||
|
'/scheduling': { path: '/asset', hash: 'scheduling' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 返回旧地址应替换成的规范地址;主路径返回 null。 */
|
||||||
|
export function resolveCanonicalUrl(location: BrowserLocation): string | null {
|
||||||
|
if (ROOT_PATHS.has(location.pathname)) return null;
|
||||||
|
|
||||||
|
const target = LEGACY_PATHS[location.pathname] ?? { path: '/asset' };
|
||||||
|
const finalHash = target.hash ? `#${target.hash}` : location.hash;
|
||||||
|
return `${target.path}${location.search}${finalHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBrowserPath(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const target = resolveCanonicalUrl(window.location);
|
||||||
|
if (target) window.history.replaceState(null, '', target);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPathSet(pathname: string): PathSet {
|
||||||
|
return pathname === '/energy' || pathname === '/energy/hydrogen-board' ? 'energy' : 'asset';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRouteKey(pathname: string, hash: string): string {
|
||||||
|
if (pathname === '/energy/hydrogen-board') {
|
||||||
|
return 'energy/hydrogen-board';
|
||||||
|
}
|
||||||
|
if (pathname === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') {
|
||||||
|
return 'ele/import';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
pathname === '/admin/feedback'
|
||||||
|
|| hash === '#/admin/feedback'
|
||||||
|
|| hash === '#admin/feedback'
|
||||||
|
) {
|
||||||
|
return 'admin/feedback';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBrowserRoute(): { routeKey: string; pathSet: PathSet } {
|
||||||
|
return {
|
||||||
|
routeKey: getRouteKey(window.location.pathname, window.location.hash),
|
||||||
|
pathSet: getPathSet(window.location.pathname),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { setTokenGetter } from './api-client';
|
|||||||
const AUTH_API = '/api/auth';
|
const AUTH_API = '/api/auth';
|
||||||
|
|
||||||
export default function AuthProvider({ children }: { children: ReactNode }) {
|
export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [mode, setMode] = useState<'sso' | 'password'>('sso');
|
||||||
const [state, setState] = useState<AuthState>({
|
const [state, setState] = useState<AuthState>({
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
@@ -19,8 +20,6 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setTokenGetter(() => tokenRef.current);
|
setTokenGetter(() => tokenRef.current);
|
||||||
|
|
||||||
// 防止 StrictMode 双重调用(jumpToken 一次性使用)
|
// 防止 StrictMode 双重调用(jumpToken 一次性使用)
|
||||||
if (authStarted.current) return;
|
|
||||||
authStarted.current = true;
|
|
||||||
|
|
||||||
// 监听 401 事件
|
// 监听 401 事件
|
||||||
const onUnauthorized = () => {
|
const onUnauthorized = () => {
|
||||||
@@ -30,14 +29,25 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
window.addEventListener('auth:unauthorized', onUnauthorized);
|
window.addEventListener('auth:unauthorized', onUnauthorized);
|
||||||
|
|
||||||
authenticate();
|
if (!authStarted.current) { authStarted.current = true; authenticate(); }
|
||||||
|
|
||||||
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
|
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function authenticate() {
|
async function authenticate() {
|
||||||
|
let currentMode: 'sso' | 'password';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${AUTH_API}/config`, { cache: 'no-store' });
|
||||||
|
const config = await response.json();
|
||||||
|
if (!response.ok || !['sso', 'password'].includes(config.mode)) throw new Error();
|
||||||
|
currentMode = config.mode;
|
||||||
|
setMode(currentMode);
|
||||||
|
} catch {
|
||||||
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: '无法获取验证配置,请刷新重试' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
|
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
|
||||||
if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
if (currentMode === 'sso' && import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
||||||
setState({
|
setState({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
@@ -59,15 +69,15 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
tokenRef.current = savedToken;
|
tokenRef.current = savedToken;
|
||||||
// 验证 token 是否仍然有效(尝试请求 health)
|
// 验证 token 是否仍然有效(尝试请求 health)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/health', {
|
const res = await fetch(`${AUTH_API}/me`, {
|
||||||
headers: { Authorization: `Bearer ${savedToken}` },
|
headers: { Authorization: `Bearer ${savedToken}` },
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const savedUser = sessionStorage.getItem('bi_user');
|
const savedUser = await res.json();
|
||||||
setState({
|
setState({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
user: savedUser ? JSON.parse(savedUser) : null,
|
user: savedUser,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -75,6 +85,12 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
} catch { /* token 无效,继续流程 */ }
|
} catch { /* token 无效,继续流程 */ }
|
||||||
sessionStorage.removeItem('bi_jwt');
|
sessionStorage.removeItem('bi_jwt');
|
||||||
sessionStorage.removeItem('bi_user');
|
sessionStorage.removeItem('bi_user');
|
||||||
|
tokenRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentMode === 'password') {
|
||||||
|
setState({ isLoading: false, isAuthenticated: false, user: null, error: null });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 从 URL 提取 jumpToken
|
// 2. 从 URL 提取 jumpToken
|
||||||
@@ -119,8 +135,18 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loginWithPassword(password: string) {
|
||||||
|
const response = await fetch(`${AUTH_API}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || !data.token) throw new Error(data.message || '登录失败');
|
||||||
|
tokenRef.current = data.token;
|
||||||
|
sessionStorage.setItem('bi_jwt', data.token);
|
||||||
|
sessionStorage.setItem('bi_user', JSON.stringify(data.user));
|
||||||
|
setState({ isLoading: false, isAuthenticated: true, user: data.user, error: null });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={state}>
|
<AuthContext.Provider value={{ ...state, mode, loginWithPassword }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuth } from './useAuth';
|
||||||
|
|
||||||
|
export default function PasswordLogin() {
|
||||||
|
const { loginWithPassword, error: sessionError } = useAuth();
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
return <main className="min-h-screen flex items-center justify-center bg-slate-950 p-6">
|
||||||
|
<form className="w-full max-w-sm rounded-2xl bg-white p-7 shadow-xl" onSubmit={async event => {
|
||||||
|
event.preventDefault(); if (busy || !loginWithPassword) return;
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try { await loginWithPassword(password); } catch (e) { setError(e instanceof Error ? e.message : '登录失败,请重试'); }
|
||||||
|
finally { setBusy(false); setPassword(''); }
|
||||||
|
}}>
|
||||||
|
<p className="text-xs font-semibold text-blue-600">羚牛 · 能源 BI</p>
|
||||||
|
<h1 className="mt-2 text-2xl font-bold text-slate-900">访问看板</h1>
|
||||||
|
<p className="mt-2 mb-6 text-sm text-slate-500">请输入管理员提供的访问密码。此入口仅提供只读访问。</p>
|
||||||
|
<label htmlFor="bi-password" className="text-sm font-medium text-slate-700">访问密码</label>
|
||||||
|
<input id="bi-password" type="password" autoComplete="current-password" required maxLength={1024} value={password} onChange={e => setPassword(e.target.value)} className="mt-2 w-full rounded-lg border border-slate-300 px-3 py-3 text-base focus:outline-blue-600" />
|
||||||
|
{error || sessionError ? <p role="alert" className="mt-3 text-sm text-red-600">{error || sessionError}</p> : null}
|
||||||
|
<button disabled={busy || !password} className="mt-5 min-h-11 w-full rounded-lg bg-blue-600 px-4 py-3 font-semibold text-white disabled:opacity-50">{busy ? '正在验证…' : '进入看板'}</button>
|
||||||
|
</form>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
import { ShieldX, Monitor, Smartphone } from 'lucide-react';
|
import { ShieldX, Monitor, Smartphone } from 'lucide-react';
|
||||||
|
import { PageFrame, SurfaceCard } from '../components/ui/surface';
|
||||||
|
|
||||||
export default function UnauthorizedPage({ message }: { message?: string }) {
|
export default function UnauthorizedPage({ message }: { message?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] flex items-center justify-center p-6">
|
<PageFrame
|
||||||
<div className="text-center max-w-sm">
|
title="未授权访问"
|
||||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-slate-100 flex items-center justify-center">
|
subtitle={message || '获取用户认证信息失败,可能是跳转令牌已过期或无效。'}
|
||||||
<ShieldX size={36} className="text-slate-400" />
|
icon={ShieldX}
|
||||||
|
eyebrow="ACCESS CONTROL"
|
||||||
|
meta="请从授权入口重新进入"
|
||||||
|
maxWidth="max-w-xl"
|
||||||
|
>
|
||||||
|
<SurfaceCard className="p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
||||||
|
<ShieldX size={30} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-lg font-black text-slate-800 mb-2">未授权访问</h1>
|
<p className="mb-4 text-[10px] font-bold uppercase tracking-wider text-slate-400">请通过以下方式进入</p>
|
||||||
<p className="text-xs text-slate-400 mb-4">
|
</div>
|
||||||
{message || '获取用户认证信息失败,可能是跳转令牌已过期或无效'}
|
<div className="space-y-3">
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 p-4 text-left space-y-3">
|
|
||||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider text-center">请通过以下方式进入</p>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3 p-2.5 rounded-xl bg-slate-50">
|
<div className="flex items-start gap-3 p-2.5 rounded-xl bg-slate-50">
|
||||||
<Monitor size={16} className="text-blue-500 flex-shrink-0 mt-0.5" />
|
<Monitor size={16} className="text-blue-500 flex-shrink-0 mt-0.5" />
|
||||||
@@ -31,7 +36,7 @@ export default function UnauthorizedPage({ message }: { message?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SurfaceCard>
|
||||||
</div>
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
|
mode?: 'sso' | 'password';
|
||||||
|
loginWithPassword?: (password: string) => Promise<void>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
+36
-350
@@ -1,24 +1,15 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
|
||||||
import {
|
|
||||||
MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles,
|
|
||||||
ImagePlus, Loader2, Inbox, Lightbulb, Bug, Palette, NotebookPen, Settings2,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { fetchJson } from '../auth/api-client';
|
import { fetchJson } from '../auth/api-client';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { canManageFeedback } from '../shared/auth/roles';
|
import { canManageFeedback } from '../shared/auth/roles';
|
||||||
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
|
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
|
||||||
import RotatingFooterHint from './RotatingFooterHint';
|
import FeedbackLauncher from './feedback/FeedbackLauncher';
|
||||||
|
import FeedbackModal from './feedback/FeedbackModal';
|
||||||
|
import type { FeedbackStep, FeedbackType, UploadedImg } from './feedback/types';
|
||||||
|
|
||||||
const MAX_SCREENSHOTS = 6;
|
const MAX_SCREENSHOTS = 6;
|
||||||
const MAX_IMG_SIZE_MB = 5;
|
const MAX_IMG_SIZE_MB = 5;
|
||||||
|
|
||||||
interface UploadedImg {
|
|
||||||
url: string;
|
|
||||||
thumbDataUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadImage(file: File): Promise<string> {
|
async function uploadImage(file: File): Promise<string> {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
@@ -42,38 +33,6 @@ function readAsDataUrl(file: File): Promise<string> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
|
|
||||||
|
|
||||||
interface TypeOption {
|
|
||||||
key: FeedbackType;
|
|
||||||
icon: LucideIcon;
|
|
||||||
label: string;
|
|
||||||
sub: string;
|
|
||||||
iconBg: string;
|
|
||||||
iconFg: string;
|
|
||||||
ring: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TYPE_OPTIONS: TypeOption[] = [
|
|
||||||
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
|
|
||||||
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
|
|
||||||
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
|
|
||||||
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
|
|
||||||
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
|
|
||||||
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
|
|
||||||
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
|
|
||||||
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const MODULE_LABELS: Record<string, string> = {
|
|
||||||
assets: '资产管理',
|
|
||||||
mileage: '里程管理',
|
|
||||||
energy: '能源管理',
|
|
||||||
scheduling: '智能调度',
|
|
||||||
ele: '充电导入',
|
|
||||||
'': '通用',
|
|
||||||
};
|
|
||||||
|
|
||||||
function detectModule(): string {
|
function detectModule(): string {
|
||||||
const hash = (window.location.hash || '').slice(1);
|
const hash = (window.location.hash || '').slice(1);
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
@@ -94,7 +53,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [step, setStep] = useState<1 | 2 | 3>(1); // 1=选类型, 2=写内容, 3=成功页
|
const [step, setStep] = useState<FeedbackStep>(1);
|
||||||
const [type, setType] = useState<FeedbackType | null>(null);
|
const [type, setType] = useState<FeedbackType | null>(null);
|
||||||
const [mod, setMod] = useState<string>('');
|
const [mod, setMod] = useState<string>('');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
@@ -128,14 +87,12 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Open: detect current module
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && step === 1) {
|
if (open && step === 1) {
|
||||||
setMod(moduleProp ?? detectModule());
|
setMod(moduleProp ?? detectModule());
|
||||||
}
|
}
|
||||||
}, [open, step, moduleProp]);
|
}, [open, step, moduleProp]);
|
||||||
|
|
||||||
// Lock scroll when open
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const prev = document.body.style.overflow;
|
const prev = document.body.style.overflow;
|
||||||
@@ -153,7 +110,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
|||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setTimeout(reset, 300); // 等动画完
|
setTimeout(reset, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
@@ -193,319 +150,48 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const back = () => setStep((Math.max(1, step - 1)) as typeof step);
|
const back = () => setStep((Math.max(1, step - 1)) as FeedbackStep);
|
||||||
|
|
||||||
const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true;
|
const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true;
|
||||||
const progress = step >= 3 ? 100 : (step / 2) * 100;
|
const progress = step >= 3 ? 100 : (step / 2) * 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Floating Action Button */}
|
<FeedbackLauncher
|
||||||
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
|
isAdmin={isAdmin}
|
||||||
<motion.button
|
menuOpen={menuOpen}
|
||||||
initial={{ scale: 0, opacity: 0 }}
|
onCloseMenu={() => setMenuOpen(false)}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
onOpenFeedback={() => { setMenuOpen(false); setOpen(true); }}
|
||||||
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
|
onOpenHistory={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
||||||
whileHover={{ scale: 1.08 }}
|
onToggleMenu={() => setMenuOpen(current => !current)}
|
||||||
whileTap={{ scale: 0.92 }}
|
|
||||||
onClick={() => setMenuOpen(m => !m)}
|
|
||||||
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
|
|
||||||
aria-label="反馈"
|
|
||||||
title="提建议 / 我的反馈"
|
|
||||||
>
|
|
||||||
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
|
|
||||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
|
|
||||||
</motion.button>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{menuOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.9, y: 8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.9, y: 8 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => { setMenuOpen(false); setOpen(true); }}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
|
|
||||||
>
|
|
||||||
<Sparkles size={14} className="text-blue-500" /> 提个建议
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
|
|
||||||
>
|
|
||||||
<Inbox size={14} className="text-emerald-500" /> 我的反馈
|
|
||||||
</button>
|
|
||||||
{isAdmin && (
|
|
||||||
<>
|
|
||||||
<div className="h-px bg-slate-100 my-0.5" />
|
|
||||||
<a
|
|
||||||
href="#/admin/feedback"
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
|
|
||||||
>
|
|
||||||
<Settings2 size={14} className="text-violet-500" /> 反馈管理
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* 点击外面关菜单 */}
|
|
||||||
{menuOpen && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[-1]"
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||||
|
|
||||||
{/* Modal */}
|
<FeedbackModal
|
||||||
<AnimatePresence>
|
canNext={canNext}
|
||||||
{open && (
|
content={content}
|
||||||
<motion.div
|
error={error}
|
||||||
initial={{ opacity: 0 }}
|
fileRef={fileRef}
|
||||||
animate={{ opacity: 1 }}
|
maxScreenshots={MAX_SCREENSHOTS}
|
||||||
exit={{ opacity: 0 }}
|
module={mod}
|
||||||
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
open={open}
|
||||||
// 不允许点击背景关闭:避免用户输入到一半误触遮罩丢失内容
|
progress={progress}
|
||||||
>
|
screenshots={shots}
|
||||||
<motion.div
|
step={step}
|
||||||
initial={{ y: '100%', opacity: 0 }}
|
submitting={submitting}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
textareaRef={taRef}
|
||||||
exit={{ y: '100%', opacity: 0 }}
|
type={type}
|
||||||
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
uploading={uploading}
|
||||||
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
onAddFiles={addFiles}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onBack={back}
|
||||||
>
|
onChangeContent={setContent}
|
||||||
{/* Drag handle */}
|
onChangeModule={setMod}
|
||||||
<div className="flex justify-center pt-2.5 pb-1">
|
onClose={close}
|
||||||
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
onNext={next}
|
||||||
</div>
|
onRemoveScreenshot={(index) => setShots(prev => prev.filter((_, current) => current !== index))}
|
||||||
|
onSelectType={(selectedType) => { setType(selectedType); setStep(2); }}
|
||||||
{/* Header + progress */}
|
|
||||||
<div className="px-4 pb-3 border-b border-slate-100">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
|
|
||||||
<Sparkles size={14} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-black text-slate-800 leading-tight">
|
|
||||||
{step === 3 ? '收到啦~' : '提个建议'}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-slate-400 font-bold">
|
|
||||||
{step === 1 && '第一步 / 共 2 步'}
|
|
||||||
{step === 2 && '第二步 / 共 2 步'}
|
|
||||||
{step === 3 && '感谢你的反馈'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={close} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
|
|
||||||
<motion.div
|
|
||||||
initial={false}
|
|
||||||
animate={{ width: `${progress}%` }}
|
|
||||||
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
|
||||||
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{step === 1 && (
|
|
||||||
<motion.div key="s1" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }}>
|
|
||||||
<p className="text-[13px] font-bold text-slate-700 mb-1.5">想反馈点什么?</p>
|
|
||||||
<RotatingFooterHint className="justify-start mb-4" />
|
|
||||||
<div className="grid grid-cols-1 gap-2">
|
|
||||||
{TYPE_OPTIONS.map((opt, i) => {
|
|
||||||
const Icon = opt.icon;
|
|
||||||
const selected = type === opt.key;
|
|
||||||
return (
|
|
||||||
<motion.button
|
|
||||||
key={opt.key}
|
|
||||||
initial={{ opacity: 0, y: 6 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: i * 0.04, duration: 0.2 }}
|
|
||||||
whileHover={{ y: -1 }}
|
|
||||||
whileTap={{ scale: 0.99 }}
|
|
||||||
onClick={() => { setType(opt.key); setStep(2); }}
|
|
||||||
className={`text-left p-3.5 rounded-2xl border bg-white transition-all flex items-center gap-3 group ${selected ? `ring-2 ${opt.ring} border-transparent shadow-sm` : 'border-slate-100 hover:border-slate-200 hover:shadow-sm'}`}
|
|
||||||
>
|
|
||||||
<div className={`w-10 h-10 rounded-xl ${opt.iconBg} flex items-center justify-center flex-shrink-0`}>
|
|
||||||
<Icon size={18} className={opt.iconFg} strokeWidth={2.2} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-[13px] font-bold text-slate-800 leading-tight">{opt.label}</div>
|
|
||||||
<div className="text-[11px] text-slate-400 font-medium mt-0.5">{opt.sub}</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRight size={15} className="text-slate-300 flex-shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
|
|
||||||
</motion.button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
|
||||||
<motion.div key="s2" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }} className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-[12px] font-bold text-slate-600 mb-2">说说具体内容</p>
|
|
||||||
<textarea
|
|
||||||
ref={taRef}
|
|
||||||
value={content}
|
|
||||||
onChange={(e) => setContent(e.target.value)}
|
|
||||||
onPaste={(e) => {
|
|
||||||
const items = e.clipboardData?.items;
|
|
||||||
if (!items) return;
|
|
||||||
const imgs: File[] = [];
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
const it = items[i];
|
|
||||||
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
|
||||||
const f = it.getAsFile();
|
|
||||||
if (f) imgs.push(f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (imgs.length > 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
addFiles(imgs);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
rows={5}
|
|
||||||
maxLength={1000}
|
|
||||||
placeholder={
|
|
||||||
type === 'dimension' ? '比如:希望按客户/区域/日期范围 等等切片看里程数据…'
|
|
||||||
: type === 'bug' ? '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)'
|
|
||||||
: type === 'ux' ? '比如:能不能把外部 tab 默认收起,加载快一点…'
|
|
||||||
: '随便聊聊你的想法'
|
|
||||||
}
|
|
||||||
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
|
|
||||||
/>
|
|
||||||
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 截图上传 */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
|
||||||
<p className="text-[10px] font-bold text-slate-400 uppercase">截图(可选)</p>
|
|
||||||
<span className="text-[10px] text-slate-300 font-bold">{shots.length}/{MAX_SCREENSHOTS},可粘贴</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={fileRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{shots.map((s, i) => (
|
|
||||||
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
|
|
||||||
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
|
|
||||||
<button
|
|
||||||
onClick={() => setShots(prev => prev.filter((_, idx) => idx !== i))}
|
|
||||||
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
|
|
||||||
>
|
|
||||||
<X size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{shots.length < MAX_SCREENSHOTS && (
|
|
||||||
<button
|
|
||||||
onClick={() => fileRef.current?.click()}
|
|
||||||
disabled={uploading}
|
|
||||||
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
|
|
||||||
>
|
|
||||||
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
|
|
||||||
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">所在板块</p>
|
|
||||||
<div className="flex gap-1 flex-wrap">
|
|
||||||
{Object.entries(MODULE_LABELS).map(([k, label]) => (
|
|
||||||
<button
|
|
||||||
key={k}
|
|
||||||
onClick={() => setMod(k)}
|
|
||||||
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${mod === k ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 3 && (
|
|
||||||
<motion.div key="s3" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.3 }} className="text-center py-6">
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0, rotate: -180 }}
|
|
||||||
animate={{ scale: 1, rotate: 0 }}
|
|
||||||
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
|
|
||||||
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
|
|
||||||
>
|
|
||||||
<Check size={28} strokeWidth={3} className="text-white" />
|
|
||||||
</motion.div>
|
|
||||||
<div className="text-base font-black text-slate-800 mb-1">谢谢你的反馈 ❤️</div>
|
|
||||||
<div className="text-[12px] text-slate-500 font-bold leading-relaxed">产品同学会认真看每一条<br />有进展可以在「我的反馈」里查看</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
{step < 3 && (
|
|
||||||
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
|
|
||||||
{step > 1 && (
|
|
||||||
<button
|
|
||||||
onClick={back}
|
|
||||||
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={14} /> 上一步
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="flex-1" />
|
|
||||||
<button
|
|
||||||
onClick={next}
|
|
||||||
disabled={!canNext || submitting}
|
|
||||||
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{submitting ? '提交中…' : step === 2 ? '提交' : '下一步'}
|
|
||||||
{!submitting && step !== 2 && <ChevronRight size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{step === 3 && (
|
|
||||||
<div className="px-4 py-3 border-t border-slate-100">
|
|
||||||
<button
|
|
||||||
onClick={close}
|
|
||||||
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
|
|
||||||
>
|
|
||||||
完成
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+142
-50
@@ -1,71 +1,91 @@
|
|||||||
import { useState, useEffect, useMemo, type ComponentType } from 'react';
|
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { Building2, ChevronRight } from 'lucide-react';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { DemoModeProvider } from './Blur';
|
import { DemoModeProvider } from './Blur';
|
||||||
import FeedbackFab from './FeedbackFab';
|
import FeedbackFab from './FeedbackFab';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
import { LoadingState } from './ui/surface';
|
||||||
|
|
||||||
export interface ModuleConfig {
|
export interface ModuleConfig {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: ComponentType<{ size?: number; className?: string }>;
|
icon: ComponentType<{ size?: number; className?: string }>;
|
||||||
component: ComponentType;
|
component?: ElementType;
|
||||||
|
children?: ModuleConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** path 到模块 id 的映射 */
|
function flattenModules(modules: ModuleConfig[]): ModuleConfig[] {
|
||||||
const PATH_MAP: Record<string, string> = {
|
return modules.flatMap((module) => module.children ?? [module]);
|
||||||
'/vehicle': 'assets',
|
}
|
||||||
'/assets': 'assets',
|
|
||||||
'/mileage': 'mileage',
|
/** hash 一级段(`#<id>` 或 `#<id>/<sub>` 都只取 id) */
|
||||||
'/scheduling': 'scheduling',
|
function getHashHead(): string {
|
||||||
'/energy': 'energy',
|
return window.location.hash.slice(1).split('/')[0];
|
||||||
};
|
}
|
||||||
|
|
||||||
function getInitialModule(modules: ModuleConfig[]): string {
|
function getInitialModule(modules: ModuleConfig[]): string {
|
||||||
// 优先看 hash
|
const head = getHashHead();
|
||||||
const hash = window.location.hash.slice(1);
|
if (modules.some((m) => m.id === head)) return head;
|
||||||
if (modules.some((m) => m.id === hash)) return hash;
|
|
||||||
// 再看 pathname
|
|
||||||
const pathModule = PATH_MAP[window.location.pathname];
|
|
||||||
if (pathModule && modules.some((m) => m.id === pathModule)) return pathModule;
|
|
||||||
// 默认第一个
|
|
||||||
return modules[0]?.id ?? '';
|
return modules[0]?.id ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHashModule(modules: ModuleConfig[]): string {
|
function getHashModule(modules: ModuleConfig[]): string {
|
||||||
const hash = window.location.hash.slice(1);
|
const head = getHashHead();
|
||||||
return modules.some((m) => m.id === hash) ? hash : '';
|
return modules.some((m) => m.id === head) ? head : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||||
const [activeModule, setActiveModule] = useState(() => getInitialModule(modules));
|
const flatModules = useMemo(() => flattenModules(modules), [modules]);
|
||||||
|
const [activeModule, setActiveModule] = useState(() => getInitialModule(flattenModules(modules)));
|
||||||
|
const [openGroupId, setOpenGroupId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onHashChange = () => {
|
const onHashChange = () => {
|
||||||
const h = getHashModule(modules);
|
const h = getHashModule(flatModules);
|
||||||
if (h) setActiveModule(h);
|
if (h) setActiveModule(h);
|
||||||
};
|
};
|
||||||
window.addEventListener('hashchange', onHashChange);
|
window.addEventListener('hashchange', onHashChange);
|
||||||
return () => window.removeEventListener('hashchange', onHashChange);
|
return () => window.removeEventListener('hashchange', onHashChange);
|
||||||
}, [modules]);
|
}, [flatModules]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 同步 hash 到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
if (!openGroupId) return undefined;
|
||||||
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (window.location.hash.slice(1) !== activeModule) {
|
if (event.key === 'Escape') setOpenGroupId(null);
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [openGroupId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 同步 hash 一段到当前模块:使用 replaceState 避免产生多余的 history 记录,
|
||||||
|
// 否则在小程序/webview 环境下首次进入需要点两次返回才能退出。
|
||||||
|
// 注意只比对一级段,避免把子模块写入的 `#<id>/<sub>` 二级段抹掉。
|
||||||
|
if (getHashHead() !== activeModule) {
|
||||||
const { pathname, search } = window.location;
|
const { pathname, search } = window.location;
|
||||||
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
|
window.history.replaceState(null, '', `${pathname}${search}#${activeModule}`);
|
||||||
}
|
}
|
||||||
}, [activeModule]);
|
}, [activeModule]);
|
||||||
|
|
||||||
const switchModule = (id: string) => {
|
const switchModule = (id: string) => {
|
||||||
if (window.location.hash.slice(1) === id) return;
|
setOpenGroupId(null);
|
||||||
|
if (getHashHead() === id) {
|
||||||
|
setActiveModule(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { pathname, search } = window.location;
|
const { pathname, search } = window.location;
|
||||||
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
window.history.replaceState(null, '', `${pathname}${search}#${id}`);
|
||||||
setActiveModule(id);
|
setActiveModule(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
|
const ActiveComponent = flatModules.find((module) => module.id === activeModule)?.component ?? flatModules[0]?.component;
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
|
||||||
|
// 氢能经营看板已在模块内提供原型一致的标题与导航,避免桌面端重复显示全局面包屑。
|
||||||
|
const showDesktopBreadcrumb = activeModule !== 'hydrogen';
|
||||||
|
const isHydrogenPrototype = activeModule === 'hydrogen';
|
||||||
const watermarkText = useMemo(() => {
|
const watermarkText = useMemo(() => {
|
||||||
const name = user?.userName || '未登录';
|
const name = user?.userName || '未登录';
|
||||||
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
|
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
|
||||||
@@ -74,58 +94,130 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DemoModeProvider enabled={false}>
|
<DemoModeProvider enabled={false}>
|
||||||
<div className="flex min-h-screen">
|
<div className="enterprise-grid-bg flex min-h-screen">
|
||||||
{/* 全局水印 */}
|
{/* 氢费看板按原型交付;其他模块继续保留全局水印。 */}
|
||||||
|
{!isHydrogenPrototype ? (
|
||||||
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
||||||
<div className="absolute inset-0" style={{
|
<div className="absolute inset-0" style={{
|
||||||
backgroundImage: `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' width='320' height='200'><text x='50%' y='50%' text-anchor='middle' dominant-baseline='middle' font-size='14' font-family='sans-serif' fill='%23000' transform='rotate(-25 160 100)'>${watermarkText}</text></svg>`)}")`,
|
backgroundImage: `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' width='320' height='200'><text x='50%' y='50%' text-anchor='middle' dominant-baseline='middle' font-size='14' font-family='sans-serif' fill='%23000' transform='rotate(-25 160 100)'>${watermarkText}</text></svg>`)}")`,
|
||||||
backgroundRepeat: 'repeat',
|
backgroundRepeat: 'repeat',
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
{/* Web 侧边栏 (md 及以上) */}
|
) : null}
|
||||||
<nav className="hidden md:flex flex-col items-center w-16 bg-white border-r border-gray-100 fixed top-0 left-0 h-full z-50 py-6 gap-2">
|
{/* Web 侧边栏 (md 及以上):能源各模块使用同一套规格,以电能为基准。 */}
|
||||||
|
<nav className="fixed left-0 top-0 z-50 hidden h-full w-20 flex-col items-center border-r border-white/10 bg-slate-950 px-2 py-4 text-white shadow-[12px_0_40px_rgba(15,23,42,0.16)] md:flex">
|
||||||
|
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-blue-600 shadow-lg shadow-blue-950/20">
|
||||||
|
<Building2 size={22} />
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-1 flex-col items-center gap-2">
|
||||||
{modules.map((m) => {
|
{modules.map((m) => {
|
||||||
const Icon = m.icon;
|
const Icon = m.icon;
|
||||||
const isActive = m.id === activeModule;
|
const isGroup = Boolean(m.children?.length);
|
||||||
|
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
|
||||||
|
const isOpen = openGroupId === m.id;
|
||||||
return (
|
return (
|
||||||
|
<div key={m.id} className="relative">
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
type="button"
|
||||||
onClick={() => switchModule(m.id)}
|
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
|
||||||
className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
|
aria-expanded={isGroup ? isOpen : undefined}
|
||||||
isActive
|
aria-haspopup={isGroup ? 'menu' : undefined}
|
||||||
? 'bg-blue-50 text-blue-600'
|
className={cn(
|
||||||
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
|
'group relative flex flex-col items-center justify-center transition-all',
|
||||||
}`}
|
'h-16 w-16 rounded-2xl text-[10px] font-black',
|
||||||
|
isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white',
|
||||||
|
)}
|
||||||
|
title={m.label}
|
||||||
>
|
>
|
||||||
<Icon size={22} />
|
{isActive ? (
|
||||||
<span className="text-[10px] mt-1 leading-tight">{m.label}</span>
|
<motion.span layoutId="desktop-shell-active" className="absolute inset-0 rounded-2xl bg-blue-600 shadow-lg shadow-blue-950/30" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
|
||||||
|
) : null}
|
||||||
|
<Icon size={21} className="relative mb-1" />
|
||||||
|
<span className="relative leading-tight">{m.label}</span>
|
||||||
|
{isGroup ? <ChevronRight size={11} className={cn('absolute right-1 top-1/2 -translate-y-1/2 transition-transform', isOpen && 'rotate-180')} /> : null}
|
||||||
|
</button>
|
||||||
|
{isGroup && isOpen ? (
|
||||||
|
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute left-[calc(100%+12px)] top-1/2 w-36 -translate-y-1/2 rounded-2xl border border-slate-700 bg-slate-900 p-2 shadow-2xl shadow-slate-950/30">
|
||||||
|
{m.children!.map((child) => {
|
||||||
|
const ChildIcon = child.icon;
|
||||||
|
const childActive = child.id === activeModule;
|
||||||
|
return (
|
||||||
|
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-black transition-colors', childActive ? 'bg-blue-600 text-white' : 'text-slate-300 hover:bg-white/8 hover:text-white')}>
|
||||||
|
<ChildIcon size={16} />{child.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col items-center gap-2 border-t border-white/10 pt-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-400/10 text-emerald-300 ring-1 ring-emerald-300/20" title={user?.userName || '当前用户'}>
|
||||||
|
<svg width="28" height="22" viewBox="4 3 44 30" role="img" aria-label="羚牛 Logo">
|
||||||
|
<image href="/lingniu-logo-light.svg" width="150" height="36" style={{ filter: 'brightness(0) invert(1)' }} />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 内容区 */}
|
{/* 内容区 */}
|
||||||
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
|
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
|
||||||
|
{showDesktopBreadcrumb ? (
|
||||||
|
<div className={cn('pointer-events-none fixed right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex', isHydrogenPrototype ? 'left-[72px]' : 'left-20')}>
|
||||||
|
<span className="text-slate-600">羚牛氢能 BI</span>
|
||||||
|
<span className="mx-2 text-slate-300">/</span>
|
||||||
|
<span>{activeLabel}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
|
||||||
{ActiveComponent && <ActiveComponent />}
|
{ActiveComponent && <ActiveComponent />}
|
||||||
<FeedbackFab module={activeModule} />
|
</Suspense>
|
||||||
|
{!isHydrogenPrototype ? <FeedbackFab module={activeModule} /> : null}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* 移动端底部导航 (md 以下) */}
|
{/* 移动端底部导航 (md 以下) */}
|
||||||
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-50">
|
<nav className="fixed inset-x-0 bottom-0 z-50 border-t border-white/70 bg-white/92 px-3 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-18px_40px_rgba(15,23,42,0.08)] backdrop-blur-xl md:hidden">
|
||||||
|
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}>
|
||||||
{modules.map((m) => {
|
{modules.map((m) => {
|
||||||
const Icon = m.icon;
|
const Icon = m.icon;
|
||||||
const isActive = m.id === activeModule;
|
const isGroup = Boolean(m.children?.length);
|
||||||
|
const isActive = m.id === activeModule || Boolean(m.children?.some((child) => child.id === activeModule));
|
||||||
|
const isOpen = openGroupId === m.id;
|
||||||
return (
|
return (
|
||||||
|
<div key={m.id} className="relative flex justify-center">
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
type="button"
|
||||||
onClick={() => switchModule(m.id)}
|
onClick={() => isGroup ? setOpenGroupId((current) => current === m.id ? null : m.id) : switchModule(m.id)}
|
||||||
className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
|
aria-expanded={isGroup ? isOpen : undefined}
|
||||||
|
aria-haspopup={isGroup ? 'menu' : undefined}
|
||||||
|
className={cn('relative flex min-h-12 w-full flex-col items-center justify-center gap-0.5 rounded-2xl text-[10px] font-medium transition-colors', isActive ? 'text-blue-700' : 'text-slate-400')}
|
||||||
>
|
>
|
||||||
<Icon size={20} />
|
{isActive ? (
|
||||||
<span className="text-[10px] mt-1">{m.label}</span>
|
<motion.span layoutId="mobile-shell-active" className="absolute inset-0 rounded-2xl bg-blue-50 ring-1 ring-blue-100" transition={{ type: 'spring', stiffness: 430, damping: 34 }} />
|
||||||
|
) : null}
|
||||||
|
<Icon size={20} className="relative" />
|
||||||
|
<span className="relative">{m.label}</span>
|
||||||
|
</button>
|
||||||
|
{isGroup && isOpen ? (
|
||||||
|
<div role="menu" aria-label={`${m.label}二级菜单`} className="absolute bottom-[calc(100%+14px)] left-1/2 w-36 -translate-x-1/2 rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl shadow-slate-900/15">
|
||||||
|
{m.children!.map((child) => {
|
||||||
|
const ChildIcon = child.icon;
|
||||||
|
const childActive = child.id === activeModule;
|
||||||
|
return (
|
||||||
|
<button key={child.id} type="button" role="menuitem" onClick={() => switchModule(child.id)} className={cn('flex h-11 w-full items-center gap-2 rounded-xl px-3 text-xs font-medium transition-colors', childActive ? 'bg-blue-50 text-blue-700' : 'text-slate-600 hover:bg-slate-50')}>
|
||||||
|
<ChildIcon size={16} />{child.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</DemoModeProvider>
|
</DemoModeProvider>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { RefObject } from 'react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { ImagePlus, Loader2, X } from 'lucide-react';
|
||||||
|
import type { FeedbackType, UploadedImg } from './types';
|
||||||
|
|
||||||
|
const MODULE_LABELS: Record<string, string> = {
|
||||||
|
assets: '资产管理',
|
||||||
|
mileage: '里程管理',
|
||||||
|
energy: '能源管理',
|
||||||
|
scheduling: '智能调度',
|
||||||
|
ele: '充电导入',
|
||||||
|
'': '通用',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPlaceholder(type: FeedbackType | null): string {
|
||||||
|
if (type === 'dimension') return '比如:希望按客户/区域/日期范围 等等切片看里程数据…';
|
||||||
|
if (type === 'bug') return '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)';
|
||||||
|
if (type === 'ux') return '比如:能不能把外部 tab 默认收起,加载快一点…';
|
||||||
|
return '随便聊聊你的想法';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: string;
|
||||||
|
error: string | null;
|
||||||
|
fileRef: RefObject<HTMLInputElement | null>;
|
||||||
|
maxScreenshots: number;
|
||||||
|
module: string;
|
||||||
|
screenshots: UploadedImg[];
|
||||||
|
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||||
|
type: FeedbackType | null;
|
||||||
|
uploading: boolean;
|
||||||
|
onAddFiles: (files: FileList | File[]) => void | Promise<void>;
|
||||||
|
onChangeContent: (content: string) => void;
|
||||||
|
onChangeModule: (module: string) => void;
|
||||||
|
onRemoveScreenshot: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackFormStep({
|
||||||
|
content,
|
||||||
|
error,
|
||||||
|
fileRef,
|
||||||
|
maxScreenshots,
|
||||||
|
module,
|
||||||
|
screenshots,
|
||||||
|
textareaRef,
|
||||||
|
type,
|
||||||
|
uploading,
|
||||||
|
onAddFiles,
|
||||||
|
onChangeContent,
|
||||||
|
onChangeModule,
|
||||||
|
onRemoveScreenshot,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 12 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -12 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] font-bold text-slate-600 mb-2">说说具体内容</p>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => onChangeContent(e.target.value)}
|
||||||
|
onPaste={(e) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
const imgs: File[] = [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const it = items[i];
|
||||||
|
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
||||||
|
const f = it.getAsFile();
|
||||||
|
if (f) imgs.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (imgs.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
onAddFiles(imgs);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
rows={5}
|
||||||
|
maxLength={1000}
|
||||||
|
placeholder={getPlaceholder(type)}
|
||||||
|
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
|
||||||
|
/>
|
||||||
|
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase">截图(可选)</p>
|
||||||
|
<span className="text-[10px] text-slate-300 font-bold">{screenshots.length}/{maxScreenshots},可粘贴</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => { if (e.target.files) onAddFiles(e.target.files); e.target.value = ''; }}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{screenshots.map((s, i) => (
|
||||||
|
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
|
||||||
|
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
<button
|
||||||
|
onClick={() => onRemoveScreenshot(i)}
|
||||||
|
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{screenshots.length < maxScreenshots && (
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
|
||||||
|
>
|
||||||
|
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
|
||||||
|
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">所在板块</p>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{Object.entries(MODULE_LABELS).map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => onChangeModule(key)}
|
||||||
|
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${module === key ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import {
|
||||||
|
Inbox,
|
||||||
|
MessageCircleHeart,
|
||||||
|
Settings2,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isAdmin: boolean;
|
||||||
|
menuOpen: boolean;
|
||||||
|
onCloseMenu: () => void;
|
||||||
|
onOpenFeedback: () => void;
|
||||||
|
onOpenHistory: () => void;
|
||||||
|
onToggleMenu: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackLauncher({
|
||||||
|
isAdmin,
|
||||||
|
menuOpen,
|
||||||
|
onCloseMenu,
|
||||||
|
onOpenFeedback,
|
||||||
|
onOpenHistory,
|
||||||
|
onToggleMenu,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
|
||||||
|
<motion.button
|
||||||
|
initial={{ scale: 0, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
|
||||||
|
whileHover={{ scale: 1.08 }}
|
||||||
|
whileTap={{ scale: 0.92 }}
|
||||||
|
onClick={onToggleMenu}
|
||||||
|
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
|
||||||
|
aria-label="反馈"
|
||||||
|
title="提建议 / 我的反馈"
|
||||||
|
>
|
||||||
|
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{menuOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onOpenFeedback}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
|
||||||
|
>
|
||||||
|
<Sparkles size={14} className="text-blue-500" /> 提个建议
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onOpenHistory}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
|
||||||
|
>
|
||||||
|
<Inbox size={14} className="text-emerald-500" /> 我的反馈
|
||||||
|
</button>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<div className="h-px bg-slate-100 my-0.5" />
|
||||||
|
<a
|
||||||
|
href="#/admin/feedback"
|
||||||
|
onClick={onCloseMenu}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
|
||||||
|
>
|
||||||
|
<Settings2 size={14} className="text-violet-500" /> 反馈管理
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[-1]"
|
||||||
|
onClick={onCloseMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import type { RefObject } from 'react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import { ChevronLeft, ChevronRight, Sparkles, X } from 'lucide-react';
|
||||||
|
import FeedbackFormStep from './FeedbackFormStep';
|
||||||
|
import FeedbackSuccessStep from './FeedbackSuccessStep';
|
||||||
|
import FeedbackTypeStep from './FeedbackTypeStep';
|
||||||
|
import type { FeedbackStep, FeedbackType, UploadedImg } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
canNext: boolean;
|
||||||
|
content: string;
|
||||||
|
error: string | null;
|
||||||
|
fileRef: RefObject<HTMLInputElement | null>;
|
||||||
|
maxScreenshots: number;
|
||||||
|
module: string;
|
||||||
|
open: boolean;
|
||||||
|
progress: number;
|
||||||
|
screenshots: UploadedImg[];
|
||||||
|
step: FeedbackStep;
|
||||||
|
submitting: boolean;
|
||||||
|
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||||
|
type: FeedbackType | null;
|
||||||
|
uploading: boolean;
|
||||||
|
onAddFiles: (files: FileList | File[]) => void | Promise<void>;
|
||||||
|
onBack: () => void;
|
||||||
|
onChangeContent: (content: string) => void;
|
||||||
|
onChangeModule: (module: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onRemoveScreenshot: (index: number) => void;
|
||||||
|
onSelectType: (type: FeedbackType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackModal({
|
||||||
|
canNext,
|
||||||
|
content,
|
||||||
|
error,
|
||||||
|
fileRef,
|
||||||
|
maxScreenshots,
|
||||||
|
module,
|
||||||
|
open,
|
||||||
|
progress,
|
||||||
|
screenshots,
|
||||||
|
step,
|
||||||
|
submitting,
|
||||||
|
textareaRef,
|
||||||
|
type,
|
||||||
|
uploading,
|
||||||
|
onAddFiles,
|
||||||
|
onBack,
|
||||||
|
onChangeContent,
|
||||||
|
onChangeModule,
|
||||||
|
onClose,
|
||||||
|
onNext,
|
||||||
|
onRemoveScreenshot,
|
||||||
|
onSelectType,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: '100%', opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: '100%', opacity: 0 }}
|
||||||
|
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
||||||
|
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex justify-center pt-2.5 pb-1">
|
||||||
|
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 pb-3 border-b border-slate-100">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
|
||||||
|
<Sparkles size={14} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-black text-slate-800 leading-tight">
|
||||||
|
{step === 3 ? '收到啦~' : '提个建议'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold">
|
||||||
|
{step === 1 && '第一步 / 共 2 步'}
|
||||||
|
{step === 2 && '第二步 / 共 2 步'}
|
||||||
|
{step === 3 && '感谢你的反馈'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={false}
|
||||||
|
animate={{ width: `${progress}%` }}
|
||||||
|
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{step === 1 && (
|
||||||
|
<FeedbackTypeStep
|
||||||
|
key="s1"
|
||||||
|
selectedType={type}
|
||||||
|
onSelect={onSelectType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<FeedbackFormStep
|
||||||
|
key="s2"
|
||||||
|
content={content}
|
||||||
|
error={error}
|
||||||
|
fileRef={fileRef}
|
||||||
|
maxScreenshots={maxScreenshots}
|
||||||
|
module={module}
|
||||||
|
screenshots={screenshots}
|
||||||
|
textareaRef={textareaRef}
|
||||||
|
type={type}
|
||||||
|
uploading={uploading}
|
||||||
|
onAddFiles={onAddFiles}
|
||||||
|
onChangeContent={onChangeContent}
|
||||||
|
onChangeModule={onChangeModule}
|
||||||
|
onRemoveScreenshot={onRemoveScreenshot}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 3 && <FeedbackSuccessStep key="s3" />}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step < 3 && (
|
||||||
|
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
|
||||||
|
{step > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} /> 上一步
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!canNext || submitting}
|
||||||
|
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{submitting ? '提交中…' : step === 2 ? '提交' : '下一步'}
|
||||||
|
{!submitting && step !== 2 && <ChevronRight size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="px-4 py-3 border-t border-slate-100">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function FeedbackSuccessStep() {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="text-center py-6"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0, rotate: -180 }}
|
||||||
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
|
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
|
||||||
|
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
|
||||||
|
>
|
||||||
|
<Check size={28} strokeWidth={3} className="text-white" />
|
||||||
|
</motion.div>
|
||||||
|
<div className="text-base font-black text-slate-800 mb-1">谢谢你的反馈 ❤️</div>
|
||||||
|
<div className="text-[12px] text-slate-500 font-bold leading-relaxed">产品同学会认真看每一条<br />有进展可以在「我的反馈」里查看</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { motion } from 'motion/react';
|
||||||
|
import {
|
||||||
|
Bug,
|
||||||
|
ChevronRight,
|
||||||
|
Lightbulb,
|
||||||
|
NotebookPen,
|
||||||
|
Palette,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import RotatingFooterHint from '../RotatingFooterHint';
|
||||||
|
import type { FeedbackType } from './types';
|
||||||
|
|
||||||
|
interface TypeOption {
|
||||||
|
key: FeedbackType;
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
sub: string;
|
||||||
|
iconBg: string;
|
||||||
|
iconFg: string;
|
||||||
|
ring: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_OPTIONS: TypeOption[] = [
|
||||||
|
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
|
||||||
|
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
|
||||||
|
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
|
||||||
|
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
|
||||||
|
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
|
||||||
|
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
|
||||||
|
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
|
||||||
|
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedType: FeedbackType | null;
|
||||||
|
onSelect: (type: FeedbackType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackTypeStep({ selectedType, onSelect }: Props) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 12 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -12 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<p className="text-[13px] font-bold text-slate-700 mb-1.5">想反馈点什么?</p>
|
||||||
|
<RotatingFooterHint className="justify-start mb-4" />
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{TYPE_OPTIONS.map((opt, i) => {
|
||||||
|
const Icon = opt.icon;
|
||||||
|
const selected = selectedType === opt.key;
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={opt.key}
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: i * 0.04, duration: 0.2 }}
|
||||||
|
whileHover={{ y: -1 }}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
onClick={() => onSelect(opt.key)}
|
||||||
|
className={`text-left p-3.5 rounded-2xl border bg-white transition-all flex items-center gap-3 group ${selected ? `ring-2 ${opt.ring} border-transparent shadow-sm` : 'border-slate-100 hover:border-slate-200 hover:shadow-sm'}`}
|
||||||
|
>
|
||||||
|
<div className={`w-10 h-10 rounded-xl ${opt.iconBg} flex items-center justify-center flex-shrink-0`}>
|
||||||
|
<Icon size={18} className={opt.iconFg} strokeWidth={2.2} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[13px] font-bold text-slate-800 leading-tight">{opt.label}</div>
|
||||||
|
<div className="text-[11px] text-slate-400 font-medium mt-0.5">{opt.sub}</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={15} className="text-slate-300 flex-shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export type FeedbackStep = 1 | 2 | 3;
|
||||||
|
|
||||||
|
export type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
|
||||||
|
|
||||||
|
export interface UploadedImg {
|
||||||
|
url: string;
|
||||||
|
thumbDataUrl: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { useState, type ComponentType, type ReactNode } from 'react';
|
||||||
|
import { AlertCircle, Info, Loader2, SearchX, X } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import { cn } from '../../lib/cn';
|
||||||
|
|
||||||
|
export type SurfaceIcon = ComponentType<{ size?: number; className?: string }>;
|
||||||
|
|
||||||
|
export function PageFrame({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
icon: Icon,
|
||||||
|
eyebrow,
|
||||||
|
meta,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
maxWidth = 'max-w-6xl',
|
||||||
|
compactInfo = false,
|
||||||
|
hideHeader = false,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
icon?: SurfaceIcon;
|
||||||
|
eyebrow?: string;
|
||||||
|
meta?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
maxWidth?: string;
|
||||||
|
compactInfo?: boolean;
|
||||||
|
hideHeader?: boolean;
|
||||||
|
}) {
|
||||||
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[var(--app-bg)] text-slate-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||||
|
<div className={cn(maxWidth, 'mx-auto flex flex-col gap-4 pb-20 md:pb-6')}>
|
||||||
|
{!hideHeader ? <motion.header
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.28, ease: 'easeOut' }}
|
||||||
|
className={cn(
|
||||||
|
'relative rounded-2xl border border-white/70 bg-white/86 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl',
|
||||||
|
compactInfo ? 'overflow-visible' : 'overflow-hidden',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-blue-600 via-cyan-400 to-emerald-400" />
|
||||||
|
{compactInfo ? (
|
||||||
|
<>
|
||||||
|
<div className="flex min-h-[48px] items-center gap-2 px-3 py-1.5 md:min-h-[54px] md:gap-3 md:px-4 md:py-2">
|
||||||
|
{Icon ? (
|
||||||
|
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100 md:h-9 md:w-9">
|
||||||
|
<Icon size={17} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{eyebrow ? <span className="shrink-0 text-[11px] font-black text-blue-600">{eyebrow}</span> : null}
|
||||||
|
{meta ? <span className="hidden truncate text-[11px] font-bold text-slate-400 sm:inline">{meta}</span> : null}
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1>
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="hidden shrink-0 items-center gap-2 md:flex">{actions}</div> : null}
|
||||||
|
{subtitle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setInfoOpen(open => !open)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-slate-400 transition-colors md:h-9 md:w-9',
|
||||||
|
infoOpen ? 'border-blue-100 bg-blue-50 text-blue-600' : 'border-slate-100 bg-slate-50 hover:bg-blue-50 hover:text-blue-600',
|
||||||
|
)}
|
||||||
|
aria-label={infoOpen ? '收起页面说明' : '展开页面说明'}
|
||||||
|
title={infoOpen ? '收起说明' : '页面说明'}
|
||||||
|
>
|
||||||
|
{infoOpen ? <X size={16} /> : <Info size={16} />}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{infoOpen && subtitle ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||||
|
className="absolute left-0 right-0 top-full z-40 mt-2 overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-xl md:static md:mt-0 md:rounded-none md:border-x-0 md:border-b-0 md:shadow-none"
|
||||||
|
>
|
||||||
|
<div className="px-4 py-3 text-xs font-bold leading-relaxed text-slate-500 md:text-sm">
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4 p-4 md:flex-row md:items-end md:justify-between md:p-5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
{Icon ? (
|
||||||
|
<span className="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
|
||||||
|
<Icon size={17} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{eyebrow ? <span className="text-[11px] font-black text-blue-600">{eyebrow}</span> : null}
|
||||||
|
{meta ? <span className="text-[11px] font-bold text-slate-400">{meta}</span> : null}
|
||||||
|
</div>
|
||||||
|
<h1 className="truncate text-xl font-black tracking-tight text-slate-950 md:text-2xl">{title}</h1>
|
||||||
|
{subtitle ? <p className="mt-2 max-w-3xl text-xs font-bold leading-relaxed text-slate-500 md:text-sm">{subtitle}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.header> : null}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SurfaceCard({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title?: ReactNode;
|
||||||
|
subtitle?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className={cn('rounded-2xl border border-slate-100 bg-white shadow-sm', className)}>
|
||||||
|
{(title || subtitle || actions) && (
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-slate-100 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
{title ? <h2 className="text-sm font-black text-slate-900">{title}</h2> : null}
|
||||||
|
{subtitle ? <p className="mt-1 text-[11px] font-bold text-slate-400">{subtitle}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricTile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
helper,
|
||||||
|
icon: Icon,
|
||||||
|
tone = 'blue',
|
||||||
|
className,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
unit?: string;
|
||||||
|
helper?: string;
|
||||||
|
icon?: SurfaceIcon;
|
||||||
|
tone?: 'blue' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||||
|
className?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const toneClass = {
|
||||||
|
blue: 'bg-blue-50 text-blue-600 ring-blue-100',
|
||||||
|
emerald: 'bg-emerald-50 text-emerald-600 ring-emerald-100',
|
||||||
|
amber: 'bg-amber-50 text-amber-600 ring-amber-100',
|
||||||
|
rose: 'bg-rose-50 text-rose-600 ring-rose-100',
|
||||||
|
slate: 'bg-slate-100 text-slate-600 ring-slate-200',
|
||||||
|
}[tone];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
'rounded-2xl border border-slate-100 bg-white shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md',
|
||||||
|
compact ? 'p-3' : 'p-4',
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 text-[11px] font-black text-slate-400">{label}</div>
|
||||||
|
{Icon ? (
|
||||||
|
<span className={cn('inline-flex shrink-0 items-center justify-center rounded-xl ring-1', compact ? 'h-8 w-8' : 'h-9 w-9', toneClass)}>
|
||||||
|
<Icon size={compact ? 16 : 18} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={cn('flex min-w-0 items-end gap-1', compact ? 'mt-2' : 'mt-3')}>
|
||||||
|
<span className={cn(
|
||||||
|
'min-w-0 whitespace-nowrap font-black leading-none tracking-tight text-slate-950 tabular-nums',
|
||||||
|
compact ? 'text-[clamp(1.45rem,6vw,1.7rem)]' : 'text-[clamp(1.65rem,6vw,2rem)]',
|
||||||
|
)}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{unit ? <span className="shrink-0 pb-0.5 text-[11px] font-black text-slate-400">{unit}</span> : null}
|
||||||
|
</div>
|
||||||
|
{helper ? <div className={cn('font-bold leading-relaxed text-slate-500', compact ? 'mt-2 text-[10px]' : 'mt-3 text-[11px]')}>{helper}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SegmentedNav<T extends string>({
|
||||||
|
tabs,
|
||||||
|
active,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
tabs: readonly { id: T; label: string; icon?: SurfaceIcon }[];
|
||||||
|
active: T;
|
||||||
|
onChange: (id: T) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-2xl border border-white/70 bg-white/90 p-1 shadow-sm backdrop-blur-xl', className)}>
|
||||||
|
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))` }}>
|
||||||
|
{tabs.map(({ id, label, icon: Icon }) => {
|
||||||
|
const isActive = active === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(id)}
|
||||||
|
className={cn(
|
||||||
|
'relative flex min-h-10 items-center justify-center gap-1.5 rounded-xl px-2 text-[12px] font-black transition-colors',
|
||||||
|
isActive ? 'text-blue-700' : 'text-slate-400 hover:bg-slate-50 hover:text-slate-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isActive ? (
|
||||||
|
<motion.span
|
||||||
|
layoutId="segmented-active-pill"
|
||||||
|
className="absolute inset-0 rounded-xl bg-blue-50 shadow-sm ring-1 ring-blue-100"
|
||||||
|
transition={{ type: 'spring', stiffness: 430, damping: 34 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{Icon ? <Icon size={15} className="relative" /> : null}
|
||||||
|
<span className="relative truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonBlock({ className }: { className?: string }) {
|
||||||
|
return <div className={cn('overflow-hidden rounded-xl bg-slate-100 shimmer', className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingState({ label = '数据加载中' }: { label?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm">
|
||||||
|
<Loader2 className="mx-auto mb-3 animate-spin text-blue-500" size={22} />
|
||||||
|
<div className="text-xs font-black text-slate-500">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
title = '暂无数据',
|
||||||
|
description = '换个筛选条件或稍后再试',
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-white p-8 text-center shadow-sm">
|
||||||
|
<SearchX className="mx-auto mb-3 text-slate-300" size={26} />
|
||||||
|
<div className="text-sm font-black text-slate-700">{title}</div>
|
||||||
|
<div className="mt-1 text-xs font-bold text-slate-400">{description}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-rose-100 bg-rose-50 p-4 text-rose-700 shadow-sm">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle size={18} className="mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-black">加载失败</div>
|
||||||
|
<div className="mt-1 text-xs font-bold leading-relaxed">{message}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FadeIn({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -6 }}
|
||||||
|
transition={{ duration: 0.22, ease: 'easeOut' }}
|
||||||
|
className={cn('w-full min-w-0', className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--app-bg: #f4f7fb;
|
||||||
|
--panel-bg: rgba(255, 255, 255, 0.9);
|
||||||
|
--hairline: rgba(148, 163, 184, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
background: var(--app-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -13,4 +20,79 @@ html {
|
|||||||
body {
|
body {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #fff;
|
||||||
|
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, select {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amap-logo,
|
||||||
|
.amap-copyright {
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amap-toolbar {
|
||||||
|
border: 1px solid #e2e8f0 !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
box-shadow: 0 8px 24px rgb(15 23 42 / 10%) !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.amap-logo,
|
||||||
|
.amap-copyright {
|
||||||
|
bottom: 60px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marquee {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes floatUp {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-marquee {
|
||||||
|
animation: marquee 30s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility no-scrollbar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
&::-webkit-scrollbar { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.72), transparent);
|
||||||
|
animation: shimmer 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enterprise-grid-bg {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 16% 0%, rgba(59, 130, 246, 0.08), transparent 28%),
|
||||||
|
radial-gradient(circle at 90% 12%, rgba(20, 184, 166, 0.08), transparent 26%),
|
||||||
|
linear-gradient(180deg, #f8fbff 0%, var(--app-bg) 42%, #f7f9fc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-date-input::-webkit-calendar-picker-indicator {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
|
return parts.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
|
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
|
||||||
import { fetchJson } from '../../auth/api-client';
|
import { fetchJson } from '../../auth/api-client';
|
||||||
|
import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface';
|
||||||
|
|
||||||
interface FeedbackItem {
|
interface FeedbackItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -20,10 +21,10 @@ interface FeedbackItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABEL: Record<string, string> = {
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
dimension: '💡 新维度',
|
dimension: '新维度',
|
||||||
bug: '🐛 Bug',
|
bug: 'Bug',
|
||||||
ux: '🎨 体验',
|
ux: '体验',
|
||||||
other: '📝 其他',
|
other: '其他',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
|
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
|
||||||
@@ -126,36 +127,36 @@ export default function FeedbackAdminPage() {
|
|||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] p-4 md:p-8">
|
<PageFrame
|
||||||
<div className="max-w-5xl mx-auto space-y-4">
|
title="用户反馈管理"
|
||||||
<header className="flex items-center justify-between">
|
subtitle="查看、回复、跟进用户提交的建议,形成从问题发现到处理闭环的运营后台。"
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
icon={Inbox}
|
||||||
|
eyebrow="FEEDBACK OPS"
|
||||||
|
meta={`当前 ${items.length} 条反馈`}
|
||||||
|
actions={(
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// 优先 history.back(来自 SPA 内部跳转);否则回到主页
|
// 优先 history.back(来自 SPA 内部跳转);否则回到主页
|
||||||
if (window.history.length > 1) window.history.back();
|
if (window.history.length > 1) window.history.back();
|
||||||
else { window.location.hash = '#mileage'; }
|
else { window.location.hash = '#mileage'; }
|
||||||
}}
|
}}
|
||||||
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
|
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
|
||||||
title="返回"
|
title="返回"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center flex-shrink-0">
|
<button onClick={reload} className="flex h-9 w-9 items-center justify-center rounded-xl bg-slate-900 text-white shadow-sm transition-colors hover:bg-slate-800" title="刷新">
|
||||||
<Inbox size={18} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h1 className="text-lg font-black text-slate-900 leading-tight">用户反馈管理</h1>
|
|
||||||
<p className="text-[11px] font-bold text-slate-400">查看、回复、跟进用户提交的建议</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={reload} className="p-2 text-slate-400 hover:text-blue-500 flex-shrink-0" title="刷新">
|
|
||||||
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
|
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</div>
|
||||||
|
)}
|
||||||
|
maxWidth="max-w-5xl"
|
||||||
|
>
|
||||||
|
|
||||||
{/* 状态过滤 */}
|
{/* 状态过滤 */}
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-2 flex items-center gap-1 overflow-x-auto">
|
<SurfaceCard className="p-2">
|
||||||
|
<div className="flex items-center gap-1 overflow-x-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => setStatusFilter('')}
|
onClick={() => setStatusFilter('')}
|
||||||
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||||
@@ -170,6 +171,7 @@ export default function FeedbackAdminPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600">
|
<div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600">
|
||||||
@@ -192,9 +194,9 @@ export default function FeedbackAdminPage() {
|
|||||||
{/* 列表 */}
|
{/* 列表 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{loading && items.length === 0 ? (
|
{loading && items.length === 0 ? (
|
||||||
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">加载中…</div>
|
<LoadingState label="正在加载用户反馈" />
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">还没有反馈</div>
|
<EmptyState title="还没有反馈" description="新的用户反馈会出现在这里" />
|
||||||
) : items.map(it => {
|
) : items.map(it => {
|
||||||
const shots = parseScreenshots(it.screenshots);
|
const shots = parseScreenshots(it.screenshots);
|
||||||
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
|
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
|
||||||
@@ -218,7 +220,7 @@ export default function FeedbackAdminPage() {
|
|||||||
{(shots.length > 0 || it.contact) && (
|
{(shots.length > 0 || it.contact) && (
|
||||||
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
|
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
|
||||||
{shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} 张</span>}
|
{shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} 张</span>}
|
||||||
{it.contact && <span>📞 {it.contact}</span>}
|
{it.contact && <span>联系 {it.contact}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{it.reply_content && (
|
{it.reply_content && (
|
||||||
@@ -236,8 +238,6 @@ export default function FeedbackAdminPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 详情 / 回复弹窗 */}
|
{/* 详情 / 回复弹窗 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{active && (
|
{active && (
|
||||||
@@ -336,6 +336,6 @@ export default function FeedbackAdminPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</PageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+247
-1390
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,43 @@ export interface WeeklyDetailItem {
|
|||||||
customer_name: string | null;
|
customer_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FlowType = 'delivered' | 'returned' | 'replaced';
|
||||||
|
|
||||||
|
export interface FlowDailyPoint {
|
||||||
|
date: string;
|
||||||
|
delivered: number;
|
||||||
|
returned: number;
|
||||||
|
replaced: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowDetailItem {
|
||||||
|
id: string;
|
||||||
|
type: FlowType;
|
||||||
|
typeLabel: string;
|
||||||
|
date: string;
|
||||||
|
truckId: string;
|
||||||
|
plateNumber: string;
|
||||||
|
eventTime: string | null;
|
||||||
|
submitTime: string | null;
|
||||||
|
department: string;
|
||||||
|
manager: string;
|
||||||
|
customerName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowStatsResponse {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
daily: FlowDailyPoint[];
|
||||||
|
totals: {
|
||||||
|
delivered: number;
|
||||||
|
returned: number;
|
||||||
|
replaced: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
details: FlowDetailItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> {
|
export async function fetchDeptStats(subject?: string | null): Promise<DeptGroup[]> {
|
||||||
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
|
return fetchJson<DeptGroup[]>(withSubject(`${BASE}/dept-stats`, subject));
|
||||||
}
|
}
|
||||||
@@ -125,3 +162,13 @@ export async function fetchWeeklyDetail(
|
|||||||
if (filters?.source) params.set('source', filters.source);
|
if (filters?.source) params.set('source', filters.source);
|
||||||
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
|
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchFlowStats(params: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
subject?: string | null;
|
||||||
|
}): Promise<FlowStatsResponse> {
|
||||||
|
const query = new URLSearchParams({ start: params.start, end: params.end });
|
||||||
|
if (params.subject) query.set('subject', params.subject);
|
||||||
|
return fetchJson<FlowStatsResponse>(`${BASE}/flow-stats?${query.toString()}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { ChevronDown, Filter, Loader2, Search } from 'lucide-react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import type { SubjectOption } from '../api';
|
||||||
|
|
||||||
|
export type AssetsTab = 'overview' | 'department' | 'region' | 'customer';
|
||||||
|
export type AssetsTheme = 'soft' | 'minimal' | 'vibrant';
|
||||||
|
|
||||||
|
interface AssetsHeaderProps {
|
||||||
|
activeTab: AssetsTab;
|
||||||
|
setActiveTab: React.Dispatch<React.SetStateAction<AssetsTab>>;
|
||||||
|
theme: AssetsTheme;
|
||||||
|
setTheme: React.Dispatch<React.SetStateAction<AssetsTheme>>;
|
||||||
|
lastUpdate: string;
|
||||||
|
loading: boolean;
|
||||||
|
selectedSubject: string | null;
|
||||||
|
setSelectedSubject: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
|
subjects: SubjectOption[];
|
||||||
|
subjectDropdownOpen: boolean;
|
||||||
|
setSubjectDropdownOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
subjectSearch: string;
|
||||||
|
setSubjectSearch: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
subjectDropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
// --- Constants ---
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'overview', label: '总览' },
|
||||||
|
{ id: 'department', label: '按部门' },
|
||||||
|
{ id: 'region', label: '按区域' },
|
||||||
|
{ id: 'customer', label: '按客户' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function MarqueeBanner() {
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const innerRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div className="relative -mx-6 mb-4 bg-green-50 border-y border-green-200">
|
||||||
|
<div ref={trackRef} className="overflow-hidden">
|
||||||
|
<div className={`flex w-max py-2 ${overflow ? 'animate-marquee' : 'w-full justify-center'}`}>
|
||||||
|
<span ref={innerRef} className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
{overflow && (
|
||||||
|
<span className="inline-block whitespace-nowrap px-6 text-xs text-green-700 font-medium">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssetsHeader({
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
lastUpdate,
|
||||||
|
loading,
|
||||||
|
selectedSubject,
|
||||||
|
setSelectedSubject,
|
||||||
|
subjects,
|
||||||
|
subjectDropdownOpen,
|
||||||
|
setSubjectDropdownOpen,
|
||||||
|
subjectSearch,
|
||||||
|
setSubjectSearch,
|
||||||
|
subjectDropdownRef,
|
||||||
|
}: AssetsHeaderProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Compact Header Bar */}
|
||||||
|
<div className="sticky top-0 z-40 -mx-3 -mt-3 mb-4 bg-white/95 backdrop-blur-sm border-b border-gray-100/80 md:-mx-6 md:-mt-6">
|
||||||
|
{/* Title row */}
|
||||||
|
<div className="relative flex items-center justify-center px-4 pt-3 pb-1">
|
||||||
|
<h1 className="hidden sm:block text-base font-semibold text-gray-800 tracking-wide">羚牛氢能-资产BI</h1>
|
||||||
|
{/* Right: status + theme */}
|
||||||
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||||
|
<div className="hidden sm:flex items-center gap-1 text-[10px] text-gray-400">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse inline-block" />
|
||||||
|
<span>{lastUpdate}</span>
|
||||||
|
</div>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-gray-400">
|
||||||
|
<Loader2 className="animate-spin" size={10} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="hidden sm:flex bg-gray-100 p-0.5 rounded-lg text-[10px]">
|
||||||
|
{(['soft','minimal','vibrant'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTheme(t)}
|
||||||
|
className={`px-2 py-0.5 rounded-md transition-all ${theme === t ? 'bg-white text-blue-600 shadow-sm font-semibold' : 'text-gray-400 hover:text-gray-600'}`}
|
||||||
|
>
|
||||||
|
{t === 'soft' ? '柔和' : t === 'minimal' ? '简约' : '经典'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 归属公司作用域筛选 (Scope Chip) */}
|
||||||
|
<div className="flex items-center justify-center px-4 pt-1">
|
||||||
|
<div className="relative" ref={subjectDropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSubjectDropdownOpen((o) => !o);
|
||||||
|
setSubjectSearch('');
|
||||||
|
}}
|
||||||
|
className={`group inline-flex items-center gap-1.5 h-7 pl-2.5 pr-2 rounded-full border text-[11px] font-normal transition-all cursor-pointer ${
|
||||||
|
selectedSubject
|
||||||
|
? 'bg-blue-50 border-blue-300 text-blue-700 hover:bg-blue-100'
|
||||||
|
: 'bg-white border-gray-200 text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
title={selectedSubject || '全部公司'}
|
||||||
|
>
|
||||||
|
<Filter size={11} className={selectedSubject ? 'text-blue-500' : 'text-gray-400'} />
|
||||||
|
<span className="max-w-[180px] truncate">
|
||||||
|
所属公司:{selectedSubject || '全部公司'}
|
||||||
|
</span>
|
||||||
|
{selectedSubject ? (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedSubject(null);
|
||||||
|
}}
|
||||||
|
className="ml-0.5 w-3.5 h-3.5 inline-flex items-center justify-center rounded-full text-blue-500 hover:bg-blue-200 hover:text-blue-700 cursor-pointer"
|
||||||
|
aria-label="清除归属公司筛选"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={11} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{subjectDropdownOpen && (
|
||||||
|
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1.5 w-[320px] max-h-[380px] bg-white border border-gray-200 rounded-lg shadow-lg z-50 flex flex-col">
|
||||||
|
<div className="p-2 border-b border-gray-100">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={subjectSearch}
|
||||||
|
onChange={(e) => setSubjectSearch(e.target.value)}
|
||||||
|
placeholder="搜索公司名"
|
||||||
|
className="w-full h-7 pl-6 pr-2 text-[11px] bg-gray-50 border border-gray-100 rounded focus:outline-none focus:border-blue-300 focus:bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1 py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSubject(null);
|
||||||
|
setSubjectDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center justify-between px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
|
||||||
|
!selectedSubject ? 'text-blue-600 font-medium' : 'text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${!selectedSubject ? 'bg-blue-500' : 'bg-gray-300'}`} />
|
||||||
|
全部公司
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-400">
|
||||||
|
{subjects.reduce((s, x) => s + x.total, 0)} 台
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="my-1 mx-3 border-t border-gray-100" />
|
||||||
|
{subjects
|
||||||
|
.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase()))
|
||||||
|
.map((s) => {
|
||||||
|
const active = selectedSubject === s.name;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSubject(s.name);
|
||||||
|
setSubjectDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center justify-between gap-2 px-3 py-1.5 text-[11px] hover:bg-gray-50 cursor-pointer ${
|
||||||
|
active ? 'text-blue-600 font-medium bg-blue-50/40' : 'text-gray-700'
|
||||||
|
}`}
|
||||||
|
title={s.name}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${active ? 'bg-blue-500' : 'bg-gray-300'}`} />
|
||||||
|
<span className="truncate">{s.name}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-400 flex-shrink-0 tabular-nums">
|
||||||
|
{s.total} 台
|
||||||
|
<span className="mx-1 text-gray-200">·</span>
|
||||||
|
<span className="text-green-500">运营 {s.operating}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{subjects.filter((s) => !subjectSearch || s.name.toLowerCase().includes(subjectSearch.toLowerCase())).length === 0 && (
|
||||||
|
<div className="px-3 py-6 text-center text-[11px] text-gray-400">未找到匹配公司</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab row */}
|
||||||
|
<div className="flex items-center justify-center gap-1 px-4 pb-0 overflow-x-auto no-scrollbar">
|
||||||
|
{TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id as typeof activeTab)}
|
||||||
|
className={`relative px-4 py-2 text-[13px] font-normal transition-all whitespace-nowrap ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'text-blue-600 font-medium'
|
||||||
|
: 'text-gray-400 hover:text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{activeTab === tab.id && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="activeTab"
|
||||||
|
className="absolute bottom-0 left-2 right-2 h-[1.5px] bg-blue-600 rounded-full"
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Status row */}
|
||||||
|
<div className="flex items-center justify-center gap-4 py-1.5 text-[10px] text-gray-400">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-blue-400 inline-block" />
|
||||||
|
最后更新: {lastUpdate}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OneOS 迁移提示滚动条 */}
|
||||||
|
<MarqueeBanner />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Download, X } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import type { FlowDetailItem, FlowType } from '../api';
|
||||||
|
|
||||||
|
export const FLOW_META: Record<FlowType, { label: string; tone: string; chip: string }> = {
|
||||||
|
delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' },
|
||||||
|
returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' },
|
||||||
|
replaced: { label: '替换', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FlowDetailModalProps {
|
||||||
|
selectedFlow: { date: string; type: FlowType } | null;
|
||||||
|
selectedFlowDetails: FlowDetailItem[];
|
||||||
|
setSelectedFlow: React.Dispatch<React.SetStateAction<{ date: string; type: FlowType } | null>>;
|
||||||
|
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
|
||||||
|
}
|
||||||
|
export function FlowDetailModal({
|
||||||
|
selectedFlow,
|
||||||
|
selectedFlowDetails,
|
||||||
|
setSelectedFlow,
|
||||||
|
exportFlowDetails,
|
||||||
|
}: FlowDetailModalProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Flow Detail Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedFlow && (
|
||||||
|
<div className="fixed inset-0 z-[1000] flex items-end justify-center bg-slate-950/45 p-0 backdrop-blur-sm sm:items-center sm:p-4">
|
||||||
|
<motion.div
|
||||||
|
data-testid="asset-flow-detail-modal"
|
||||||
|
initial={{ opacity: 0, y: 28, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 20, scale: 0.98 }}
|
||||||
|
className="flex max-h-[88vh] w-full flex-col overflow-hidden rounded-t-3xl bg-white shadow-2xl sm:max-w-5xl sm:rounded-3xl"
|
||||||
|
>
|
||||||
|
<div className="border-b border-slate-100 bg-slate-950 px-4 py-4 text-white sm:px-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-black uppercase tracking-wide text-slate-400">提交时间口径</div>
|
||||||
|
<h3 className="mt-1 text-lg font-black">
|
||||||
|
{selectedFlow.date} · {FLOW_META[selectedFlow.type].label}明细
|
||||||
|
</h3>
|
||||||
|
<div className="mt-1 text-[12px] font-bold text-slate-300">
|
||||||
|
共 {selectedFlowDetails.length} 条,包含车牌、流转时间、提交时间、部门、负责人和客户
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedFlow(null)}
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => exportFlowDetails(selectedFlowDetails, `${selectedFlow.date}-${FLOW_META[selectedFlow.type].label}明细`)}
|
||||||
|
disabled={selectedFlowDetails.length === 0}
|
||||||
|
className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Download size={13} />
|
||||||
|
导出当前明细
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-auto bg-slate-50 p-3 sm:p-4">
|
||||||
|
<div className="hidden overflow-hidden rounded-2xl border border-slate-100 bg-white lg:block">
|
||||||
|
<table className="w-full table-fixed text-left">
|
||||||
|
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="w-28 px-3 py-3">车牌</th>
|
||||||
|
<th className="w-40 px-3 py-3">{FLOW_META[selectedFlow.type].label}时间</th>
|
||||||
|
<th className="w-40 px-3 py-3">提交时间</th>
|
||||||
|
<th className="w-36 px-3 py-3">部门</th>
|
||||||
|
<th className="w-28 px-3 py-3">业务负责人</th>
|
||||||
|
<th className="px-3 py-3">客户</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 text-[12px] font-bold text-slate-700">
|
||||||
|
{selectedFlowDetails.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-blue-50/40">
|
||||||
|
<td className="px-3 py-3 font-black text-slate-950">{item.plateNumber}</td>
|
||||||
|
<td className="px-3 py-3 text-slate-500">{item.eventTime || '-'}</td>
|
||||||
|
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
|
||||||
|
<td className="px-3 py-3">{item.department || '-'}</td>
|
||||||
|
<td className="px-3 py-3">{item.manager || '-'}</td>
|
||||||
|
<td className="px-3 py-3">{item.customerName || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 lg:hidden">
|
||||||
|
{selectedFlowDetails.map((item) => (
|
||||||
|
<div key={item.id} className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-black text-slate-950">{item.plateNumber}</div>
|
||||||
|
<div className={`mt-1 inline-flex rounded-full border px-2 py-0.5 text-[10px] font-black ${FLOW_META[item.type].chip}`}>
|
||||||
|
{item.typeLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-[10px] font-bold text-slate-400">
|
||||||
|
<div>提交</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-blue-600">{item.submitTime?.slice(5, 16) || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
|
||||||
|
<div className="rounded-xl bg-slate-50 p-2">
|
||||||
|
<div className="font-black text-slate-400">{item.typeLabel}时间</div>
|
||||||
|
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-slate-50 p-2">
|
||||||
|
<div className="font-black text-slate-400">负责人</div>
|
||||||
|
<div className="mt-1 font-bold text-slate-700">{item.manager || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-slate-50 p-2">
|
||||||
|
<div className="font-black text-slate-400">部门</div>
|
||||||
|
<div className="mt-1 font-bold text-slate-700">{item.department || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-slate-50 p-2">
|
||||||
|
<div className="font-black text-slate-400">客户</div>
|
||||||
|
<div className="mt-1 line-clamp-2 font-bold text-slate-700">{item.customerName || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedFlowDetails.length === 0 && (
|
||||||
|
<div className="rounded-2xl bg-white px-4 py-10 text-center text-sm font-bold text-slate-400">当前日期暂无明细</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { AssetSummarySection } from './overview/AssetSummarySection';
|
||||||
|
import { FlowStatisticsCard } from './overview/FlowStatisticsCard';
|
||||||
|
import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection';
|
||||||
|
import { SummaryMetrics } from './overview/SummaryMetrics';
|
||||||
|
import type { OverviewViewProps } from './overview/types';
|
||||||
|
|
||||||
|
export function OverviewView(props: OverviewViewProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SummaryMetrics
|
||||||
|
summary={props.summary}
|
||||||
|
operatingRate={props.operatingRate}
|
||||||
|
inventoryRate={props.inventoryRate}
|
||||||
|
pendingRate={props.pendingRate}
|
||||||
|
setVehicleSelection={props.setVehicleSelection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FlowStatisticsCard
|
||||||
|
flowRange={props.flowRange}
|
||||||
|
setFlowRange={props.setFlowRange}
|
||||||
|
flowLoading={props.flowLoading}
|
||||||
|
flowStats={props.flowStats}
|
||||||
|
exportFlowDetails={props.exportFlowDetails}
|
||||||
|
flowDailyExpanded={props.flowDailyExpanded}
|
||||||
|
setFlowDailyExpanded={props.setFlowDailyExpanded}
|
||||||
|
setSelectedFlow={props.setSelectedFlow}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AssetSummarySection
|
||||||
|
processedData={props.processedData}
|
||||||
|
theme={props.theme}
|
||||||
|
expandedAssetTypes={props.expandedAssetTypes}
|
||||||
|
toggleAssetType={props.toggleAssetType}
|
||||||
|
expandedModels={props.expandedModels}
|
||||||
|
toggleModel={props.toggleModel}
|
||||||
|
setVehicleSelection={props.setVehicleSelection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InventoryStatisticsSection
|
||||||
|
inventoryFilters={props.inventoryFilters}
|
||||||
|
setInventoryFilters={props.setInventoryFilters}
|
||||||
|
draftInventoryFilters={props.draftInventoryFilters}
|
||||||
|
setDraftInventoryFilters={props.setDraftInventoryFilters}
|
||||||
|
isInventoryFilterOpen={props.isInventoryFilterOpen}
|
||||||
|
setIsInventoryFilterOpen={props.setIsInventoryFilterOpen}
|
||||||
|
uniqueInventoryRegions={props.uniqueInventoryRegions}
|
||||||
|
uniqueInventoryCities={props.uniqueInventoryCities}
|
||||||
|
uniqueInventoryBrands={props.uniqueInventoryBrands}
|
||||||
|
uniqueInventoryTypes={props.uniqueInventoryTypes}
|
||||||
|
uniqueInventoryModelsForType={props.uniqueInventoryModelsForType}
|
||||||
|
filteredInventoryStats={props.filteredInventoryStats}
|
||||||
|
inventoryTab={props.inventoryTab}
|
||||||
|
setInventoryTab={props.setInventoryTab}
|
||||||
|
inventoryByRegion={props.inventoryByRegion}
|
||||||
|
expandedInventoryRegions={props.expandedInventoryRegions}
|
||||||
|
toggleInventoryRegion={props.toggleInventoryRegion}
|
||||||
|
inventoryByModel={props.inventoryByModel}
|
||||||
|
expandedInventoryTypes={props.expandedInventoryTypes}
|
||||||
|
toggleInventoryType={props.toggleInventoryType}
|
||||||
|
setVehicleSelection={props.setVehicleSelection}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import Blur from '../../../components/Blur';
|
||||||
|
import { SearchSelect } from '../../../components/SearchSelect';
|
||||||
|
import type { WeeklyDetailItem } from '../api';
|
||||||
|
import type { ModalVehicleFilters, VehicleModalSelection } from '../model';
|
||||||
|
import type { VehicleListItem } from '../types';
|
||||||
|
|
||||||
|
interface VehicleDetailModalProps {
|
||||||
|
selection: VehicleModalSelection | null;
|
||||||
|
setSelection: React.Dispatch<React.SetStateAction<VehicleModalSelection | null>>;
|
||||||
|
isFilterExpanded: boolean;
|
||||||
|
setIsFilterExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
filters: ModalVehicleFilters;
|
||||||
|
setFilters: React.Dispatch<React.SetStateAction<ModalVehicleFilters>>;
|
||||||
|
plates: string[];
|
||||||
|
models: string[];
|
||||||
|
brands: string[];
|
||||||
|
locations: string[];
|
||||||
|
loading: boolean;
|
||||||
|
weeklyDetails: WeeklyDetailItem[];
|
||||||
|
filteredWeeklyDetails: WeeklyDetailItem[];
|
||||||
|
filteredVehicles: VehicleListItem[];
|
||||||
|
}
|
||||||
|
export function VehicleDetailModal({
|
||||||
|
selection: showPlateNumbers,
|
||||||
|
setSelection: setShowPlateNumbers,
|
||||||
|
isFilterExpanded: isModalFilterExpanded,
|
||||||
|
setIsFilterExpanded: setIsModalFilterExpanded,
|
||||||
|
filters: modalFilters,
|
||||||
|
setFilters: setModalFilters,
|
||||||
|
plates: uniqueModalPlates,
|
||||||
|
models: uniqueModalModels,
|
||||||
|
brands: uniqueModalBrands,
|
||||||
|
locations: uniqueModalLocations,
|
||||||
|
loading: modalLoading,
|
||||||
|
weeklyDetails: modalWeeklyDetail,
|
||||||
|
filteredWeeklyDetails: filteredModalWeeklyDetail,
|
||||||
|
filteredVehicles: filteredModalVehicles,
|
||||||
|
}: VehicleDetailModalProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Vehicle Detail Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showPlateNumbers && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
|
className={`bg-white rounded-xl shadow-2xl w-full max-w-[95vw] ${showPlateNumbers.source === 'customer' ? 'lg:max-w-6xl' : 'lg:max-w-4xl'} overflow-hidden flex flex-col max-h-[85vh] sm:max-h-[90vh] min-h-[40vh]`}
|
||||||
|
>
|
||||||
|
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-slate-800 text-white shrink-0">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-base flex items-center gap-2">
|
||||||
|
<Truck size={18} className="text-blue-400" />
|
||||||
|
{showPlateNumbers.title || (
|
||||||
|
(showPlateNumbers.manager ? `${showPlateNumbers.manager} 的${showPlateNumbers.type || ''}车辆` :
|
||||||
|
showPlateNumbers.customer ? `${showPlateNumbers.customer} 的${showPlateNumbers.type || ''}车辆` :
|
||||||
|
showPlateNumbers.batch === 'All' ? '全量批次' : `${showPlateNumbers.batch} 批次`) + ' - 运营明细'
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] opacity-60 mt-0.5">
|
||||||
|
{showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} |
|
||||||
|
{showPlateNumbers.category === 'Pending' ? '待交车' :
|
||||||
|
showPlateNumbers.category === 'Delivered' ? '本周已交车' :
|
||||||
|
showPlateNumbers.category === 'Returned' ? '已还车' :
|
||||||
|
showPlateNumbers.category === 'Replaced' ? '已替换' :
|
||||||
|
showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` :
|
||||||
|
showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowPlateNumbers(null)} className="hover:bg-white/10 p-2 rounded-full transition-colors">
|
||||||
|
<PlusCircle className="rotate-45" size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Filters */}
|
||||||
|
<div className="px-4 py-2 bg-slate-50 border-b border-gray-200 shrink-0">
|
||||||
|
<div
|
||||||
|
className="flex justify-between items-center cursor-pointer py-1"
|
||||||
|
onClick={() => setIsModalFilterExpanded(!isModalFilterExpanded)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-slate-600">
|
||||||
|
<Filter size={14} />
|
||||||
|
<span className="text-xs font-bold">筛选条件</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Quick Search always visible when collapsed */}
|
||||||
|
{!isModalFilterExpanded && (
|
||||||
|
<div className="w-40 sm:w-64" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="快速搜索车牌..." className="text-[11px] py-1 px-2" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: isModalFilterExpanded ? 180 : 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<ChevronDown size={16} className="text-slate-400" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isModalFilterExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 py-3 border-t border-gray-100 mt-1">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-[10px] text-gray-500 font-medium">车牌号码</label>
|
||||||
|
<SearchSelect value={modalFilters.plateNumber} onChange={(v) => setModalFilters({...modalFilters, plateNumber: v})} options={uniqueModalPlates} placeholder="全部车牌" className="text-[11px] py-1.5 px-2" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-[10px] text-gray-500 font-medium">车型型号</label>
|
||||||
|
<select value={modalFilters.model} onChange={(e) => setModalFilters({...modalFilters, model: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||||
|
<option value="">全部车型</option>
|
||||||
|
{uniqueModalModels.map(m => <option key={m} value={m}>{m}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-[10px] text-gray-500 font-medium">品牌名称</label>
|
||||||
|
<select value={modalFilters.brand} onChange={(e) => setModalFilters({...modalFilters, brand: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||||
|
<option value="">全部品牌</option>
|
||||||
|
{uniqueModalBrands.map(b => <option key={b} value={b}>{b}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-[10px] text-gray-500 font-medium">运营所在地</label>
|
||||||
|
<select value={modalFilters.location} onChange={(e) => setModalFilters({...modalFilters, location: e.target.value})} className="w-full text-[11px] px-2 py-1.5 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||||
|
<option value="">全部地区</option>
|
||||||
|
{uniqueModalLocations.map(l => <option key={l} value={l}>{l}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end pb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setModalFilters({ plateNumber: '', model: '', brand: '', location: '' })}
|
||||||
|
className="text-[10px] text-blue-500 hover:text-blue-600 font-medium"
|
||||||
|
>
|
||||||
|
重置筛选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-0 sm:p-4 bg-gray-50 min-h-0 overscroll-contain">
|
||||||
|
{modalLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="animate-spin text-blue-500" size={32} />
|
||||||
|
</div>
|
||||||
|
) : modalWeeklyDetail.length > 0 ? (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden w-full">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-700 text-white text-[10px] uppercase tracking-wider sticky top-0 z-20 shadow-sm">
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 text-center">车牌</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 text-center">客户名称</th>
|
||||||
|
<th className="p-2 font-semibold text-center">日期</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-[11px]">
|
||||||
|
{filteredModalWeeklyDetail.map((v, i) => (
|
||||||
|
<tr key={`${v.truck_id}-${i}`} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors ${i % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
|
||||||
|
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center"><Blur>{v.plate_number}</Blur></td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{v.customer_name || '—'}</Blur></td>
|
||||||
|
<td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 shadow-sm w-full">
|
||||||
|
<table className="min-w-full w-max text-left border-collapse">
|
||||||
|
<thead className="sticky top-0 z-20 shadow-sm">
|
||||||
|
<tr className="bg-slate-700 text-white text-[10px] uppercase tracking-wider whitespace-nowrap">
|
||||||
|
{showPlateNumbers.source === 'customer' ? (
|
||||||
|
<>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-24">业务部门</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-24">业务负责人</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-16">品牌</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-16">车型</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-48">资产归属</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-48">客户名称</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-24">车牌</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-16 text-center">状态</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-24 text-center">提车时间</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-24 text-center">合同到期时间</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-24">运营区域</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 w-20 text-center">离到期</th>
|
||||||
|
<th className="p-2 font-semibold w-48">签约公司</th>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 text-center">车牌</th>
|
||||||
|
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 text-center">客户名称</th>
|
||||||
|
)}
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 text-center">品牌</th>
|
||||||
|
<th className="p-2 font-semibold border-r border-slate-600 text-center">车型</th>
|
||||||
|
<th className="p-2 font-semibold text-center">所在地</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-[11px]">
|
||||||
|
{filteredModalVehicles.map((v, idx) => (
|
||||||
|
<tr key={v.id} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors whitespace-nowrap ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
|
||||||
|
{showPlateNumbers.source === 'customer' ? (
|
||||||
|
<>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-gray-600">{v.departmentName || '—'}</td>
|
||||||
|
<td className="p-2 border-r border-gray-100 font-medium text-gray-700"><Blur>{v.customerManager || '—'}</Blur></td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-gray-600">{v.brandLabel || '—'}</td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-gray-600">{v.type}</td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]"><Blur>{v.subjectOrg || '—'}</Blur></td>
|
||||||
|
<td className="p-2 border-r border-gray-100 font-bold text-gray-800"><Blur>{v.customerName || '—'}</Blur></td>
|
||||||
|
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-center">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${
|
||||||
|
v.status === 'Operating' ? 'bg-green-100 text-green-700' :
|
||||||
|
v.status === 'Inventory' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{v.status === 'Operating' ? '在租' : v.status === 'Inventory' ? '库存' : '异常'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-gray-600">{v.location === '其他' ? '对接中' : v.location}</td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-center font-bold text-orange-600">{'—'}</td>
|
||||||
|
<td className="p-2 text-gray-500 text-[10px]"><Blur>{v.orgName || '—'}</Blur></td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
|
||||||
|
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
|
||||||
|
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center"><Blur>{v.customerName || '—'}</Blur></td>
|
||||||
|
)}
|
||||||
|
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td>
|
||||||
|
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td>
|
||||||
|
<td className="p-2 text-gray-600 text-center">{v.location === '其他' ? '对接中' : v.location}</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{filteredModalVehicles.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={showPlateNumbers.source === 'customer' ? 13 : ((showPlateNumbers.source === 'asset' || showPlateNumbers.category === 'Inventory') ? 4 : 5)} className="p-8 text-center text-gray-400 italic">
|
||||||
|
暂无符合条件的车辆数据
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-white border-t border-gray-100 flex justify-between items-center shrink-0">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
共计 <span className="font-bold text-blue-600">{filteredModalWeeklyDetail.length > 0 ? filteredModalWeeklyDetail.length : filteredModalVehicles.length}</span> 台车辆
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPlateNumbers(null)}
|
||||||
|
className="px-6 py-2 bg-slate-800 text-white rounded-lg text-xs font-bold hover:bg-slate-700 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
确认并关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import type { AssetSummarySectionProps } from './types';
|
||||||
|
|
||||||
|
export function AssetSummaryDesktopTable({
|
||||||
|
processedData,
|
||||||
|
theme,
|
||||||
|
expandedAssetTypes,
|
||||||
|
toggleAssetType,
|
||||||
|
expandedModels,
|
||||||
|
toggleModel,
|
||||||
|
setVehicleSelection: setShowPlateNumbers,
|
||||||
|
}: AssetSummarySectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="hidden lg:block overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse table-fixed min-w-[1200px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-100">
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 w-24">车型</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 w-48">品牌型号</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/30 w-24">车辆总资产</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存总数</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-江浙沪</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-广东</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-北京</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-新疆</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-其他</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">待交车</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-green-50/30 w-24">当前在运营</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/20 w-24">本周交车</th>
|
||||||
|
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-orange-50/20 w-24">本周还车</th>
|
||||||
|
<th className="p-3 font-semibold text-center bg-purple-50/20 w-24">本周替换</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-xs">
|
||||||
|
{processedData.map((typeGroup) => (
|
||||||
|
<React.Fragment key={typeGroup.type}>
|
||||||
|
{/* Category Header Row */}
|
||||||
|
<tr className={`border-b border-gray-100 cursor-pointer transition-all ${
|
||||||
|
theme === 'vibrant' ? 'bg-blue-600 text-white hover:bg-blue-700' :
|
||||||
|
theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 hover:bg-gray-50' :
|
||||||
|
'bg-blue-50/50 hover:bg-blue-50 transition-colors'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleAssetType(typeGroup.type)}>
|
||||||
|
<td colSpan={14} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedAssetTypes.has(typeGroup.type) ?
|
||||||
|
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} /> :
|
||||||
|
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-gray-400'} />
|
||||||
|
}
|
||||||
|
<span>{typeGroup.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`flex gap-6 text-[11px] font-normal mr-4 ${theme === 'vibrant' ? 'text-white/80' : 'text-gray-500'}`}>
|
||||||
|
<span>资产 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
|
||||||
|
<span>库存 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
|
||||||
|
<span>运营 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{expandedAssetTypes.has(typeGroup.type) && typeGroup.models.map((model) => (
|
||||||
|
<React.Fragment key={model.model}>
|
||||||
|
<motion.tr
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer ${expandedModels.has(model.model) ? 'bg-blue-50/10' : ''}`}
|
||||||
|
onClick={() => toggleModel(model.model)}
|
||||||
|
>
|
||||||
|
<td className="p-3 border-r border-gray-100 text-gray-300 text-center italic">{typeGroup.type}</td>
|
||||||
|
<td className="p-3 border-r border-gray-100 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||||
|
<span className={expandedModels.has(model.model) ? 'font-bold text-blue-700' : ''}>{model.model}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 text-[9px] font-normal text-gray-400">
|
||||||
|
<span>资产 <span className="font-bold text-gray-600">{model.total}</span></span>
|
||||||
|
<span>库存 <span className="font-bold text-blue-500">{model.inventory}</span></span>
|
||||||
|
<span>运营 <span className="font-bold text-green-500">{model.operating}</span></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 font-medium">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.total}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 font-medium">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.inventory}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
{['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => (
|
||||||
|
<td key={reg} className="p-3 text-center border-r border-gray-100">
|
||||||
|
{model.inventoryRegions[reg] > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory', source: 'asset', title: `${model.model} - ${reg} 库存` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.inventoryRegions[reg]}
|
||||||
|
</button>
|
||||||
|
) : ''}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="p-3 text-center border-r border-gray-100">
|
||||||
|
{model.pending > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.pending}
|
||||||
|
</button>
|
||||||
|
) : model.pending}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 text-green-600 font-bold bg-green-50/10">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` });
|
||||||
|
}}
|
||||||
|
className="text-green-600 hover:underline font-bold"
|
||||||
|
>
|
||||||
|
{model.operating}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 text-blue-600 bg-blue-50/5">
|
||||||
|
{model.weeklyDelivered > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.weeklyDelivered}
|
||||||
|
</button>
|
||||||
|
) : model.weeklyDelivered}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center border-r border-gray-100 text-orange-600 bg-orange-50/5">
|
||||||
|
{model.weeklyReturned > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.weeklyReturned}
|
||||||
|
</button>
|
||||||
|
) : model.weeklyReturned}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center text-purple-600 bg-purple-50/5 font-medium">
|
||||||
|
{model.weeklyReplaced > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` });
|
||||||
|
}}
|
||||||
|
className="text-blue-500 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{model.weeklyReplaced}
|
||||||
|
</button>
|
||||||
|
) : model.weeklyReplaced}
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import type { AssetSummarySectionProps } from './types';
|
||||||
|
|
||||||
|
export function AssetSummaryMobileCards({
|
||||||
|
processedData,
|
||||||
|
theme,
|
||||||
|
expandedAssetTypes,
|
||||||
|
toggleAssetType,
|
||||||
|
expandedModels,
|
||||||
|
toggleModel,
|
||||||
|
setVehicleSelection: setShowPlateNumbers,
|
||||||
|
}: AssetSummarySectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="lg:hidden p-4 space-y-4">
|
||||||
|
{processedData.map((typeGroup) => (
|
||||||
|
<div key={typeGroup.type} className="space-y-3">
|
||||||
|
<div
|
||||||
|
className={`px-3 py-2 rounded flex justify-between items-center shadow-sm cursor-pointer transition-all ${
|
||||||
|
theme === 'vibrant' ? 'bg-blue-600 text-white active:bg-blue-700' :
|
||||||
|
theme === 'minimal' ? 'bg-white border-l-4 border-blue-500 text-gray-800 active:bg-gray-50' :
|
||||||
|
'bg-blue-50 border border-blue-100 text-blue-700 active:bg-blue-100'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleAssetType(typeGroup.type)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedAssetTypes.has(typeGroup.type) ?
|
||||||
|
<ChevronDown size={16} className={theme === 'vibrant' ? 'text-white' : 'text-blue-500'} /> :
|
||||||
|
<ChevronRight size={16} className={theme === 'vibrant' ? 'text-white/70' : 'text-blue-300'} />
|
||||||
|
}
|
||||||
|
<span className="text-xs font-bold">{typeGroup.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`flex gap-3 text-[9px] font-normal ${theme === 'vibrant' ? 'opacity-90' : 'text-gray-500'}`}>
|
||||||
|
<span>资产 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
|
||||||
|
<span>库存 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
|
||||||
|
<span>运营 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{expandedAssetTypes.has(typeGroup.type) && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="space-y-3 overflow-hidden"
|
||||||
|
>
|
||||||
|
{typeGroup.models.map((model) => (
|
||||||
|
<div key={model.model} className="bg-white rounded-lg border border-gray-100 shadow-sm overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="p-3 flex justify-between items-center cursor-pointer active:bg-gray-50"
|
||||||
|
onClick={() => toggleModel(model.model)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedModels.has(model.model) ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||||
|
<span className="text-xs font-bold text-gray-700">{model.model}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-blue-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model }); }}>资产 {model.total}</span>
|
||||||
|
<span className="text-[10px] bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-orange-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` }); }}>库存 {model.inventory}</span>
|
||||||
|
<span className="text-[10px] bg-green-50 text-green-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-green-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` }); }}>运营 {model.operating}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedModels.has(model.model) && (
|
||||||
|
<div className="px-3 pb-3 pt-1 border-t border-gray-50 bg-gray-50/30">
|
||||||
|
<div className="grid grid-cols-2 gap-y-3 gap-x-4 mt-2">
|
||||||
|
<div className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` })}>
|
||||||
|
<span className="text-[10px] text-gray-400">总库存</span>
|
||||||
|
<span className="text-xs font-bold text-blue-600">{model.inventory}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center cursor-pointer hover:bg-gray-100 p-1 rounded transition-colors"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Pending', source: 'asset', title: `${model.model} - 待交车` })}>
|
||||||
|
<span className="text-[10px] text-gray-400">待交车</span>
|
||||||
|
<span className="text-xs font-bold text-gray-600">{model.pending}</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 grid grid-cols-5 gap-1 py-2 border-y border-gray-100">
|
||||||
|
{['嘉兴', '广东', '北京', '新疆', '其他'].map(reg => (
|
||||||
|
<div key={reg} className="text-center">
|
||||||
|
<div className="text-[8px] text-gray-400 mb-0.5">{reg === '嘉兴' ? '浙' : reg === '广东' ? '粤' : reg === '北京' ? '京' : reg === '新疆' ? '新' : '其'}</div>
|
||||||
|
{model.inventoryRegions[reg] > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: model.model, location: reg, category: 'Inventory', source: 'asset', title: `${model.model} - ${reg} 库存` });
|
||||||
|
}}
|
||||||
|
className="text-[10px] font-bold text-blue-500 hover:underline"
|
||||||
|
>
|
||||||
|
{model.inventoryRegions[reg]}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] font-bold text-gray-300">-</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 grid grid-cols-3 gap-2 pt-1">
|
||||||
|
<div className="bg-blue-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-blue-100/50 transition-colors"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Delivered', source: 'asset', title: `${model.model} - 本周交车` })}>
|
||||||
|
<span className="text-[8px] text-gray-400 mb-1">本周已交车</span>
|
||||||
|
<span className="text-xs font-bold text-blue-600">{model.weeklyDelivered}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-orange-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-orange-100/50 transition-colors"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Returned', source: 'asset', title: `${model.model} - 本周还车` })}>
|
||||||
|
<span className="text-[8px] text-gray-400 mb-1">已还车</span>
|
||||||
|
<span className="text-xs font-bold text-orange-600">{model.weeklyReturned}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-50/50 p-2 rounded flex flex-col items-center cursor-pointer hover:bg-purple-100/50 transition-colors"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Replaced', source: 'asset', title: `${model.model} - 本周替换` })}>
|
||||||
|
<span className="text-[8px] text-gray-400 mb-1">已替换</span>
|
||||||
|
<span className="text-xs font-bold text-purple-600">{model.weeklyReplaced}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Info } from 'lucide-react';
|
||||||
|
import { AssetSummaryDesktopTable } from './AssetSummaryDesktopTable';
|
||||||
|
import { AssetSummaryMobileCards } from './AssetSummaryMobileCards';
|
||||||
|
import type { AssetSummarySectionProps } from './types';
|
||||||
|
|
||||||
|
export function AssetSummarySection(props: AssetSummarySectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden mb-6">
|
||||||
|
<div className="p-4 border-b border-gray-50 bg-gray-50/50 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-4 sm:gap-6">
|
||||||
|
<h2 className="text-sm font-bold text-gray-700">资产数据实时汇总</h2>
|
||||||
|
<div className="hidden md:flex items-center gap-1 text-[10px] text-blue-500 bg-blue-50 px-2 py-0.5 rounded">
|
||||||
|
<Info size={10} />
|
||||||
|
点击型号展开详情
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop View Table */}
|
||||||
|
<AssetSummaryDesktopTable {...props} />
|
||||||
|
|
||||||
|
{/* Mobile View Cards for Asset Summary */}
|
||||||
|
<AssetSummaryMobileCards {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { CalendarDays, ChevronDown, Download, Loader2 } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import type { FlowType } from '../../api';
|
||||||
|
import { FLOW_META } from '../FlowDetailModal';
|
||||||
|
import type { FlowStatisticsCardProps } from './types';
|
||||||
|
|
||||||
|
const FLOW_TYPES: FlowType[] = ['delivered', 'returned', 'replaced'];
|
||||||
|
|
||||||
|
export function FlowStatisticsCard({
|
||||||
|
flowRange,
|
||||||
|
setFlowRange,
|
||||||
|
flowLoading,
|
||||||
|
flowStats,
|
||||||
|
exportFlowDetails,
|
||||||
|
flowDailyExpanded,
|
||||||
|
setFlowDailyExpanded,
|
||||||
|
setSelectedFlow,
|
||||||
|
}: FlowStatisticsCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-3 mb-4">
|
||||||
|
<div data-testid="asset-flow-card" className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-[13px] font-black text-slate-700">
|
||||||
|
<CalendarDays size={14} className="text-blue-500" />
|
||||||
|
<span>交还车统计</span>
|
||||||
|
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => exportFlowDetails()}
|
||||||
|
disabled={!flowStats?.details.length}
|
||||||
|
className="inline-flex h-8 items-center justify-center gap-1 rounded-xl border border-slate-200 bg-slate-50 px-2.5 text-[11px] font-black text-slate-600 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Download size={13} />
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 rounded-xl border border-slate-100 bg-slate-50/80 px-2 py-1.5">
|
||||||
|
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
||||||
|
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
|
||||||
|
<span className="block text-[9px] font-black text-slate-400">开始</span>
|
||||||
|
<span className="mt-0.5 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[12px] font-black text-slate-700">{flowRange.start.replaceAll('-', '/')}</span>
|
||||||
|
<CalendarDays size={12} className="text-slate-300" />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={flowRange.start}
|
||||||
|
onChange={(e) => setFlowRange((prev) => ({ ...prev, start: e.target.value }))}
|
||||||
|
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="rounded-full bg-slate-200/70 px-2 py-0.5 text-[9px] font-black text-slate-400">至</div>
|
||||||
|
<label className="relative cursor-pointer rounded-lg px-2 py-1 transition hover:bg-white">
|
||||||
|
<span className="block text-[9px] font-black text-slate-400">结束</span>
|
||||||
|
<span className="mt-0.5 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[12px] font-black text-slate-700">{flowRange.end.replaceAll('-', '/')}</span>
|
||||||
|
<CalendarDays size={12} className="text-slate-300" />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={flowRange.end}
|
||||||
|
onChange={(e) => setFlowRange((prev) => ({ ...prev, end: e.target.value }))}
|
||||||
|
className="asset-date-input absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5">
|
||||||
|
<div className="grid grid-cols-4 items-center text-center">
|
||||||
|
<div className="px-2">
|
||||||
|
<div className="text-lg font-black leading-none text-slate-950">{flowStats?.totals.total ?? 0}</div>
|
||||||
|
<div className="mt-1 text-[10px] font-black text-slate-400">合计</div>
|
||||||
|
</div>
|
||||||
|
{FLOW_TYPES.map((type) => (
|
||||||
|
<div key={type} className="border-l border-slate-200/70 px-2">
|
||||||
|
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>
|
||||||
|
{flowStats?.totals[type] ?? 0}
|
||||||
|
</div>
|
||||||
|
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="asset-flow-daily-toggle"
|
||||||
|
onClick={() => setFlowDailyExpanded((prev) => !prev)}
|
||||||
|
className="mt-2 flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[12px] font-black text-slate-500 transition hover:border-blue-100 hover:bg-blue-50/50 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<span>{flowDailyExpanded ? '收起每日明细' : '展开每日明细'}</span>
|
||||||
|
<span className="flex items-center gap-2 text-[11px] text-slate-400">
|
||||||
|
{flowStats?.daily.length ?? 0} 天
|
||||||
|
<ChevronDown size={15} className={`transition-transform ${flowDailyExpanded ? 'rotate-180' : ''}`} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{flowDailyExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="mt-3 max-h-[280px] space-y-2 overflow-y-auto pr-1">
|
||||||
|
{flowLoading && (
|
||||||
|
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">正在加载交还车统计...</div>
|
||||||
|
)}
|
||||||
|
{!flowLoading && flowStats?.daily.map((day) => (
|
||||||
|
<div key={day.date} className="rounded-2xl border border-slate-100 bg-white px-3 py-3 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-[11px] font-black text-slate-400">{day.date}</div>
|
||||||
|
<div className="text-[10px] font-bold italic text-slate-300">提交时间</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-4 items-center text-center">
|
||||||
|
<div className="px-2">
|
||||||
|
<div className="text-lg font-black leading-none text-slate-900">{day.total}</div>
|
||||||
|
<div className="mt-1 text-[10px] font-black text-slate-400">合计</div>
|
||||||
|
</div>
|
||||||
|
{FLOW_TYPES.map((type) => {
|
||||||
|
const count = day[type];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
data-testid={`asset-flow-cell-${day.date}-${type}`}
|
||||||
|
disabled={count === 0}
|
||||||
|
onClick={() => setSelectedFlow({ date: day.date, type })}
|
||||||
|
className="border-l border-slate-100 px-2 text-center transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-35"
|
||||||
|
>
|
||||||
|
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>{count}</div>
|
||||||
|
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!flowLoading && !flowStats?.daily.length && (
|
||||||
|
<div className="rounded-xl bg-slate-50 px-3 py-4 text-center text-[11px] font-bold text-slate-400">暂无交换车数据</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import type { InventoryStatisticsSectionProps } from './types';
|
||||||
|
|
||||||
|
type InventoryDesktopTableProps = Pick<
|
||||||
|
InventoryStatisticsSectionProps,
|
||||||
|
| 'inventoryTab'
|
||||||
|
| 'inventoryByRegion'
|
||||||
|
| 'expandedInventoryRegions'
|
||||||
|
| 'toggleInventoryRegion'
|
||||||
|
| 'inventoryByModel'
|
||||||
|
| 'expandedInventoryTypes'
|
||||||
|
| 'toggleInventoryType'
|
||||||
|
| 'setVehicleSelection'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function InventoryDesktopTable({
|
||||||
|
inventoryTab,
|
||||||
|
inventoryByRegion,
|
||||||
|
expandedInventoryRegions,
|
||||||
|
toggleInventoryRegion,
|
||||||
|
inventoryByModel,
|
||||||
|
expandedInventoryTypes,
|
||||||
|
toggleInventoryType,
|
||||||
|
setVehicleSelection: setShowPlateNumbers,
|
||||||
|
}: InventoryDesktopTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="hidden lg:block overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-50 text-[11px] text-slate-500 uppercase tracking-wider border-b border-slate-100">
|
||||||
|
<th className="p-3 font-semibold w-64">{inventoryTab === 'region' ? '区域 / 城市' : '车型分类 / 型号'}</th>
|
||||||
|
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '品牌' : '品牌'}</th>
|
||||||
|
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '车型' : '所在区域/城市'}</th>
|
||||||
|
<th className="p-3 font-semibold text-center w-32">库存数量</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-xs">
|
||||||
|
{inventoryTab === 'region' ? (
|
||||||
|
Object.entries(inventoryByRegion).map(([region, cities]) => (
|
||||||
|
<React.Fragment key={region}>
|
||||||
|
<tr
|
||||||
|
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||||
|
onClick={() => toggleInventoryRegion(region)}
|
||||||
|
>
|
||||||
|
<td colSpan={4} className="p-3 font-bold text-slate-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||||
|
<span>{region}</span>
|
||||||
|
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: 'All', location: region, category: 'Inventory', source: 'asset', title: `库存统计 - ${region}` });
|
||||||
|
}}>
|
||||||
|
(共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<AnimatePresence>
|
||||||
|
{expandedInventoryRegions.has(region) && Object.entries(cities).map(([city, stats]) => (
|
||||||
|
<React.Fragment key={city}>
|
||||||
|
{stats.map((stat, idx) => (
|
||||||
|
<motion.tr
|
||||||
|
key={`${city}-${stat.model}-${idx}`}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
|
||||||
|
{idx === 0 ? <span className="font-medium text-slate-600">{city}</span> : ''}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
|
||||||
|
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.model}</td>
|
||||||
|
<td className="p-3 text-center font-bold text-blue-600">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` });
|
||||||
|
}}
|
||||||
|
className="text-blue-600 hover:underline font-bold"
|
||||||
|
>
|
||||||
|
{stat.quantity}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</React.Fragment>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
Object.entries(inventoryByModel).map(([type, models]) => (
|
||||||
|
<React.Fragment key={type}>
|
||||||
|
<tr
|
||||||
|
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||||
|
onClick={() => toggleInventoryType(type)}
|
||||||
|
>
|
||||||
|
<td colSpan={4} className="p-3 font-bold text-slate-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||||
|
<span>{type}</span>
|
||||||
|
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', type: type, category: 'Inventory', source: 'asset', title: `库存统计 - ${type}` });
|
||||||
|
}}>
|
||||||
|
(共 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<AnimatePresence>
|
||||||
|
{expandedInventoryTypes.has(type) && Object.entries(models).map(([model, stats]) => (
|
||||||
|
<React.Fragment key={model}>
|
||||||
|
{stats.map((stat, idx) => (
|
||||||
|
<motion.tr
|
||||||
|
key={`${model}-${stat.region}-${stat.city}-${idx}`}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
|
||||||
|
{idx === 0 ? <span className="font-medium text-slate-600">{model}</span> : ''}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
|
||||||
|
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.region} / {stat.city}</td>
|
||||||
|
<td className="p-3 text-center font-bold text-blue-600">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` });
|
||||||
|
}}
|
||||||
|
className="text-blue-600 hover:underline font-bold"
|
||||||
|
>
|
||||||
|
{stat.quantity}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</React.Fragment>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import type { InventoryStatisticsSectionProps } from './types';
|
||||||
|
|
||||||
|
type InventoryMobileListProps = Pick<
|
||||||
|
InventoryStatisticsSectionProps,
|
||||||
|
| 'inventoryTab'
|
||||||
|
| 'inventoryByRegion'
|
||||||
|
| 'expandedInventoryRegions'
|
||||||
|
| 'toggleInventoryRegion'
|
||||||
|
| 'inventoryByModel'
|
||||||
|
| 'expandedInventoryTypes'
|
||||||
|
| 'toggleInventoryType'
|
||||||
|
| 'setVehicleSelection'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function InventoryMobileList({
|
||||||
|
inventoryTab,
|
||||||
|
inventoryByRegion,
|
||||||
|
expandedInventoryRegions,
|
||||||
|
toggleInventoryRegion,
|
||||||
|
inventoryByModel,
|
||||||
|
expandedInventoryTypes,
|
||||||
|
toggleInventoryType,
|
||||||
|
setVehicleSelection: setShowPlateNumbers,
|
||||||
|
}: InventoryMobileListProps) {
|
||||||
|
return (
|
||||||
|
<div className="lg:hidden p-3 space-y-3">
|
||||||
|
{inventoryTab === 'region' ? (
|
||||||
|
Object.entries(inventoryByRegion).map(([region, cities]) => (
|
||||||
|
<div key={region} className="border border-slate-100 rounded overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
|
||||||
|
onClick={() => toggleInventoryRegion(region)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||||
|
<span className="text-xs font-bold text-slate-700">{region}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-bold text-blue-600">
|
||||||
|
{Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedInventoryRegions.has(region) && (
|
||||||
|
<div className="p-2 space-y-2 bg-white">
|
||||||
|
{Object.entries(cities).map(([city, stats]) => (
|
||||||
|
<div key={city} className="border-l-2 border-slate-200 pl-3 py-1">
|
||||||
|
<div className="text-[10px] font-bold text-slate-500 mb-2">{city}</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stats.map((stat, idx) => (
|
||||||
|
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
|
||||||
|
<span className="text-slate-700 font-medium">{stat.model}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` })}
|
||||||
|
className="font-bold text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{stat.quantity} 台
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
Object.entries(inventoryByModel).map(([type, models]) => (
|
||||||
|
<div key={type} className="border border-slate-100 rounded overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
|
||||||
|
onClick={() => toggleInventoryType(type)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||||
|
<span className="text-xs font-bold text-slate-700">{type}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-bold text-blue-600">
|
||||||
|
{Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedInventoryTypes.has(type) && (
|
||||||
|
<div className="p-2 space-y-2 bg-white">
|
||||||
|
{Object.entries(models).map(([model, stats]) => (
|
||||||
|
<div key={model} className="border-l-2 border-slate-200 pl-3 py-1">
|
||||||
|
<div className="text-[10px] font-bold text-slate-500 mb-2">{model}</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stats.map((stat, idx) => (
|
||||||
|
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
|
||||||
|
<span className="text-slate-700 font-medium">{stat.region} / {stat.city}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: stat.model, location: stat.city, category: 'Inventory', source: 'asset', title: `库存统计 - ${stat.model} - ${stat.city}` })}
|
||||||
|
className="font-bold text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{stat.quantity} 台
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { Filter } from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import { InventoryDesktopTable } from './InventoryDesktopTable';
|
||||||
|
import { InventoryMobileList } from './InventoryMobileList';
|
||||||
|
import type { InventoryStatisticsSectionProps } from './types';
|
||||||
|
|
||||||
|
export function InventoryStatisticsSection({
|
||||||
|
inventoryFilters,
|
||||||
|
setInventoryFilters,
|
||||||
|
draftInventoryFilters,
|
||||||
|
setDraftInventoryFilters,
|
||||||
|
isInventoryFilterOpen,
|
||||||
|
setIsInventoryFilterOpen,
|
||||||
|
uniqueInventoryRegions,
|
||||||
|
uniqueInventoryCities,
|
||||||
|
uniqueInventoryBrands,
|
||||||
|
uniqueInventoryTypes,
|
||||||
|
uniqueInventoryModelsForType,
|
||||||
|
filteredInventoryStats,
|
||||||
|
inventoryTab,
|
||||||
|
setInventoryTab,
|
||||||
|
inventoryByRegion,
|
||||||
|
expandedInventoryRegions,
|
||||||
|
toggleInventoryRegion,
|
||||||
|
inventoryByModel,
|
||||||
|
expandedInventoryTypes,
|
||||||
|
toggleInventoryType,
|
||||||
|
setVehicleSelection: setShowPlateNumbers,
|
||||||
|
}: InventoryStatisticsSectionProps) {
|
||||||
|
const listProps = {
|
||||||
|
inventoryTab,
|
||||||
|
inventoryByRegion,
|
||||||
|
expandedInventoryRegions,
|
||||||
|
toggleInventoryRegion,
|
||||||
|
inventoryByModel,
|
||||||
|
expandedInventoryTypes,
|
||||||
|
toggleInventoryType,
|
||||||
|
setVehicleSelection: setShowPlateNumbers,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm mb-6 overflow-hidden min-h-[420px]">
|
||||||
|
<div className="p-3 sm:p-4 border-b border-gray-50 bg-white flex items-center justify-between relative">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-1.5 h-6 bg-blue-600 rounded-full"></div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h2 className="text-lg font-bold text-gray-800 leading-tight">库存统计</h2>
|
||||||
|
<span className="text-[10px] text-gray-400 font-medium">实时更新库存数据</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6 ml-auto pr-2">
|
||||||
|
<div className="flex flex-col items-end cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
|
||||||
|
<span className="text-[10px] text-gray-400 font-bold tracking-wider uppercase mb-0.5">总库存数据</span>
|
||||||
|
<span className="text-2xl font-black text-gray-900 leading-none">
|
||||||
|
{filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)}
|
||||||
|
<span className="text-xs font-bold text-gray-400 ml-1">台</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => { if (!isInventoryFilterOpen) setDraftInventoryFilters({...inventoryFilters}); setIsInventoryFilterOpen(!isInventoryFilterOpen); }}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
isInventoryFilterOpen || (inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model)
|
||||||
|
? 'bg-blue-600 text-white shadow-md'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Filter size={14} />
|
||||||
|
<span>筛选</span>
|
||||||
|
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
|
||||||
|
<span className="w-2 h-2 bg-white rounded-full animate-pulse"></span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isInventoryFilterOpen && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
|
className="fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-slate-100 z-50 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-xs font-bold text-slate-800">库存统计 - 数据筛选</h3>
|
||||||
|
<button onClick={() => setDraftInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[10px] text-blue-500 hover:underline">重置所有</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-left">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-slate-400 block mb-1">区域</label>
|
||||||
|
<select value={draftInventoryFilters.region} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, region: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||||
|
<option value="">全部区域</option>
|
||||||
|
{uniqueInventoryRegions.map(r => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-slate-400 block mb-1">城市</label>
|
||||||
|
<select value={draftInventoryFilters.city} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, city: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||||
|
<option value="">全部城市</option>
|
||||||
|
{uniqueInventoryCities.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-slate-400 block mb-1">品牌</label>
|
||||||
|
<select value={draftInventoryFilters.brand} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, brand: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||||
|
<option value="">全部品牌</option>
|
||||||
|
{uniqueInventoryBrands.map(b => <option key={b} value={b}>{b}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-slate-400 block mb-1">车型</label>
|
||||||
|
<select value={draftInventoryFilters.type} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, type: e.target.value, model: ''})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||||
|
<option value="">全部车型</option>
|
||||||
|
{uniqueInventoryTypes.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-slate-400 block mb-1">批次</label>
|
||||||
|
<select value={draftInventoryFilters.model} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, model: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
|
||||||
|
<option value="">全部批次</option>
|
||||||
|
{uniqueInventoryModelsForType.map(m => <option key={m} value={m}>{m}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { setInventoryFilters({...draftInventoryFilters}); setIsInventoryFilterOpen(false); }} className="w-full mt-4 py-2 bg-blue-600 text-white rounded-lg text-xs font-bold hover:bg-blue-700 transition-colors">确认</button>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
|
||||||
|
<div className="flex bg-gray-200/50 p-1 rounded-lg w-fit shadow-inner">
|
||||||
|
<button
|
||||||
|
onClick={() => setInventoryTab('region')}
|
||||||
|
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
|
||||||
|
inventoryTab === 'region' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
按区域
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setInventoryTab('model')}
|
||||||
|
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
|
||||||
|
inventoryTab === 'model' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
按车型
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Filters Bar */}
|
||||||
|
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
|
||||||
|
<div className="px-4 py-2 border-b border-gray-100 flex flex-wrap gap-2 items-center">
|
||||||
|
{inventoryFilters.region && (
|
||||||
|
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||||
|
区域: {inventoryFilters.region}
|
||||||
|
<button onClick={() => setInventoryFilters({...inventoryFilters, region: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{inventoryFilters.city && (
|
||||||
|
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||||
|
城市: {inventoryFilters.city}
|
||||||
|
<button onClick={() => setInventoryFilters({...inventoryFilters, city: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{inventoryFilters.brand && (
|
||||||
|
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||||
|
品牌: {inventoryFilters.brand}
|
||||||
|
<button onClick={() => setInventoryFilters({...inventoryFilters, brand: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{inventoryFilters.type && (
|
||||||
|
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||||
|
车型: {inventoryFilters.type}
|
||||||
|
<button onClick={() => setInventoryFilters({...inventoryFilters, type: '', model: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{inventoryFilters.model && (
|
||||||
|
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
|
||||||
|
批次: {inventoryFilters.model}
|
||||||
|
<button onClick={() => setInventoryFilters({...inventoryFilters, model: ''})} className="hover:text-red-500 ml-0.5">×</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[11px] text-red-500 font-bold ml-auto hover:text-red-600">清除</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop View Table */}
|
||||||
|
<InventoryDesktopTable {...listProps} />
|
||||||
|
|
||||||
|
{/* Mobile View */}
|
||||||
|
<InventoryMobileList {...listProps} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Activity, PlusCircle, Truck, Warehouse } from 'lucide-react';
|
||||||
|
import type { SummaryMetricsProps } from './types';
|
||||||
|
|
||||||
|
export function SummaryMetrics({
|
||||||
|
summary: SUMMARY,
|
||||||
|
operatingRate,
|
||||||
|
inventoryRate,
|
||||||
|
pendingRate,
|
||||||
|
setVehicleSelection: setShowPlateNumbers,
|
||||||
|
}: SummaryMetricsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header Summary - Ultra Compact */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">
|
||||||
|
{/* Total Assets */}
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', source: 'asset', title: '资产概览' })}>
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
|
||||||
|
<Truck size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">资产总数</div>
|
||||||
|
<div className="text-base font-bold text-gray-800 leading-none">{SUMMARY.totalAssets.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operating */}
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset', title: '正在运营' })}>
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
|
||||||
|
<Activity size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">总运营</div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-base font-bold text-blue-600 leading-none">{SUMMARY.operating.total}</span>
|
||||||
|
<span className="text-[8px] text-gray-400 leading-none">自{SUMMARY.operating.self} 租{SUMMARY.operating.leased}{SUMMARY.operating.hanging > 0 && ` 挂${SUMMARY.operating.hanging}`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inventory */}
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}>
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-500">
|
||||||
|
<Warehouse size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">总库存</div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-base font-bold text-gray-800 leading-none">{SUMMARY.inventory.total}</span>
|
||||||
|
<span className="text-[8px] text-gray-400 leading-none">库{SUMMARY.inventory.inStock} 异{SUMMARY.inventory.abnormal}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pending */}
|
||||||
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm flex items-center gap-2 cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '待交车' })}>
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-blue-50 flex items-center justify-center text-blue-500">
|
||||||
|
<PlusCircle size={14} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-blue-500 font-bold uppercase leading-none mb-0.5">待交车</div>
|
||||||
|
<div className="text-base font-bold text-blue-600 leading-none">{SUMMARY.pendingDelivery}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="shrink-0 text-[11px] font-black text-slate-400">运营概览</div>
|
||||||
|
<div className="grid min-w-0 flex-1 grid-cols-3 divide-x divide-slate-100 text-center">
|
||||||
|
<div className="px-2">
|
||||||
|
<div className="text-[10px] font-black text-slate-400">运营率</div>
|
||||||
|
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-2">
|
||||||
|
<div className="text-[10px] font-black text-slate-400">库存率</div>
|
||||||
|
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-2">
|
||||||
|
<div className="text-[10px] font-black text-slate-400">待交率</div>
|
||||||
|
<div className="mt-0.5 text-base font-black text-amber-600">{pendingRate.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
import type { FlowDetailItem, FlowStatsResponse, FlowType } from '../../api';
|
||||||
|
import type {
|
||||||
|
DateRange,
|
||||||
|
InventoryFilters,
|
||||||
|
VehicleModalSelection,
|
||||||
|
} from '../../model';
|
||||||
|
import type { RegionalInventoryStats, SummaryData, TypeSummary } from '../../types';
|
||||||
|
import type { AssetsTheme } from '../AssetsHeader';
|
||||||
|
|
||||||
|
export interface SummaryMetricsProps {
|
||||||
|
summary: SummaryData;
|
||||||
|
operatingRate: number;
|
||||||
|
inventoryRate: number;
|
||||||
|
pendingRate: number;
|
||||||
|
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowStatisticsCardProps {
|
||||||
|
flowRange: DateRange;
|
||||||
|
setFlowRange: Dispatch<SetStateAction<DateRange>>;
|
||||||
|
flowLoading: boolean;
|
||||||
|
flowStats: FlowStatsResponse | null;
|
||||||
|
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
|
||||||
|
flowDailyExpanded: boolean;
|
||||||
|
setFlowDailyExpanded: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setSelectedFlow: Dispatch<SetStateAction<{ date: string; type: FlowType } | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetSummarySectionProps {
|
||||||
|
processedData: TypeSummary[];
|
||||||
|
theme: AssetsTheme;
|
||||||
|
expandedAssetTypes: Set<string>;
|
||||||
|
toggleAssetType: (type: string) => void;
|
||||||
|
expandedModels: Set<string>;
|
||||||
|
toggleModel: (model: string) => void;
|
||||||
|
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryStatisticsSectionProps {
|
||||||
|
inventoryFilters: InventoryFilters;
|
||||||
|
setInventoryFilters: Dispatch<SetStateAction<InventoryFilters>>;
|
||||||
|
draftInventoryFilters: InventoryFilters;
|
||||||
|
setDraftInventoryFilters: Dispatch<SetStateAction<InventoryFilters>>;
|
||||||
|
isInventoryFilterOpen: boolean;
|
||||||
|
setIsInventoryFilterOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
uniqueInventoryRegions: string[];
|
||||||
|
uniqueInventoryCities: string[];
|
||||||
|
uniqueInventoryBrands: string[];
|
||||||
|
uniqueInventoryTypes: string[];
|
||||||
|
uniqueInventoryModelsForType: string[];
|
||||||
|
filteredInventoryStats: RegionalInventoryStats[];
|
||||||
|
inventoryTab: 'region' | 'model';
|
||||||
|
setInventoryTab: Dispatch<SetStateAction<'region' | 'model'>>;
|
||||||
|
inventoryByRegion: Record<string, Record<string, RegionalInventoryStats[]>>;
|
||||||
|
expandedInventoryRegions: Set<string>;
|
||||||
|
toggleInventoryRegion: (region: string) => void;
|
||||||
|
inventoryByModel: Record<string, Record<string, RegionalInventoryStats[]>>;
|
||||||
|
expandedInventoryTypes: Set<string>;
|
||||||
|
toggleInventoryType: (type: string) => void;
|
||||||
|
setVehicleSelection: Dispatch<SetStateAction<VehicleModalSelection | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OverviewViewProps = SummaryMetricsProps
|
||||||
|
& FlowStatisticsCardProps
|
||||||
|
& AssetSummarySectionProps
|
||||||
|
& InventoryStatisticsSectionProps
|
||||||
|
& {
|
||||||
|
allTypesExpanded: boolean;
|
||||||
|
toggleAllAssetTypes: () => void;
|
||||||
|
};
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { FlowStatsResponse, WeeklyDetailItem } from './api';
|
||||||
|
import type {
|
||||||
|
CustomerStats,
|
||||||
|
DeptGroup,
|
||||||
|
ManagerStats,
|
||||||
|
RegionalInventoryStats,
|
||||||
|
VehicleListItem,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
buildCustomerPieData,
|
||||||
|
buildVehicleModalRequest,
|
||||||
|
deriveCustomerView,
|
||||||
|
deriveDepartmentView,
|
||||||
|
deriveInventoryView,
|
||||||
|
deriveModalVehicleView,
|
||||||
|
formatLocalDate,
|
||||||
|
formatLocalDateTime,
|
||||||
|
getWeeklyFlowRange,
|
||||||
|
groupInventoryByModel,
|
||||||
|
selectFlowDetails,
|
||||||
|
} from './model';
|
||||||
|
|
||||||
|
const emptyInventoryFilters = { region: '', city: '', brand: '', type: '', model: '' };
|
||||||
|
const emptyCustomerFilters = { customer: [], brand: '', department: '', manager: '', region: '' };
|
||||||
|
const emptyModalFilters = { plateNumber: '', model: '', brand: '', location: '' };
|
||||||
|
|
||||||
|
function inventory(overrides: Partial<RegionalInventoryStats>): RegionalInventoryStats {
|
||||||
|
return {
|
||||||
|
region: '广东',
|
||||||
|
city: '广州',
|
||||||
|
brand: '现代',
|
||||||
|
type: '18T',
|
||||||
|
model: 'M1',
|
||||||
|
batch: 'B1',
|
||||||
|
quantity: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function manager(overrides: Partial<ManagerStats>): ManagerStats {
|
||||||
|
return {
|
||||||
|
manager: '负责人甲',
|
||||||
|
department: '业务一部',
|
||||||
|
t4_5: 0,
|
||||||
|
t4_5c: 0,
|
||||||
|
t18: 0,
|
||||||
|
t49: 0,
|
||||||
|
trailer: 0,
|
||||||
|
other: 0,
|
||||||
|
total: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function department(name: string, managers: ManagerStats[]): DeptGroup {
|
||||||
|
return {
|
||||||
|
department: name,
|
||||||
|
totalAssets: managers.reduce((sum, item) => sum + item.total, 0),
|
||||||
|
operatingCount: 0,
|
||||||
|
idleCount: 0,
|
||||||
|
attendanceRate: 0,
|
||||||
|
avgMileage: 0,
|
||||||
|
managers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function customer(overrides: Partial<CustomerStats>): CustomerStats {
|
||||||
|
return {
|
||||||
|
customer: '客户甲',
|
||||||
|
manager: '负责人甲',
|
||||||
|
brand: '现代',
|
||||||
|
department: '业务一部',
|
||||||
|
region: '广东',
|
||||||
|
city: '广州',
|
||||||
|
t4_5: 0,
|
||||||
|
t4_5c: 0,
|
||||||
|
t18: 0,
|
||||||
|
t49: 0,
|
||||||
|
trailer: 0,
|
||||||
|
other: 0,
|
||||||
|
total: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function vehicle(overrides: Partial<VehicleListItem>): VehicleListItem {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
plateNumber: '粤A00001',
|
||||||
|
vin: 'VIN-1',
|
||||||
|
type: '18T',
|
||||||
|
model: 'M1',
|
||||||
|
location: '广州',
|
||||||
|
province: '广东',
|
||||||
|
city: '广州',
|
||||||
|
status: '运营',
|
||||||
|
ownership: '自营',
|
||||||
|
contractNo: null,
|
||||||
|
customerName: null,
|
||||||
|
subjectOrg: null,
|
||||||
|
departmentName: null,
|
||||||
|
customerManager: null,
|
||||||
|
brandLabel: '现代',
|
||||||
|
orgName: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('本地日期格式不引入 UTC 偏移', () => {
|
||||||
|
const date = new Date(2026, 7, 3, 9, 7, 5);
|
||||||
|
assert.equal(formatLocalDate(date), '2026-08-03');
|
||||||
|
assert.equal(formatLocalDateTime(date), '2026-08-03 09:07:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('周流转区间按周六至周五计算', () => {
|
||||||
|
assert.deepEqual(getWeeklyFlowRange(new Date(2026, 7, 12)), {
|
||||||
|
start: '2026-08-08',
|
||||||
|
end: '2026-08-14',
|
||||||
|
});
|
||||||
|
assert.deepEqual(getWeeklyFlowRange(new Date(2026, 7, 15)), {
|
||||||
|
start: '2026-08-08',
|
||||||
|
end: '2026-08-14',
|
||||||
|
});
|
||||||
|
assert.deepEqual(getWeeklyFlowRange(new Date(2026, 7, 16)), {
|
||||||
|
start: '2026-08-08',
|
||||||
|
end: '2026-08-14',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('交还替明细走周接口且原样保留周筛选参数', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildVehicleModalRequest({
|
||||||
|
batch: 'All',
|
||||||
|
model: 'M1',
|
||||||
|
location: '广州',
|
||||||
|
category: 'Delivered',
|
||||||
|
source: 'asset',
|
||||||
|
}, '主体甲'),
|
||||||
|
{
|
||||||
|
kind: 'weekly',
|
||||||
|
type: 'delivered',
|
||||||
|
filters: { model: 'M1', batch: 'All', location: '广州', source: 'asset' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('待交付与车型映射继续走车辆列表参数', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildVehicleModalRequest({
|
||||||
|
batch: 'B1',
|
||||||
|
model: 'M1',
|
||||||
|
location: '广州',
|
||||||
|
category: 'Pending',
|
||||||
|
type: '4.5T',
|
||||||
|
isColdChain: true,
|
||||||
|
isTrailer: false,
|
||||||
|
manager: '负责人甲',
|
||||||
|
}, '主体甲'),
|
||||||
|
{
|
||||||
|
kind: 'vehicles',
|
||||||
|
params: {
|
||||||
|
batch: 'B1',
|
||||||
|
model: 'M1',
|
||||||
|
location: '广州',
|
||||||
|
category: 'Pending',
|
||||||
|
manager: '负责人甲',
|
||||||
|
vehicleType: '4.5T冷链',
|
||||||
|
subject: '主体甲',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
buildVehicleModalRequest({
|
||||||
|
batch: 'All',
|
||||||
|
model: 'All',
|
||||||
|
location: 'All',
|
||||||
|
isColdChain: false,
|
||||||
|
isTrailer: true,
|
||||||
|
}, null),
|
||||||
|
{
|
||||||
|
kind: 'vehicles',
|
||||||
|
params: { isColdChain: 'false', isTrailer: 'true', subject: null },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('库存筛选与区域、车型分组保持既有顺序', () => {
|
||||||
|
const rows = [
|
||||||
|
inventory({ region: '广东', city: '广州', type: '18T', model: 'M1', quantity: 2 }),
|
||||||
|
inventory({ region: '浙江', city: '嘉兴', type: '4.5T普货', model: 'M2', quantity: 3 }),
|
||||||
|
inventory({ region: '广东', city: '佛山', type: '18T', model: 'M3', quantity: 4 }),
|
||||||
|
inventory({ region: '广东', city: '广州', type: '新增车型', model: 'M4', quantity: 5 }),
|
||||||
|
];
|
||||||
|
const view = deriveInventoryView(
|
||||||
|
rows,
|
||||||
|
{ ...emptyInventoryFilters, region: '广东', type: '18T' },
|
||||||
|
'18T',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(view.filtered.map((item) => item.model), ['M1', 'M3']);
|
||||||
|
assert.deepEqual(view.modelsForType, ['M1', 'M3']);
|
||||||
|
assert.deepEqual(Object.keys(view.byRegion), ['广东']);
|
||||||
|
assert.deepEqual(Object.keys(view.byRegion['广东']), ['广州', '佛山']);
|
||||||
|
assert.deepEqual(Object.keys(view.byModel), ['18T']);
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(groupInventoryByModel(rows)),
|
||||||
|
['4.5T普货', '18T', '新增车型'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('部门负责人去重、分组并按车辆数倒序', () => {
|
||||||
|
const departments = [
|
||||||
|
department('业务二部', [
|
||||||
|
manager({ manager: '负责人乙', department: '业务二部', total: 3 }),
|
||||||
|
manager({ manager: '负责人甲', department: '业务二部', total: 8 }),
|
||||||
|
]),
|
||||||
|
department('业务一部', [manager({ manager: '负责人甲', total: 5 })]),
|
||||||
|
];
|
||||||
|
const view = deriveDepartmentView(departments, 'All');
|
||||||
|
|
||||||
|
assert.deepEqual(view.managers, ['负责人乙', '负责人甲']);
|
||||||
|
assert.deepEqual(view.groupedManagers, [
|
||||||
|
{ department: '业务二部', managers: ['负责人乙', '负责人甲'] },
|
||||||
|
{ department: '业务一部', managers: ['负责人甲'] },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(view.managerStats.map((item) => item.total), [8, 5, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('客户筛选与负责人分组合并部门和客户来源', () => {
|
||||||
|
const departments = [
|
||||||
|
department('业务二部', [manager({ manager: '负责人乙', department: '业务二部' })]),
|
||||||
|
department('业务一部', [manager({ manager: '负责人甲' })]),
|
||||||
|
];
|
||||||
|
const customers = [
|
||||||
|
customer({ customer: '客户甲', department: '业务一部', manager: '负责人甲', total: 2 }),
|
||||||
|
customer({ customer: '客户乙', department: '业务二部', manager: '负责人丙', region: '浙江', city: '嘉兴', total: 3 }),
|
||||||
|
customer({ customer: '公务车', department: '公务车', manager: '负责人丁', total: 1 }),
|
||||||
|
];
|
||||||
|
const view = deriveCustomerView(
|
||||||
|
customers,
|
||||||
|
departments,
|
||||||
|
{ ...emptyCustomerFilters, customer: ['客户乙'], region: '浙江' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(view.filtered.map((item) => item.customer), ['客户乙']);
|
||||||
|
assert.deepEqual(view.departments, ['业务一部', '业务二部', '公务车']);
|
||||||
|
assert.deepEqual(view.managersByDepartment, [
|
||||||
|
{ department: '业务一部', managers: ['负责人甲'] },
|
||||||
|
{ department: '业务二部', managers: ['负责人乙', '负责人丙'] },
|
||||||
|
{ department: '公务车', managers: ['负责人丁'] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('弹窗筛选沿用车牌为空时回退 VIN 的规则', () => {
|
||||||
|
const vehicles = [
|
||||||
|
vehicle({ id: 1 }),
|
||||||
|
vehicle({ id: 2, plateNumber: '', vin: 'VIN-2', model: 'M2', brandLabel: '福田', location: '嘉兴' }),
|
||||||
|
];
|
||||||
|
const weeklyDetails: WeeklyDetailItem[] = [
|
||||||
|
{ truck_id: 1, plate_number: '粤A00001', handover_date: null, contract_type: null, customer_name: null },
|
||||||
|
{ truck_id: 2, plate_number: 'VIN-2', handover_date: null, contract_type: null, customer_name: null },
|
||||||
|
];
|
||||||
|
const view = deriveModalVehicleView(
|
||||||
|
vehicles,
|
||||||
|
weeklyDetails,
|
||||||
|
{ ...emptyModalFilters, plateNumber: 'VIN-2' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(view.plates, ['粤A00001', 'VIN-2']);
|
||||||
|
assert.deepEqual(view.filteredVehicles.map((item) => item.id), [2]);
|
||||||
|
assert.deepEqual(view.filteredWeeklyDetails.map((item) => item.truck_id), [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('流转明细按日期和类型同时筛选,客户饼图按区域汇总', () => {
|
||||||
|
const flowStats: FlowStatsResponse = {
|
||||||
|
start: '2026-08-08',
|
||||||
|
end: '2026-08-14',
|
||||||
|
daily: [],
|
||||||
|
totals: { delivered: 1, returned: 1, replaced: 0, total: 2 },
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
id: '1', type: 'delivered', typeLabel: '交车', date: '2026-08-12', truckId: '1',
|
||||||
|
plateNumber: '粤A00001', eventTime: null, submitTime: null, department: '', manager: '', customerName: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2', type: 'returned', typeLabel: '还车', date: '2026-08-12', truckId: '2',
|
||||||
|
plateNumber: '粤A00002', eventTime: null, submitTime: null, department: '', manager: '', customerName: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.deepEqual(
|
||||||
|
selectFlowDetails(flowStats, { date: '2026-08-12', type: 'delivered' }).map((item) => item.id),
|
||||||
|
['1'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const customers = [
|
||||||
|
customer({ region: '广东', total: 2 }),
|
||||||
|
customer({ customer: '客户乙', region: '浙江', total: 5 }),
|
||||||
|
customer({ customer: '客户丙', region: '广东', total: 3 }),
|
||||||
|
];
|
||||||
|
assert.deepEqual(buildCustomerPieData(customers, 'region', []), [
|
||||||
|
{ name: '广东', value: 5 },
|
||||||
|
{ name: '浙江', value: 5 },
|
||||||
|
]);
|
||||||
|
const provinceData = [{ name: '广东省', value: 10 }];
|
||||||
|
assert.equal(buildCustomerPieData(customers, 'province', provinceData), provinceData);
|
||||||
|
});
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import type { FlowStatsResponse, FlowType, WeeklyDetailItem } from './api';
|
||||||
|
import type {
|
||||||
|
CustomerStats,
|
||||||
|
DeptGroup,
|
||||||
|
RegionalInventoryStats,
|
||||||
|
VehicleListItem,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const INVENTORY_TYPE_ORDER = ['4.5T普货', '4.5T冷链', '18T', '49T', '挂车', '其他'];
|
||||||
|
const CHINESE_NUMBER_ORDER: Record<string, number> = {
|
||||||
|
一: 1,
|
||||||
|
二: 2,
|
||||||
|
三: 3,
|
||||||
|
四: 4,
|
||||||
|
五: 5,
|
||||||
|
六: 6,
|
||||||
|
七: 7,
|
||||||
|
八: 8,
|
||||||
|
九: 9,
|
||||||
|
十: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryFilters {
|
||||||
|
region: string;
|
||||||
|
city: string;
|
||||||
|
brand: string;
|
||||||
|
type: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerFilters {
|
||||||
|
customer: string[];
|
||||||
|
brand: string;
|
||||||
|
department: string;
|
||||||
|
manager: string;
|
||||||
|
region: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModalVehicleFilters {
|
||||||
|
plateNumber: string;
|
||||||
|
model: string;
|
||||||
|
brand: string;
|
||||||
|
location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VehicleModalCategory =
|
||||||
|
| 'Inventory'
|
||||||
|
| 'Pending'
|
||||||
|
| 'Delivered'
|
||||||
|
| 'Returned'
|
||||||
|
| 'Replaced'
|
||||||
|
| 'Operating';
|
||||||
|
|
||||||
|
export interface VehicleModalSelection {
|
||||||
|
batch: string;
|
||||||
|
model: string;
|
||||||
|
location: string;
|
||||||
|
category?: VehicleModalCategory;
|
||||||
|
vehicleType?: string;
|
||||||
|
manager?: string;
|
||||||
|
customer?: string;
|
||||||
|
department?: string;
|
||||||
|
attendance?: 'active' | 'idle';
|
||||||
|
isColdChain?: boolean;
|
||||||
|
isTrailer?: boolean;
|
||||||
|
type?: string;
|
||||||
|
source?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehicleListRequestParams {
|
||||||
|
batch?: string;
|
||||||
|
model?: string;
|
||||||
|
location?: string;
|
||||||
|
category?: 'Inventory' | 'Operating' | 'Pending';
|
||||||
|
vehicleType?: string;
|
||||||
|
manager?: string;
|
||||||
|
customer?: string;
|
||||||
|
isColdChain?: string;
|
||||||
|
isTrailer?: string;
|
||||||
|
department?: string;
|
||||||
|
attendance?: string;
|
||||||
|
subject?: string | null;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VehicleModalRequest =
|
||||||
|
| {
|
||||||
|
kind: 'weekly';
|
||||||
|
type: FlowType;
|
||||||
|
filters: { model: string; batch: string; location: string; source?: string };
|
||||||
|
}
|
||||||
|
| { kind: 'vehicles'; params: VehicleListRequestParams };
|
||||||
|
|
||||||
|
export interface FlowSelection {
|
||||||
|
date: string;
|
||||||
|
type: FlowType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLocalDateTime(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLocalDate(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date: Date, days: number): Date {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setDate(next.getDate() + days);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 资产流转周报按周六至周五统计;周末查看时仍停留在刚结束的周五。
|
||||||
|
export function getWeeklyFlowRange(referenceDate = new Date()): DateRange {
|
||||||
|
const day = referenceDate.getDay();
|
||||||
|
const end = day === 6
|
||||||
|
? addDays(referenceDate, -1)
|
||||||
|
: day === 0
|
||||||
|
? addDays(referenceDate, -2)
|
||||||
|
: addDays(referenceDate, 5 - day);
|
||||||
|
return {
|
||||||
|
start: formatLocalDate(addDays(end, -6)),
|
||||||
|
end: formatLocalDate(end),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKLY_FLOW_TYPE_BY_CATEGORY: Partial<Record<VehicleModalCategory, FlowType>> = {
|
||||||
|
Delivered: 'delivered',
|
||||||
|
Returned: 'returned',
|
||||||
|
Replaced: 'replaced',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pending 不是周流转事件,必须继续走车辆列表接口以保留型号、批次和区域筛选。
|
||||||
|
export function buildVehicleModalRequest(
|
||||||
|
selection: VehicleModalSelection,
|
||||||
|
subject: string | null,
|
||||||
|
): VehicleModalRequest {
|
||||||
|
const weeklyType = selection.category
|
||||||
|
? WEEKLY_FLOW_TYPE_BY_CATEGORY[selection.category]
|
||||||
|
: undefined;
|
||||||
|
if (weeklyType) {
|
||||||
|
return {
|
||||||
|
kind: 'weekly',
|
||||||
|
type: weeklyType,
|
||||||
|
filters: {
|
||||||
|
model: selection.model,
|
||||||
|
batch: selection.batch,
|
||||||
|
location: selection.location,
|
||||||
|
source: selection.source,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: VehicleListRequestParams = {};
|
||||||
|
if (selection.vehicleType) params.vehicleType = selection.vehicleType;
|
||||||
|
if (selection.batch !== 'All') params.batch = selection.batch;
|
||||||
|
if (selection.model !== 'All') params.model = selection.model;
|
||||||
|
if (selection.location !== 'All') params.location = selection.location;
|
||||||
|
if (selection.source) params.source = selection.source;
|
||||||
|
if (selection.category === 'Inventory') params.category = 'Inventory';
|
||||||
|
if (selection.category === 'Operating') params.category = 'Operating';
|
||||||
|
if (selection.category === 'Pending') params.category = 'Pending';
|
||||||
|
if (selection.manager) params.manager = selection.manager;
|
||||||
|
if (selection.customer) params.customer = selection.customer;
|
||||||
|
if (selection.department) params.department = selection.department;
|
||||||
|
if (selection.attendance) params.attendance = selection.attendance;
|
||||||
|
|
||||||
|
if (!selection.type) {
|
||||||
|
if (selection.isColdChain !== undefined) params.isColdChain = String(selection.isColdChain);
|
||||||
|
if (selection.isTrailer !== undefined) params.isTrailer = String(selection.isTrailer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面车型分组与列表接口的 vehicleType 取值并不完全一致,这里集中保留既有映射。
|
||||||
|
if (selection.type === '4.5T') {
|
||||||
|
if (selection.isColdChain === true) params.vehicleType = '4.5T冷链';
|
||||||
|
if (selection.isColdChain === false) params.vehicleType = '4.5T普货';
|
||||||
|
} else if (
|
||||||
|
selection.type === '4.5T普货'
|
||||||
|
|| selection.type === '4.5T冷链'
|
||||||
|
|| selection.type === '18T'
|
||||||
|
|| selection.type === '49T'
|
||||||
|
|| selection.type === '挂车'
|
||||||
|
|| selection.type === '其他'
|
||||||
|
) {
|
||||||
|
params.vehicleType = selection.type;
|
||||||
|
} else if (selection.type === '其他车型') {
|
||||||
|
if (selection.isTrailer === true) params.isTrailer = 'true';
|
||||||
|
if (selection.isTrailer === false) params.vehicleType = '其他';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'vehicles', params: { ...params, subject } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueNonEmpty(values: Array<string | null | undefined>): string[] {
|
||||||
|
return Array.from(new Set(values.filter((value): value is string => Boolean(value))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDepartmentOrder(name: string): number {
|
||||||
|
const match = name.match(/[一二三四五六七八九十]/);
|
||||||
|
return match ? (CHINESE_NUMBER_ORDER[match[0]] || 99) : 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterInventoryStats(
|
||||||
|
inventory: RegionalInventoryStats[],
|
||||||
|
filters: InventoryFilters,
|
||||||
|
): RegionalInventoryStats[] {
|
||||||
|
return inventory.filter((item) => (
|
||||||
|
(!filters.region || item.region === filters.region)
|
||||||
|
&& (!filters.city || item.city === filters.city)
|
||||||
|
&& (!filters.brand || item.brand === filters.brand)
|
||||||
|
&& (!filters.type || item.type === filters.type)
|
||||||
|
&& (!filters.model || item.model === filters.model)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupInventoryByRegion(
|
||||||
|
inventory: RegionalInventoryStats[],
|
||||||
|
): Record<string, Record<string, RegionalInventoryStats[]>> {
|
||||||
|
const result: Record<string, Record<string, RegionalInventoryStats[]>> = {};
|
||||||
|
for (const item of inventory) {
|
||||||
|
if (!result[item.region]) result[item.region] = {};
|
||||||
|
if (!result[item.region][item.city]) result[item.region][item.city] = [];
|
||||||
|
result[item.region][item.city].push(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupInventoryByModel(
|
||||||
|
inventory: RegionalInventoryStats[],
|
||||||
|
): Record<string, Record<string, RegionalInventoryStats[]>> {
|
||||||
|
const raw: Record<string, Record<string, RegionalInventoryStats[]>> = {};
|
||||||
|
for (const item of inventory) {
|
||||||
|
if (!raw[item.type]) raw[item.type] = {};
|
||||||
|
if (!raw[item.type][item.model]) raw[item.type][item.model] = [];
|
||||||
|
raw[item.type][item.model].push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已知车型按报表约定排序,新增的未知车型保留接口返回时的首次出现顺序。
|
||||||
|
const result: Record<string, Record<string, RegionalInventoryStats[]>> = {};
|
||||||
|
for (const type of INVENTORY_TYPE_ORDER) {
|
||||||
|
if (raw[type]) result[type] = raw[type];
|
||||||
|
}
|
||||||
|
for (const type of Object.keys(raw)) {
|
||||||
|
if (!result[type]) result[type] = raw[type];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveInventoryView(
|
||||||
|
inventory: RegionalInventoryStats[],
|
||||||
|
filters: InventoryFilters,
|
||||||
|
modelTypeFilter: string,
|
||||||
|
) {
|
||||||
|
const filtered = filterInventoryStats(inventory, filters);
|
||||||
|
const modelSource = modelTypeFilter
|
||||||
|
? inventory.filter((item) => item.type === modelTypeFilter)
|
||||||
|
: inventory;
|
||||||
|
const types = uniqueNonEmpty(inventory.map((item) => item.type));
|
||||||
|
|
||||||
|
return {
|
||||||
|
filtered,
|
||||||
|
brands: uniqueNonEmpty(inventory.map((item) => item.brand)),
|
||||||
|
regions: Array.from(new Set(inventory.map((item) => item.region))),
|
||||||
|
cities: uniqueNonEmpty(inventory.map((item) => item.city)),
|
||||||
|
types: types.sort(
|
||||||
|
(left, right) => INVENTORY_TYPE_ORDER.indexOf(left) - INVENTORY_TYPE_ORDER.indexOf(right),
|
||||||
|
),
|
||||||
|
modelsForType: uniqueNonEmpty(modelSource.map((item) => item.model)),
|
||||||
|
byRegion: groupInventoryByRegion(filtered),
|
||||||
|
byModel: groupInventoryByModel(filtered),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveDepartmentView(departments: DeptGroup[], selectedManager: string) {
|
||||||
|
const managers = departments
|
||||||
|
.flatMap((department) => department.managers.map((manager) => manager.manager))
|
||||||
|
.filter((value, index, all) => all.indexOf(value) === index)
|
||||||
|
.sort();
|
||||||
|
const groupedManagers = departments.map((department) => ({
|
||||||
|
department: department.department,
|
||||||
|
managers: department.managers.map((manager) => manager.manager),
|
||||||
|
}));
|
||||||
|
const managerStats = departments
|
||||||
|
.flatMap((department) => department.managers)
|
||||||
|
.filter((manager) => selectedManager === 'All' || manager.manager === selectedManager)
|
||||||
|
.sort((left, right) => right.total - left.total);
|
||||||
|
|
||||||
|
return { managers, groupedManagers, managerStats };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterCustomerStats(
|
||||||
|
customers: CustomerStats[],
|
||||||
|
filters: CustomerFilters,
|
||||||
|
): CustomerStats[] {
|
||||||
|
return customers.filter((customer) => (
|
||||||
|
(filters.customer.length === 0 || filters.customer.includes(customer.customer))
|
||||||
|
&& (!filters.brand || customer.brand === filters.brand)
|
||||||
|
&& (!filters.department || customer.department === filters.department)
|
||||||
|
&& (!filters.manager || customer.manager === filters.manager)
|
||||||
|
&& (!filters.region || customer.region === filters.region)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupCustomerManagers(
|
||||||
|
customers: CustomerStats[],
|
||||||
|
departments: DeptGroup[],
|
||||||
|
): Array<{ department: string; managers: string[] }> {
|
||||||
|
const departmentManagers = new Map<string, Set<string>>();
|
||||||
|
for (const department of departments) {
|
||||||
|
if (!departmentManagers.has(department.department)) {
|
||||||
|
departmentManagers.set(department.department, new Set());
|
||||||
|
}
|
||||||
|
for (const manager of department.managers) {
|
||||||
|
departmentManagers.get(department.department)!.add(manager.manager);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const customer of customers) {
|
||||||
|
if (!customer.manager || !customer.department) continue;
|
||||||
|
if (!departmentManagers.has(customer.department)) {
|
||||||
|
departmentManagers.set(customer.department, new Set());
|
||||||
|
}
|
||||||
|
departmentManagers.get(customer.department)!.add(customer.manager);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(departmentManagers.entries())
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftOrder = left[0] === '公务车' ? 100 : getDepartmentOrder(left[0]);
|
||||||
|
const rightOrder = right[0] === '公务车' ? 100 : getDepartmentOrder(right[0]);
|
||||||
|
return leftOrder - rightOrder;
|
||||||
|
})
|
||||||
|
.map(([department, managers]) => ({ department, managers: Array.from(managers) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveCustomerView(
|
||||||
|
customers: CustomerStats[],
|
||||||
|
departments: DeptGroup[],
|
||||||
|
filters: CustomerFilters,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
filtered: filterCustomerStats(customers, filters),
|
||||||
|
brands: uniqueNonEmpty(customers.map((customer) => customer.brand)),
|
||||||
|
departments: uniqueNonEmpty(customers.map((customer) => customer.department))
|
||||||
|
.sort((left, right) => getDepartmentOrder(left) - getDepartmentOrder(right)),
|
||||||
|
regions: Array.from(new Set(customers.map((customer) => customer.region))),
|
||||||
|
cities: uniqueNonEmpty(customers.map((customer) => customer.city)),
|
||||||
|
customerNames: uniqueNonEmpty(customers.map((customer) => customer.customer)),
|
||||||
|
managersByDepartment: groupCustomerManagers(customers, departments),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveModalVehicleView(
|
||||||
|
vehicles: VehicleListItem[],
|
||||||
|
weeklyDetails: WeeklyDetailItem[],
|
||||||
|
filters: ModalVehicleFilters,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
plates: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.plateNumber || vehicle.vin)),
|
||||||
|
models: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.model)),
|
||||||
|
brands: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.brandLabel)),
|
||||||
|
locations: uniqueNonEmpty(vehicles.map((vehicle) => vehicle.location)),
|
||||||
|
filteredVehicles: vehicles.filter((vehicle) => (
|
||||||
|
(!filters.plateNumber || (vehicle.plateNumber || vehicle.vin) === filters.plateNumber)
|
||||||
|
&& (!filters.model || vehicle.model === filters.model)
|
||||||
|
&& (!filters.brand || vehicle.brandLabel === filters.brand)
|
||||||
|
&& (!filters.location || vehicle.location === filters.location)
|
||||||
|
)),
|
||||||
|
filteredWeeklyDetails: weeklyDetails.filter((detail) => (
|
||||||
|
!filters.plateNumber || detail.plate_number === filters.plateNumber
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectFlowDetails(
|
||||||
|
flowStats: FlowStatsResponse | null,
|
||||||
|
selection: FlowSelection | null,
|
||||||
|
) {
|
||||||
|
if (!flowStats || !selection) return [];
|
||||||
|
return flowStats.details.filter((detail) => (
|
||||||
|
detail.date === selection.date && detail.type === selection.type
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCustomerPieData(
|
||||||
|
customers: CustomerStats[],
|
||||||
|
view: 'region' | 'province',
|
||||||
|
provinceData: Array<{ name: string; value: number }>,
|
||||||
|
): Array<{ name: string; value: number }> {
|
||||||
|
if (view === 'province') return provinceData;
|
||||||
|
|
||||||
|
const totals: Record<string, number> = {};
|
||||||
|
for (const customer of customers) {
|
||||||
|
totals[customer.region] = (totals[customer.region] || 0) + customer.total;
|
||||||
|
}
|
||||||
|
return Object.entries(totals)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((left, right) => right.value - left.value);
|
||||||
|
}
|
||||||
@@ -106,6 +106,7 @@ export interface VehicleListItem {
|
|||||||
city: string | null;
|
city: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
ownership: string;
|
ownership: string;
|
||||||
|
rentCompany?: string | null;
|
||||||
contractNo: string | null;
|
contractNo: string | null;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
subjectOrg: string | null;
|
subjectOrg: string | null;
|
||||||
|
|||||||
@@ -1,63 +1,28 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { ArrowLeft, Upload, Zap } from 'lucide-react';
|
||||||
import {
|
|
||||||
Upload, FileSpreadsheet, RotateCcw, CheckCircle2, AlertCircle,
|
|
||||||
Truck, ExternalLink, Layers, Zap, ArrowLeft,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { fetchJson } from '../../auth/api-client';
|
import { fetchJson } from '../../auth/api-client';
|
||||||
import { useAuth } from '../../auth/useAuth';
|
import { useAuth } from '../../auth/useAuth';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
|
||||||
import FeedbackFab from '../../components/FeedbackFab';
|
import FeedbackFab from '../../components/FeedbackFab';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { PageFrame } from '../../components/ui/surface';
|
||||||
|
import { BatchTable } from './import-page/BatchTable';
|
||||||
|
import { RecordFilters } from './import-page/RecordFilters';
|
||||||
|
import { RecordTable } from './import-page/RecordTable';
|
||||||
|
import { SummaryKpis } from './import-page/SummaryKpis';
|
||||||
|
import { UploadDropzone } from './import-page/UploadDropzone';
|
||||||
|
import { UploadFeedback } from './import-page/UploadFeedback';
|
||||||
|
import type {
|
||||||
|
BatchRow,
|
||||||
|
ListItem,
|
||||||
|
OverallRow,
|
||||||
|
UploadResult,
|
||||||
|
VehicleKindFilter,
|
||||||
|
} from './import-page/types';
|
||||||
|
|
||||||
function getJwt(): string | null {
|
function getJwt(): string | null {
|
||||||
return sessionStorage.getItem('bi_jwt');
|
return sessionStorage.getItem('bi_jwt');
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UploadResult {
|
|
||||||
ok: boolean;
|
|
||||||
filename: string;
|
|
||||||
batchId: string;
|
|
||||||
parsed: number;
|
|
||||||
fileDuplicates: number;
|
|
||||||
inserted: number;
|
|
||||||
dbDuplicates: number;
|
|
||||||
breakdown: { internal: number; external: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ListItem {
|
|
||||||
id: number;
|
|
||||||
order_no: string;
|
|
||||||
station_name: string | null;
|
|
||||||
terminal_name: string | null;
|
|
||||||
region: string | null;
|
|
||||||
city: string | null;
|
|
||||||
start_time: string | null;
|
|
||||||
end_time: string | null;
|
|
||||||
duration_min: number | null;
|
|
||||||
kwh: number | null;
|
|
||||||
fee: number | null;
|
|
||||||
e_fee: number | null;
|
|
||||||
service_fee: number | null;
|
|
||||||
plate: string | null;
|
|
||||||
judged_plate: string | null;
|
|
||||||
customer_name: string | null;
|
|
||||||
vehicle_kind: 'internal' | 'external' | 'unknown';
|
|
||||||
batch_id: string;
|
|
||||||
imported_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OverallRow { vehicle_kind: 'internal' | 'external'; records: number; total_kwh: number; total_fee: number; }
|
|
||||||
interface BatchRow { batch_id: string; imported_at: string; records: number; internal_count: number; external_count: number; total_kwh: number; total_fee: number; }
|
|
||||||
|
|
||||||
const KIND_LABEL: Record<string, string> = {
|
|
||||||
internal: '内部',
|
|
||||||
external: '外部',
|
|
||||||
};
|
|
||||||
const KIND_STYLE: Record<string, string> = {
|
|
||||||
internal: 'bg-blue-50 text-blue-600 border-blue-200',
|
|
||||||
external: 'bg-amber-50 text-amber-600 border-amber-200',
|
|
||||||
};
|
|
||||||
|
|
||||||
async function uploadFile(file: File): Promise<UploadResult> {
|
async function uploadFile(file: File): Promise<UploadResult> {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
@@ -82,7 +47,7 @@ export default function EleImportPage() {
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [overall, setOverall] = useState<OverallRow[]>([]);
|
const [overall, setOverall] = useState<OverallRow[]>([]);
|
||||||
const [batches, setBatches] = useState<BatchRow[]>([]);
|
const [batches, setBatches] = useState<BatchRow[]>([]);
|
||||||
const [filter, setFilter] = useState<'' | 'internal' | 'external'>('');
|
const [filter, setFilter] = useState<VehicleKindFilter>('');
|
||||||
const [batchFilter, setBatchFilter] = useState('');
|
const [batchFilter, setBatchFilter] = useState('');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState('');
|
||||||
@@ -109,13 +74,13 @@ export default function EleImportPage() {
|
|||||||
reload().catch(e => console.error(e));
|
reload().catch(e => console.error(e));
|
||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
const handleUpload = async (f: File) => {
|
const handleUpload = async (selectedFile: File) => {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
try {
|
try {
|
||||||
const r = await uploadFile(f);
|
const nextResult = await uploadFile(selectedFile);
|
||||||
setResult(r);
|
setResult(nextResult);
|
||||||
await reload();
|
await reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
@@ -124,269 +89,87 @@ export default function EleImportPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPick = (f: File | null) => {
|
const onPick = (selectedFile: File | null) => {
|
||||||
setFile(f);
|
setFile(selectedFile);
|
||||||
if (f) handleUpload(f);
|
if (selectedFile) handleUpload(selectedFile);
|
||||||
};
|
};
|
||||||
|
|
||||||
const overallMap = new Map(overall.map(o => [o.vehicle_kind, o]));
|
|
||||||
const totalRecords = overall.reduce((s, o) => s + Number(o.records || 0), 0);
|
|
||||||
const totalKwh = overall.reduce((s, o) => s + Number(o.total_kwh || 0), 0);
|
|
||||||
const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 p-4 md:p-8">
|
<PageFrame
|
||||||
<div className="max-w-6xl mx-auto space-y-4">
|
title="充电记录导入"
|
||||||
<header className="flex items-center justify-between">
|
subtitle="每日上传 xlsx,按订单编号去重,并自动匹配内部/外部车辆归属。"
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
icon={Zap}
|
||||||
|
eyebrow="ELECTRIC IMPORT"
|
||||||
|
meta={user?.userName || '导入工作台'}
|
||||||
|
actions={(
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.history.length > 1) window.history.back();
|
if (window.history.length > 1) window.history.back();
|
||||||
else { window.location.hash = '#mileage'; }
|
else { window.location.hash = '#mileage'; }
|
||||||
}}
|
}}
|
||||||
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
|
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-100 bg-white text-slate-500 transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
|
||||||
title="返回"
|
title="返回"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center flex-shrink-0">
|
<button
|
||||||
<Zap size={18} className="text-white" />
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className="inline-flex h-9 items-center gap-1.5 rounded-xl bg-slate-900 px-3 text-xs font-black text-white shadow-sm transition-colors hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
上传文件
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
)}
|
||||||
<h1 className="text-lg font-black text-slate-900 leading-tight">充电记录导入</h1>
|
>
|
||||||
<p className="text-[11px] font-bold text-slate-400">每日上传 xlsx · 订单编号去重 · 系统车辆自动匹配</p>
|
<UploadDropzone
|
||||||
</div>
|
inputRef={inputRef}
|
||||||
</div>
|
file={file}
|
||||||
<span className="text-[10px] font-bold text-slate-400 flex-shrink-0">{user?.userName || ''}</span>
|
uploading={uploading}
|
||||||
</header>
|
dragOver={dragOver}
|
||||||
|
|
||||||
{/* 上传区 */}
|
|
||||||
<section
|
|
||||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
onDragLeave={() => setDragOver(false)}
|
onDragLeave={() => setDragOver(false)}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
const f = e.dataTransfer.files?.[0];
|
const droppedFile = e.dataTransfer.files?.[0];
|
||||||
if (f) onPick(f);
|
if (droppedFile) onPick(droppedFile);
|
||||||
}}
|
}}
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
className={`bg-white rounded-2xl border-2 border-dashed shadow-sm cursor-pointer transition-all ${
|
onInputChange={(e) => onPick(e.target.files?.[0] || null)}
|
||||||
dragOver ? 'border-blue-400 bg-blue-50/40' : uploading ? 'border-slate-200' : 'border-slate-200 hover:border-blue-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".xlsx,.xls"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => onPick(e.target.files?.[0] || null)}
|
|
||||||
/>
|
/>
|
||||||
<div className="px-6 py-10 flex flex-col items-center text-center">
|
|
||||||
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3">
|
|
||||||
{uploading ? <RotateCcw size={22} className="text-blue-500 animate-spin" /> : <Upload size={22} className="text-blue-500" />}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-bold text-slate-700 mb-1">
|
|
||||||
{uploading ? '正在解析...' : file ? file.name : '点击或拖拽 xlsx 文件到此处'}
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-slate-400 max-w-md leading-relaxed">
|
|
||||||
支持「充电成功记录明细」格式;订单编号已存在的会自动跳过
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 上传结果提示 */}
|
<UploadFeedback
|
||||||
<AnimatePresence>
|
result={result}
|
||||||
{result && (
|
error={error}
|
||||||
<motion.div
|
onCloseResult={() => setResult(null)}
|
||||||
initial={{ opacity: 0, y: 8 }}
|
onCloseError={() => setError(null)}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -8 }}
|
|
||||||
className="bg-white rounded-2xl border border-emerald-100 shadow-sm p-4 flex items-start gap-3"
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={18} className="text-emerald-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-[12px] font-bold text-slate-700 mb-1">
|
|
||||||
上传成功:<span className="text-slate-500 font-mono">{result.filename}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-[11px]">
|
|
||||||
<Stat label="解析" value={result.parsed} color="text-slate-700" />
|
|
||||||
<Stat label="新增" value={result.inserted} color="text-blue-600" />
|
|
||||||
<Stat label="重复跳过" value={result.fileDuplicates + result.dbDuplicates} color="text-slate-500" />
|
|
||||||
<Stat label="内部" value={result.breakdown.internal} color="text-blue-600" />
|
|
||||||
<Stat label="外部(含无车牌)" value={result.breakdown.external} color="text-amber-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setResult(null)} className="text-slate-300 hover:text-slate-600 text-[10px] font-bold">关闭</button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
<AnimatePresence>
|
|
||||||
{error && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -8 }}
|
|
||||||
className="bg-red-50 rounded-2xl border border-red-200 shadow-sm p-4 flex items-start gap-3"
|
|
||||||
>
|
|
||||||
<AlertCircle size={18} className="text-red-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="flex-1 text-[12px] font-bold text-red-700">{error}</div>
|
|
||||||
<button onClick={() => setError(null)} className="text-red-300 hover:text-red-600 text-[10px] font-bold">关闭</button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* 聚合卡 */}
|
|
||||||
<section className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
|
||||||
<KpiCard icon={<Layers size={14} />} label="总记录" value={totalRecords.toLocaleString()} />
|
|
||||||
<KpiCard icon={<Truck size={14} />} label="内部记录" value={(overallMap.get('internal')?.records ?? 0).toLocaleString()} accent="blue" />
|
|
||||||
<KpiCard icon={<ExternalLink size={14} />} label="外部记录" value={(overallMap.get('external')?.records ?? 0).toLocaleString()} accent="amber" />
|
|
||||||
<KpiCard icon={<Zap size={14} />} label="累计电量" value={`${totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} />
|
|
||||||
<KpiCard icon={<Zap size={14} />} label="内部电量" value={`${(overallMap.get('internal')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="blue" />
|
|
||||||
<KpiCard icon={<Zap size={14} />} label="外部电量" value={`${(overallMap.get('external')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="amber" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 批次 */}
|
|
||||||
{batches.length > 0 && (
|
|
||||||
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
|
||||||
<div className="px-3 py-2 bg-slate-50 flex items-center justify-between">
|
|
||||||
<span className="text-[11px] font-bold text-slate-500">最近上传批次</span>
|
|
||||||
{batchFilter && (
|
|
||||||
<button onClick={() => setBatchFilter('')} className="text-[10px] font-bold text-blue-500">取消筛选</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-[11px]">
|
|
||||||
<thead className="text-slate-400 font-bold bg-slate-50/40">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-2 text-left">导入时间</th>
|
|
||||||
<th className="px-3 py-2 text-right">总数</th>
|
|
||||||
<th className="px-3 py-2 text-right">内部</th>
|
|
||||||
<th className="px-3 py-2 text-right">外部</th>
|
|
||||||
<th className="px-3 py-2 text-right">电量(度)</th>
|
|
||||||
<th className="px-3 py-2 text-right">费用(元)</th>
|
|
||||||
<th className="px-3 py-2 text-right">批次</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{batches.map(b => (
|
|
||||||
<tr
|
|
||||||
key={b.batch_id}
|
|
||||||
onClick={() => setBatchFilter(batchFilter === b.batch_id ? '' : b.batch_id)}
|
|
||||||
className={`border-t border-slate-100 cursor-pointer transition-colors ${batchFilter === b.batch_id ? 'bg-blue-50/40' : 'hover:bg-slate-50/60'}`}
|
|
||||||
>
|
|
||||||
<td className="px-3 py-2 text-slate-600 whitespace-nowrap">{(b.imported_at || '').replace('T', ' ').slice(0, 19)}</td>
|
|
||||||
<td className="px-3 py-2 text-right font-bold text-slate-700">{Number(b.records).toLocaleString()}</td>
|
|
||||||
<td className="px-3 py-2 text-right text-blue-600 font-bold">{Number(b.internal_count).toLocaleString()}</td>
|
|
||||||
<td className="px-3 py-2 text-right text-amber-600 font-bold">{Number(b.external_count).toLocaleString()}</td>
|
|
||||||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">{Number(b.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })}</td>
|
|
||||||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">¥{Number(b.total_fee ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</td>
|
|
||||||
<td className="px-3 py-2 text-right text-slate-300 font-mono text-[10px]">{b.batch_id.slice(0, 12)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 列表 */}
|
|
||||||
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
|
||||||
<div className="px-3 py-2 bg-slate-50 flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="text-[11px] font-bold text-slate-500">最新记录</span>
|
|
||||||
<span className="text-[10px] font-bold text-slate-400">共 {total.toLocaleString()} 条</span>
|
|
||||||
<div className="flex-1" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchInput}
|
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
|
|
||||||
placeholder="搜索订单/车牌/电站"
|
|
||||||
className="bg-white border border-slate-200 rounded-lg px-2 py-1 text-[11px] outline-none focus:ring-1 focus:ring-blue-500/20 w-44"
|
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-1 bg-white p-0.5 rounded-lg border border-slate-200">
|
|
||||||
{([['', '全部'], ['internal', '内部'], ['external', '外部']] as const).map(([k, label]) => (
|
<SummaryKpis overall={overall} />
|
||||||
<button
|
|
||||||
key={k}
|
<BatchTable
|
||||||
onClick={() => setFilter(k as typeof filter)}
|
batches={batches}
|
||||||
className={`px-2 py-0.5 rounded text-[10px] font-bold transition-colors ${filter === k ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
batchFilter={batchFilter}
|
||||||
>{label}</button>
|
onClearFilter={() => setBatchFilter('')}
|
||||||
))}
|
onSelectBatch={(batchId) => setBatchFilter(batchFilter === batchId ? '' : batchId)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||||
<table className="w-full text-[11px]">
|
<RecordFilters
|
||||||
<thead className="bg-slate-50/40 text-slate-400 font-bold">
|
total={total}
|
||||||
<tr>
|
searchInput={searchInput}
|
||||||
<th className="px-3 py-2 text-left whitespace-nowrap">充电时间</th>
|
filter={filter}
|
||||||
<th className="px-3 py-2 text-left whitespace-nowrap">车牌</th>
|
onSearchInputChange={(e) => setSearchInput(e.target.value)}
|
||||||
<th className="px-3 py-2 text-center whitespace-nowrap">分类</th>
|
onSearchKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
|
||||||
<th className="px-3 py-2 text-left">电站 / 终端</th>
|
onFilterChange={setFilter}
|
||||||
<th className="px-3 py-2 text-right whitespace-nowrap">电量(度)</th>
|
/>
|
||||||
<th className="px-3 py-2 text-right whitespace-nowrap">费用(元)</th>
|
<RecordTable items={items} />
|
||||||
<th className="px-3 py-2 text-right whitespace-nowrap">时长(分)</th>
|
|
||||||
<th className="px-3 py-2 text-left whitespace-nowrap">订单编号</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map(it => (
|
|
||||||
<tr key={it.id} className="border-t border-slate-100 hover:bg-slate-50/60">
|
|
||||||
<td className="px-3 py-2 text-slate-600 whitespace-nowrap font-mono">{(it.start_time || '').replace('T', ' ').slice(0, 16)}</td>
|
|
||||||
<td className="px-3 py-2 font-bold text-slate-700 font-mono whitespace-nowrap">{it.plate || it.judged_plate || <span className="text-slate-300">—</span>}</td>
|
|
||||||
<td className="px-3 py-2 text-center">
|
|
||||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold border ${KIND_STYLE[it.vehicle_kind]}`}>
|
|
||||||
{KIND_LABEL[it.vehicle_kind]}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-slate-600 truncate max-w-xs">{it.station_name || '—'}{it.terminal_name ? ` · ${it.terminal_name}` : ''}</td>
|
|
||||||
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.kwh ?? 0).toFixed(2)}</td>
|
|
||||||
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.fee ?? 0).toFixed(2)}</td>
|
|
||||||
<td className="px-3 py-2 text-right text-slate-500 tabular-nums">{it.duration_min ?? '—'}</td>
|
|
||||||
<td className="px-3 py-2 text-slate-400 font-mono text-[10px]">{it.order_no}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{items.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={8} className="px-3 py-8 text-center text-slate-300 text-[11px] font-bold">
|
|
||||||
<FileSpreadsheet size={18} className="mx-auto mb-2 text-slate-200" />
|
|
||||||
尚无记录,先上传一份 xlsx 试试
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<RotatingFooterHint className="pb-4" />
|
<RotatingFooterHint className="pb-4" />
|
||||||
</div>
|
|
||||||
<FeedbackFab module="ele" />
|
<FeedbackFab module="ele" />
|
||||||
</div>
|
</PageFrame>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
|
||||||
return (
|
|
||||||
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
|
|
||||||
<div className="text-[9px] text-slate-400 uppercase font-bold">{label}</div>
|
|
||||||
<div className={`text-sm font-black tabular-nums ${color}`}>{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KpiCard({ icon, label, value, accent = 'slate' }: { icon: React.ReactNode; label: string; value: string; accent?: 'slate' | 'blue' | 'amber' }) {
|
|
||||||
const accentMap: Record<string, string> = {
|
|
||||||
slate: 'text-slate-700',
|
|
||||||
blue: 'text-blue-600',
|
|
||||||
amber: 'text-amber-600',
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-xl border border-slate-100 shadow-sm p-3">
|
|
||||||
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-400 uppercase">
|
|
||||||
<span className={accentMap[accent]}>{icon}</span>
|
|
||||||
<span>{label}</span>
|
|
||||||
</div>
|
|
||||||
<div className={`text-base font-black tabular-nums leading-tight mt-0.5 ${accentMap[accent]}`}>{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { formatCount, formatDateTime, formatDecimal } from './model';
|
||||||
|
import type { BatchRow } from './types';
|
||||||
|
|
||||||
|
interface BatchTableProps {
|
||||||
|
batches: BatchRow[];
|
||||||
|
batchFilter: string;
|
||||||
|
onClearFilter: () => void;
|
||||||
|
onSelectBatch: (batchId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BatchTable({ batches, batchFilter, onClearFilter, onSelectBatch }: BatchTableProps) {
|
||||||
|
if (batches.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||||
|
<div className="px-3 py-2 bg-slate-50 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-bold text-slate-500">最近上传批次</span>
|
||||||
|
{batchFilter && (
|
||||||
|
<button onClick={onClearFilter} className="text-[10px] font-bold text-blue-500">取消筛选</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="text-slate-400 font-bold bg-slate-50/40">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left">导入时间</th>
|
||||||
|
<th className="px-3 py-2 text-right">总数</th>
|
||||||
|
<th className="px-3 py-2 text-right">内部</th>
|
||||||
|
<th className="px-3 py-2 text-right">外部</th>
|
||||||
|
<th className="px-3 py-2 text-right">电量(度)</th>
|
||||||
|
<th className="px-3 py-2 text-right">费用(元)</th>
|
||||||
|
<th className="px-3 py-2 text-right">批次</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{batches.map(batch => (
|
||||||
|
<tr
|
||||||
|
key={batch.batch_id}
|
||||||
|
onClick={() => onSelectBatch(batch.batch_id)}
|
||||||
|
className={`border-t border-slate-100 cursor-pointer transition-colors ${batchFilter === batch.batch_id ? 'bg-blue-50/40' : 'hover:bg-slate-50/60'}`}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 text-slate-600 whitespace-nowrap">{formatDateTime(batch.imported_at, 19)}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-bold text-slate-700">{formatCount(batch.records)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-blue-600 font-bold">{formatCount(batch.internal_count)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-amber-600 font-bold">{formatCount(batch.external_count)}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">{formatDecimal(batch.total_kwh, 1)}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">¥{formatDecimal(batch.total_fee, 2)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-slate-300 font-mono text-[10px]">{batch.batch_id.slice(0, 12)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
|
||||||
|
import type { VehicleKindFilter } from './types';
|
||||||
|
|
||||||
|
interface RecordFiltersProps {
|
||||||
|
total: number;
|
||||||
|
searchInput: string;
|
||||||
|
filter: VehicleKindFilter;
|
||||||
|
onSearchInputChange: ChangeEventHandler<HTMLInputElement>;
|
||||||
|
onSearchKeyDown: KeyboardEventHandler<HTMLInputElement>;
|
||||||
|
onFilterChange: (filter: VehicleKindFilter) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_OPTIONS: ReadonlyArray<readonly [VehicleKindFilter, string]> = [
|
||||||
|
['', '全部'],
|
||||||
|
['internal', '内部'],
|
||||||
|
['external', '外部'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function RecordFilters({
|
||||||
|
total,
|
||||||
|
searchInput,
|
||||||
|
filter,
|
||||||
|
onSearchInputChange,
|
||||||
|
onSearchKeyDown,
|
||||||
|
onFilterChange,
|
||||||
|
}: RecordFiltersProps) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-2 bg-slate-50 flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-[11px] font-bold text-slate-500">最新记录</span>
|
||||||
|
<span className="text-[10px] font-bold text-slate-400">共 {total.toLocaleString()} 条</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={onSearchInputChange}
|
||||||
|
onKeyDown={onSearchKeyDown}
|
||||||
|
placeholder="搜索订单/车牌/电站"
|
||||||
|
className="bg-white border border-slate-200 rounded-lg px-2 py-1 text-[11px] outline-none focus:ring-1 focus:ring-blue-500/20 w-44"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1 bg-white p-0.5 rounded-lg border border-slate-200">
|
||||||
|
{FILTER_OPTIONS.map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => onFilterChange(key)}
|
||||||
|
className={`px-2 py-0.5 rounded text-[10px] font-bold transition-colors ${filter === key ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||||
|
>{label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { FileSpreadsheet } from 'lucide-react';
|
||||||
|
import { formatDateTime, formatFixed, KIND_LABEL, KIND_STYLE } from './model';
|
||||||
|
import type { ListItem } from './types';
|
||||||
|
|
||||||
|
interface RecordTableProps {
|
||||||
|
items: ListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecordTable({ items }: RecordTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="bg-slate-50/40 text-slate-400 font-bold">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left whitespace-nowrap">充电时间</th>
|
||||||
|
<th className="px-3 py-2 text-left whitespace-nowrap">车牌</th>
|
||||||
|
<th className="px-3 py-2 text-center whitespace-nowrap">分类</th>
|
||||||
|
<th className="px-3 py-2 text-left">电站 / 终端</th>
|
||||||
|
<th className="px-3 py-2 text-right whitespace-nowrap">电量(度)</th>
|
||||||
|
<th className="px-3 py-2 text-right whitespace-nowrap">费用(元)</th>
|
||||||
|
<th className="px-3 py-2 text-right whitespace-nowrap">时长(分)</th>
|
||||||
|
<th className="px-3 py-2 text-left whitespace-nowrap">订单编号</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map(item => (
|
||||||
|
<tr key={item.id} className="border-t border-slate-100 hover:bg-slate-50/60">
|
||||||
|
<td className="px-3 py-2 text-slate-600 whitespace-nowrap font-mono">{formatDateTime(item.start_time, 16)}</td>
|
||||||
|
<td className="px-3 py-2 font-bold text-slate-700 font-mono whitespace-nowrap">{item.plate || item.judged_plate || <span className="text-slate-300">—</span>}</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold border ${KIND_STYLE[item.vehicle_kind]}`}>
|
||||||
|
{KIND_LABEL[item.vehicle_kind]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-slate-600 truncate max-w-xs">{item.station_name || '—'}{item.terminal_name ? ` · ${item.terminal_name}` : ''}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{formatFixed(item.kwh, 2)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{formatFixed(item.fee, 2)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-slate-500 tabular-nums">{item.duration_min ?? '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-slate-400 font-mono text-[10px]">{item.order_no}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-3 py-8 text-center text-slate-300 text-[11px] font-bold">
|
||||||
|
<FileSpreadsheet size={18} className="mx-auto mb-2 text-slate-200" />
|
||||||
|
尚无记录,先上传一份 xlsx 试试
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { ExternalLink, Layers, Truck, Zap } from 'lucide-react';
|
||||||
|
import { summarizeOverall } from './model';
|
||||||
|
import type { OverallRow } from './types';
|
||||||
|
|
||||||
|
interface SummaryKpisProps {
|
||||||
|
overall: OverallRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SummaryKpis({ overall }: SummaryKpisProps) {
|
||||||
|
const summary = summarizeOverall(overall);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
<KpiCard icon={<Layers size={14} />} label="总记录" value={summary.totalRecords.toLocaleString()} />
|
||||||
|
<KpiCard icon={<Truck size={14} />} label="内部记录" value={summary.internalRecords.toLocaleString()} accent="blue" />
|
||||||
|
<KpiCard icon={<ExternalLink size={14} />} label="外部记录" value={summary.externalRecords.toLocaleString()} accent="amber" />
|
||||||
|
<KpiCard icon={<Zap size={14} />} label="累计电量" value={`${summary.totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} />
|
||||||
|
<KpiCard icon={<Zap size={14} />} label="内部电量" value={`${summary.internalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="blue" />
|
||||||
|
<KpiCard icon={<Zap size={14} />} label="外部电量" value={`${summary.externalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="amber" />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ icon, label, value, accent = 'slate' }: { icon: ReactNode; label: string; value: string; accent?: 'slate' | 'blue' | 'amber' }) {
|
||||||
|
const accentMap: Record<string, string> = {
|
||||||
|
slate: 'text-slate-700',
|
||||||
|
blue: 'text-blue-600',
|
||||||
|
amber: 'text-amber-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-100 shadow-sm p-3">
|
||||||
|
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-400 uppercase">
|
||||||
|
<span className={accentMap[accent]}>{icon}</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`text-base font-black tabular-nums leading-tight mt-0.5 ${accentMap[accent]}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type {
|
||||||
|
ChangeEventHandler,
|
||||||
|
DragEventHandler,
|
||||||
|
MouseEventHandler,
|
||||||
|
RefObject,
|
||||||
|
} from 'react';
|
||||||
|
import { RotateCcw, Upload } from 'lucide-react';
|
||||||
|
|
||||||
|
interface UploadDropzoneProps {
|
||||||
|
inputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
file: File | null;
|
||||||
|
uploading: boolean;
|
||||||
|
dragOver: boolean;
|
||||||
|
onClick: MouseEventHandler<HTMLElement>;
|
||||||
|
onDragOver: DragEventHandler<HTMLElement>;
|
||||||
|
onDragLeave: DragEventHandler<HTMLElement>;
|
||||||
|
onDrop: DragEventHandler<HTMLElement>;
|
||||||
|
onInputChange: ChangeEventHandler<HTMLInputElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadDropzone({
|
||||||
|
inputRef,
|
||||||
|
file,
|
||||||
|
uploading,
|
||||||
|
dragOver,
|
||||||
|
onClick,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
onInputChange,
|
||||||
|
}: UploadDropzoneProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`bg-white rounded-2xl border-2 border-dashed shadow-sm cursor-pointer transition-all ${
|
||||||
|
dragOver ? 'border-blue-400 bg-blue-50/40' : uploading ? 'border-slate-200' : 'border-slate-200 hover:border-blue-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
className="hidden"
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
<div className="px-6 py-10 flex flex-col items-center text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3">
|
||||||
|
{uploading ? <RotateCcw size={22} className="text-blue-500 animate-spin" /> : <Upload size={22} className="text-blue-500" />}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-slate-700 mb-1">
|
||||||
|
{uploading ? '正在解析...' : file ? file.name : '点击或拖拽 xlsx 文件到此处'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 max-w-md leading-relaxed">
|
||||||
|
支持「充电成功记录明细」格式;订单编号已存在的会自动跳过
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import { AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||||
|
import type { UploadResult } from './types';
|
||||||
|
|
||||||
|
interface UploadFeedbackProps {
|
||||||
|
result: UploadResult | null;
|
||||||
|
error: string | null;
|
||||||
|
onCloseResult: () => void;
|
||||||
|
onCloseError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadFeedback({ result, error, onCloseResult, onCloseError }: UploadFeedbackProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatePresence>
|
||||||
|
{result && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
className="bg-white rounded-2xl border border-emerald-100 shadow-sm p-4 flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={18} className="text-emerald-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[12px] font-bold text-slate-700 mb-1">
|
||||||
|
上传成功:<span className="text-slate-500 font-mono">{result.filename}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-[11px]">
|
||||||
|
<Stat label="解析" value={result.parsed} color="text-slate-700" />
|
||||||
|
<Stat label="新增" value={result.inserted} color="text-blue-600" />
|
||||||
|
<Stat label="重复跳过" value={result.fileDuplicates + result.dbDuplicates} color="text-slate-500" />
|
||||||
|
<Stat label="内部" value={result.breakdown.internal} color="text-blue-600" />
|
||||||
|
<Stat label="外部(含无车牌)" value={result.breakdown.external} color="text-amber-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onCloseResult} className="text-slate-300 hover:text-slate-600 text-[10px] font-bold">关闭</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<AnimatePresence>
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
className="bg-red-50 rounded-2xl border border-red-200 shadow-sm p-4 flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<AlertCircle size={18} className="text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 text-[12px] font-bold text-red-700">{error}</div>
|
||||||
|
<button onClick={onCloseError} className="text-red-300 hover:text-red-600 text-[10px] font-bold">关闭</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
|
||||||
|
<div className="text-[9px] text-slate-400 uppercase font-bold">{label}</div>
|
||||||
|
<div className={`text-sm font-black tabular-nums ${color}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
formatCount,
|
||||||
|
formatDateTime,
|
||||||
|
formatDecimal,
|
||||||
|
formatFixed,
|
||||||
|
summarizeOverall,
|
||||||
|
} from './model';
|
||||||
|
|
||||||
|
test('summarizeOverall preserves aggregate totals and per-kind values', () => {
|
||||||
|
assert.deepEqual(summarizeOverall([
|
||||||
|
{ vehicle_kind: 'internal', records: 3, total_kwh: 12.34, total_fee: 20.5 },
|
||||||
|
{ vehicle_kind: 'external', records: 2, total_kwh: 5.66, total_fee: 9.5 },
|
||||||
|
]), {
|
||||||
|
totalRecords: 5,
|
||||||
|
totalKwh: 18,
|
||||||
|
totalFee: 30,
|
||||||
|
internalRecords: 3,
|
||||||
|
externalRecords: 2,
|
||||||
|
internalKwh: 12.34,
|
||||||
|
externalKwh: 5.66,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeOverall preserves empty fallbacks', () => {
|
||||||
|
assert.deepEqual(summarizeOverall([]), {
|
||||||
|
totalRecords: 0,
|
||||||
|
totalKwh: 0,
|
||||||
|
totalFee: 0,
|
||||||
|
internalRecords: 0,
|
||||||
|
externalRecords: 0,
|
||||||
|
internalKwh: 0,
|
||||||
|
externalKwh: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatters preserve the import page display rules', () => {
|
||||||
|
assert.equal(formatDateTime('2026-08-13T09:08:07.000Z', 19), '2026-08-13 09:08:07');
|
||||||
|
assert.equal(formatDateTime(null, 16), '');
|
||||||
|
assert.equal(formatCount(1234), (1234).toLocaleString());
|
||||||
|
assert.equal(formatDecimal(1234.56, 1), (1234.56).toLocaleString('zh-CN', { maximumFractionDigits: 1 }));
|
||||||
|
assert.equal(formatFixed(null, 2), '0.00');
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { OverallRow } from './types';
|
||||||
|
|
||||||
|
export const KIND_LABEL: Record<string, string> = {
|
||||||
|
internal: '内部',
|
||||||
|
external: '外部',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KIND_STYLE: Record<string, string> = {
|
||||||
|
internal: 'bg-blue-50 text-blue-600 border-blue-200',
|
||||||
|
external: 'bg-amber-50 text-amber-600 border-amber-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function summarizeOverall(rows: OverallRow[]) {
|
||||||
|
const byKind = new Map(rows.map(row => [row.vehicle_kind, row]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalRecords: rows.reduce((sum, row) => sum + Number(row.records || 0), 0),
|
||||||
|
totalKwh: rows.reduce((sum, row) => sum + Number(row.total_kwh || 0), 0),
|
||||||
|
totalFee: rows.reduce((sum, row) => sum + Number(row.total_fee || 0), 0),
|
||||||
|
internalRecords: byKind.get('internal')?.records ?? 0,
|
||||||
|
externalRecords: byKind.get('external')?.records ?? 0,
|
||||||
|
internalKwh: byKind.get('internal')?.total_kwh ?? 0,
|
||||||
|
externalKwh: byKind.get('external')?.total_kwh ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value: string | null | undefined, length: number) {
|
||||||
|
return (value || '').replace('T', ' ').slice(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCount(value: number) {
|
||||||
|
return Number(value).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDecimal(value: number | null | undefined, maximumFractionDigits: number) {
|
||||||
|
return Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFixed(value: number | null | undefined, fractionDigits: number) {
|
||||||
|
return Number(value ?? 0).toFixed(fractionDigits);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
export interface UploadResult {
|
||||||
|
ok: boolean;
|
||||||
|
filename: string;
|
||||||
|
batchId: string;
|
||||||
|
parsed: number;
|
||||||
|
fileDuplicates: number;
|
||||||
|
inserted: number;
|
||||||
|
dbDuplicates: number;
|
||||||
|
breakdown: { internal: number; external: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VehicleKind = 'internal' | 'external' | 'unknown';
|
||||||
|
export type VehicleKindFilter = '' | 'internal' | 'external';
|
||||||
|
|
||||||
|
export interface ListItem {
|
||||||
|
id: number;
|
||||||
|
order_no: string;
|
||||||
|
station_name: string | null;
|
||||||
|
terminal_name: string | null;
|
||||||
|
region: string | null;
|
||||||
|
city: string | null;
|
||||||
|
start_time: string | null;
|
||||||
|
end_time: string | null;
|
||||||
|
duration_min: number | null;
|
||||||
|
kwh: number | null;
|
||||||
|
fee: number | null;
|
||||||
|
e_fee: number | null;
|
||||||
|
service_fee: number | null;
|
||||||
|
plate: string | null;
|
||||||
|
judged_plate: string | null;
|
||||||
|
customer_name: string | null;
|
||||||
|
vehicle_kind: VehicleKind;
|
||||||
|
batch_id: string;
|
||||||
|
imported_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverallRow {
|
||||||
|
vehicle_kind: 'internal' | 'external';
|
||||||
|
records: number;
|
||||||
|
total_kwh: number;
|
||||||
|
total_fee: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchRow {
|
||||||
|
batch_id: string;
|
||||||
|
imported_at: string;
|
||||||
|
records: number;
|
||||||
|
internal_count: number;
|
||||||
|
external_count: number;
|
||||||
|
total_kwh: number;
|
||||||
|
total_fee: number;
|
||||||
|
}
|
||||||
@@ -1,79 +1,52 @@
|
|||||||
import { motion } from 'motion/react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Construction, Hammer } from 'lucide-react';
|
import { BadgeCheck, CarFront, FileText, ReceiptText, WalletCards } from 'lucide-react';
|
||||||
|
import { fetchEtcOverview, type EtcOverviewResponse } from './api';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||||
|
|
||||||
const ETC_HINTS = [
|
function formatYuan(value: number) {
|
||||||
'ETC 通行费数据正在与发卡方系统打通…',
|
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`;
|
||||||
'工人 GG 正在搭脚手架,敬请期待 ~',
|
}
|
||||||
'马上能看到每月通行费明细啦',
|
|
||||||
'想看哪个维度的 ETC?反馈一下嘛',
|
|
||||||
'上线时机:等数据接通的那一天',
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ETCView() {
|
export default function ETCView() {
|
||||||
|
const [data, setData] = useState<EtcOverviewResponse | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchEtcOverview()
|
||||||
|
.then(result => { if (!cancelled) setData(result); })
|
||||||
|
.catch(reason => { if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason)); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) return <ErrorState message="ETC 数据源暂时不可用,请稍后刷新。" />;
|
||||||
|
if (!data) return <LoadingState label="正在核对 ETC 台账" />;
|
||||||
|
|
||||||
|
if (!data.hasData) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-3">
|
||||||
<motion.div
|
<EmptyState title="暂无 ETC 通行台账" description="当前账单和通行明细均未入库,接入后会自动展示统计数据。" />
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<RotatingFooterHint hints={['ETC 台账状态已按数据库实时核对', '待通行明细入库后自动生成统计']} />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
|
||||||
>
|
|
||||||
<div className="relative w-20 h-20 mb-4">
|
|
||||||
<motion.div
|
|
||||||
animate={{ rotate: [0, -8, 8, -4, 4, 0] }}
|
|
||||||
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
|
|
||||||
className="absolute inset-0 rounded-3xl bg-gradient-to-br from-amber-50 to-orange-50 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Construction size={36} className="text-amber-500" strokeWidth={2.2} />
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
animate={{ rotate: [0, 18, -10, 0], y: [0, -2, 1, 0] }}
|
|
||||||
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
|
|
||||||
className="absolute -top-1 -right-1 w-9 h-9 rounded-2xl bg-white border border-amber-100 shadow-sm flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Hammer size={16} className="text-amber-500" strokeWidth={2.2} />
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<div className="text-base font-black text-slate-800 mb-1.5">ETC 模块建设中</div>
|
return (
|
||||||
<div className="text-[12px] text-slate-500 font-bold leading-relaxed max-w-[280px]">
|
<div className="flex flex-col gap-3">
|
||||||
通行费明细、按车按月统计、运营成本拆分
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<br />
|
<MetricTile icon={ReceiptText} label="通行明细" value={data.tollRecordCount.toLocaleString('zh-CN')} unit="笔" helper={data.latestTollTime ? `最新记录 ${data.latestTollTime}` : '暂无通行时间'} />
|
||||||
这些数据都在路上啦
|
<MetricTile icon={CarFront} label="涉及车辆" value={data.vehicleCount.toLocaleString('zh-CN')} unit="台" helper="按通行明细车牌去重" tone="emerald" />
|
||||||
|
<MetricTile icon={WalletCards} label="通行费金额" value={formatYuan(data.totalAmount)} helper="通行费与服务费合计" tone="amber" />
|
||||||
|
<MetricTile icon={FileText} label="ETC 账单" value={data.billCount.toLocaleString('zh-CN')} unit="张" helper={`应收 ${formatYuan(data.receivableAmount)} · 已收 ${formatYuan(data.paidAmount)}`} tone="slate" />
|
||||||
</div>
|
</div>
|
||||||
|
<SurfaceCard className="p-4">
|
||||||
{/* 简单的里程碑进度感 */}
|
<div className="flex items-start gap-2 text-xs font-bold text-slate-500">
|
||||||
<div className="mt-6 w-full max-w-xs space-y-2">
|
<BadgeCheck size={17} className="mt-0.5 shrink-0 text-emerald-500" />
|
||||||
{[
|
<div>当前统计直接读取 ETC 通行明细与账单台账;金额、应收和已收均不做推算。</div>
|
||||||
{ label: '需求评审', done: true },
|
|
||||||
{ label: '数据对接', done: true },
|
|
||||||
{ label: '页面开发', done: false, current: true },
|
|
||||||
{ label: '正式上线', done: false },
|
|
||||||
].map((m, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, x: -8 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: 0.1 + i * 0.08, duration: 0.3 }}
|
|
||||||
className="flex items-center gap-2.5 text-[11px]"
|
|
||||||
>
|
|
||||||
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${
|
|
||||||
m.done ? 'bg-emerald-400'
|
|
||||||
: m.current ? 'bg-amber-400 ring-4 ring-amber-100 animate-pulse'
|
|
||||||
: 'bg-slate-200'
|
|
||||||
}`} />
|
|
||||||
<span className={`font-bold ${m.done ? 'text-slate-500' : m.current ? 'text-amber-600' : 'text-slate-300'}`}>
|
|
||||||
{m.label}
|
|
||||||
</span>
|
|
||||||
{m.done && <span className="text-[10px] text-emerald-500 font-bold ml-auto">已完成</span>}
|
|
||||||
{m.current && <span className="text-[10px] text-amber-500 font-bold ml-auto">进行中</span>}
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</SurfaceCard>
|
||||||
|
<RotatingFooterHint />
|
||||||
<RotatingFooterHint hints={ETC_HINTS} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,51 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronRight, Plug } from 'lucide-react';
|
import { BatteryCharging, CalendarDays, ChevronRight, TrendingUp, Wallet } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import TrendBadge from './TrendBadge';
|
import TrendBadge from './TrendBadge';
|
||||||
import { fetchElectricMonthly } from './api';
|
import { fetchElectricMonthly } from './api';
|
||||||
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { EmptyState, ErrorState, LoadingState, MetricTile } from '../../components/ui/surface';
|
||||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
import DailyRangeControls from './daily-range/DailyRangeControls';
|
||||||
{ id: 'thisWeek', label: '本周' },
|
import {
|
||||||
{ id: 'thisMonth', label: '本月' },
|
getQuickRange,
|
||||||
{ id: 'last15', label: '近 15 天' },
|
getRangeModeLabel,
|
||||||
];
|
normalizeRange,
|
||||||
|
type RangeMode,
|
||||||
|
} from './daily-range/model';
|
||||||
|
import { summarizeElectricMonths } from './electric-daily/model';
|
||||||
|
|
||||||
export default function ElectricDaily() {
|
export default function ElectricDaily() {
|
||||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||||
const [pick, setPick] = useState<DateQuickPick>('last15');
|
const [pick, setPick] = useState<RangeMode>('last15');
|
||||||
|
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||||
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
||||||
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
fetchElectricMonthly(customer, pick)
|
setMonths(null);
|
||||||
|
const query = pick === 'custom'
|
||||||
|
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||||
|
: { range: pick };
|
||||||
|
fetchElectricMonthly(customer, query)
|
||||||
.then(m => {
|
.then(m => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setMonths(m);
|
setMonths(m);
|
||||||
// 默认展开最新一个月
|
// 默认展开最新一个月
|
||||||
if (m.length > 0) setOpenMonths(prev => prev.size > 0 ? prev : new Set([m[0].month]));
|
if (m.length > 0) setOpenMonths(new Set([m[0].month]));
|
||||||
})
|
})
|
||||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
.catch(e => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setMonths(null);
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [customer, pick]);
|
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
|
||||||
|
|
||||||
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -39,66 +53,72 @@ export default function ElectricDaily() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
|
const {
|
||||||
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
totalKwh,
|
||||||
|
totalFee,
|
||||||
|
activeDays,
|
||||||
|
abnormalDays,
|
||||||
|
avgKwh,
|
||||||
|
avgPrice,
|
||||||
|
hasFeeDetail,
|
||||||
|
} = useMemo(() => summarizeElectricMonths(months), [months]);
|
||||||
|
const scopeLabel = getRangeModeLabel(pick);
|
||||||
|
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||||
|
|
||||||
|
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||||
|
setPick(nextPick);
|
||||||
|
setDateRange(getQuickRange(nextPick));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
||||||
|
if (!value) return;
|
||||||
|
setPick('custom');
|
||||||
|
setDateRange(prev => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<DailyRangeControls
|
||||||
|
pick={pick}
|
||||||
|
dateRange={dateRange}
|
||||||
|
customer={customer}
|
||||||
|
onQuickPick={applyQuickPick}
|
||||||
|
onCustomPick={() => setPick('custom')}
|
||||||
|
onDateRangeChange={updateDateRange}
|
||||||
|
onCustomerChange={setCustomer}
|
||||||
|
/>
|
||||||
|
<ErrorState message={error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* 日期速选 */}
|
<DailyRangeControls
|
||||||
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
|
pick={pick}
|
||||||
{QUICK_PICK_OPTIONS.map(opt => (
|
dateRange={dateRange}
|
||||||
<button
|
customer={customer}
|
||||||
key={opt.id}
|
onQuickPick={applyQuickPick}
|
||||||
onClick={() => setPick(opt.id)}
|
onCustomPick={() => setPick('custom')}
|
||||||
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
onDateRangeChange={updateDateRange}
|
||||||
pick === opt.id
|
onCustomerChange={setCustomer}
|
||||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
/>
|
||||||
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 客户类型 */}
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
<MetricTile icon={BatteryCharging} label={`${scopeLabel}充电量`} value={totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={rangeText} />
|
||||||
{(['lingniu', 'external'] as const).map(c => (
|
<MetricTile
|
||||||
<button
|
icon={Wallet}
|
||||||
key={c}
|
label="充电费用"
|
||||||
onClick={() => setCustomer(c)}
|
value={`¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||||||
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
|
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前范围未记录费用'}
|
||||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
tone={hasFeeDetail ? 'emerald' : 'slate'}
|
||||||
}`}
|
/>
|
||||||
>
|
<MetricTile icon={CalendarDays} label="有效天数" value={`${activeDays}`} unit="天" helper={`日均 ${avgKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} tone="amber" />
|
||||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
<MetricTile icon={TrendingUp} label="波动提醒" value={abnormalDays} unit="天" helper="环比超过 30% 标记" tone={abnormalDays > 0 ? 'rose' : 'slate'} />
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 外部车辆 数据未就绪 */}
|
|
||||||
{showExternalEmpty && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
|
||||||
>
|
|
||||||
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
|
|
||||||
<Plug size={22} className="text-blue-500" />
|
|
||||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
|
|
||||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
|
|
||||||
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
|
|
||||||
新系统的外部车辆充电数据还在准备中
|
|
||||||
<br />
|
|
||||||
上线后此处将展示完整明细
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 月份分组表 */}
|
{/* 月份分组表 */}
|
||||||
{!showExternalEmpty && (
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
<div className="grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||||||
<span>月份 / 日期</span>
|
<span>月份 / 日期</span>
|
||||||
@@ -106,11 +126,11 @@ export default function ElectricDaily() {
|
|||||||
<span className="text-right">环比</span>
|
<span className="text-right">环比</span>
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">加载失败:{error}</div>
|
<div className="p-3"><ErrorState message={error} /></div>
|
||||||
) : months === null ? (
|
) : months === null ? (
|
||||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">加载中…</div>
|
<div className="p-3"><LoadingState label="正在加载充电明细" /></div>
|
||||||
) : months.length === 0 ? (
|
) : months.length === 0 ? (
|
||||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">暂无数据</div>
|
<div className="p-3"><EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" /></div>
|
||||||
) : months.map(m => {
|
) : months.map(m => {
|
||||||
const open = openMonths.has(m.month);
|
const open = openMonths.has(m.month);
|
||||||
return (
|
return (
|
||||||
@@ -164,7 +184,6 @@ export default function ElectricDaily() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<RotatingFooterHint />
|
<RotatingFooterHint />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { LayoutDashboard, CalendarDays } from 'lucide-react';
|
||||||
|
import { AnimatePresence } from 'motion/react';
|
||||||
|
import ElectricView, { type ElectricSubTab } from './ElectricView';
|
||||||
|
import SubTabs from './SubTabs';
|
||||||
|
import { useHashSubTab } from './useHashSubTab';
|
||||||
|
import { FadeIn, PageFrame } from '../../components/ui/surface';
|
||||||
|
|
||||||
|
const SUB_TABS = [
|
||||||
|
{ id: 'daily', label: '按日', icon: CalendarDays },
|
||||||
|
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
||||||
|
] as const satisfies readonly { id: ElectricSubTab; label: string; icon: typeof CalendarDays }[];
|
||||||
|
|
||||||
|
const SUB_IDS: readonly ElectricSubTab[] = ['daily', 'overview'];
|
||||||
|
|
||||||
|
export default function ElectricModule() {
|
||||||
|
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
|
||||||
|
return (
|
||||||
|
<PageFrame
|
||||||
|
title="电能经营看板"
|
||||||
|
subtitle="充电量、费用、日趋势与车辆归属"
|
||||||
|
icon={CalendarDays}
|
||||||
|
eyebrow="ELECTRIC BI"
|
||||||
|
meta="按日 · 总览"
|
||||||
|
>
|
||||||
|
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<FadeIn key={sub}>
|
||||||
|
<ElectricView sub={sub} />
|
||||||
|
</FadeIn>
|
||||||
|
</AnimatePresence>
|
||||||
|
</PageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Wallet, CalendarClock } from 'lucide-react';
|
import { BatteryCharging, Gauge, Wallet, CalendarClock } from 'lucide-react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||||||
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||||
|
|
||||||
function fmtYuan(yuan: number) {
|
function fmtYuan(yuan: number) {
|
||||||
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||||
@@ -24,48 +25,66 @@ export default function ElectricOverview() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
return <ErrorState message={error} />;
|
||||||
}
|
}
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm">加载中…</div>;
|
return <LoadingState label="正在加载电能总览" />;
|
||||||
}
|
}
|
||||||
const k = data.kpi;
|
const k = data.kpi;
|
||||||
const trendData = data.trend;
|
const trendData = data.trend;
|
||||||
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
|
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
|
||||||
const trendMonthLabel = trendData[0]?.date.slice(0, 7);
|
const trendMonthLabel = trendData[0]?.date.slice(0, 7);
|
||||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
const now = new Date();
|
||||||
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
|
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||||
? `${trendMonthLabel} 每日充电`
|
const dataIsCurrentMonth = trendMonthLabel === currentMonth;
|
||||||
: '本月每日充电';
|
const chartTitle = trendMonthLabel ? `${trendMonthLabel} 每日充电` : '每日充电';
|
||||||
|
const activeDays = trendData.filter(item => item.kwh > 0).length;
|
||||||
|
const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0;
|
||||||
|
const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 0;
|
||||||
|
const peakDay = trendData.reduce<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null);
|
||||||
|
const latestDay = trendData.at(-1) ?? null;
|
||||||
|
const displayedMonthKwh = dataIsCurrentMonth ? k.monthKwh : trendData.reduce((sum, item) => sum + item.kwh, 0);
|
||||||
|
const displayedMonthFee = dataIsCurrentMonth ? k.monthFee : trendData.reduce((sum, item) => sum + item.fee, 0);
|
||||||
|
const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0;
|
||||||
|
const monthPrice = displayedMonthKwh > 0 ? displayedMonthFee / displayedMonthKwh : 0;
|
||||||
|
const dataAsOf = data.latestChargeTime ?? '暂无充电记录';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
|
<div className="rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[11px] font-bold text-slate-400">
|
||||||
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
充电账本最新记录:{dataAsOf} · {dataIsCurrentMonth ? '本月数据' : `${trendMonthLabel ?? '暂无'} 数据`}
|
||||||
</div>
|
</div>
|
||||||
{/* 横向 mini KPI 头 */}
|
{/* 横向 mini KPI 头 */}
|
||||||
<div className="grid grid-cols-2 gap-2 md:gap-3">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
<MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} />
|
||||||
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
|
<MetricTile icon={CalendarClock} label={dataIsCurrentMonth ? '本月充电费' : '数据最新月费用'} value={fmtYuan(displayedMonthFee)} helper={fmtKwh(displayedMonthKwh)} tone="emerald" />
|
||||||
<Wallet size={11} className="text-blue-600" />累计
|
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`${dataIsCurrentMonth ? '本月' : '最新月'} ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
|
||||||
</div>
|
<MetricTile icon={BatteryCharging} label={dataIsCurrentMonth ? '今日充电' : '最新日充电'} value={(dataIsCurrentMonth ? k.todayKwh : latestDay?.kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={dataIsCurrentMonth ? `${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%` : `${latestDay?.date ?? '暂无记录'} · ${fmtYuan(latestDay?.fee ?? 0)}`} tone={dataIsCurrentMonth && Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
|
||||||
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.totalFee)}</div>
|
|
||||||
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.totalKwh)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
|
||||||
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
|
|
||||||
<CalendarClock size={11} className="text-blue-600" />本月
|
|
||||||
</div>
|
|
||||||
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.monthFee)}</div>
|
|
||||||
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.monthKwh)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
|
<SurfaceCard className="p-3">
|
||||||
|
<div className="text-[11px] font-black text-slate-400">有效充电日</div>
|
||||||
|
<div className="mt-1 text-xl font-black text-slate-900">{activeDays}<span className="ml-1 text-[11px] text-slate-400">天</span></div>
|
||||||
|
<div className="mt-1 text-[11px] font-bold text-slate-500">日均 {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
<SurfaceCard className="p-3">
|
||||||
|
<div className="text-[11px] font-black text-slate-400">峰值日</div>
|
||||||
|
<div className="mt-1 text-xl font-black text-blue-600">{peakDay ? peakDay.date.slice(5) : '—'}</div>
|
||||||
|
<div className="mt-1 text-[11px] font-bold text-slate-500">{peakDay ? `${peakDay.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度 · ${fmtYuan(peakDay.fee)}` : '暂无数据'}</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
<SurfaceCard className="p-3">
|
||||||
|
<div className="text-[11px] font-black text-slate-400">月度占比</div>
|
||||||
|
<div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (displayedMonthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
|
||||||
|
<div className="mt-1 text-[11px] font-bold text-slate-500">{dataIsCurrentMonth ? '本月' : '最新月'}费用 / 累计费用</div>
|
||||||
|
</SurfaceCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 本月每日充电柱图 */}
|
{/* 本月每日充电柱图 */}
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
<SurfaceCard className="p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
|
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
|
||||||
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
<span className="text-[11px] text-slate-400 font-bold">时间单位:日 · 单位 元 · 均线为日均费用</span>
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={160}>
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||||
@@ -85,6 +104,14 @@ export default function ElectricOverview() {
|
|||||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||||
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
||||||
/>
|
/>
|
||||||
|
{avgDailyFee > 0 && (
|
||||||
|
<ReferenceLine
|
||||||
|
y={avgDailyFee}
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
|
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
|
||||||
{trendData.map((_, i) => (
|
{trendData.map((_, i) => (
|
||||||
<Cell key={i} fill="url(#electricBarGrad)" />
|
<Cell key={i} fill="url(#electricBarGrad)" />
|
||||||
@@ -98,7 +125,7 @@ export default function ElectricOverview() {
|
|||||||
</defs>
|
</defs>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</SurfaceCard>
|
||||||
<RotatingFooterHint />
|
<RotatingFooterHint />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Fuel, BatteryCharging, Receipt, LayoutDashboard, CalendarDays } from 'lucide-react';
|
|
||||||
import { motion } from 'motion/react';
|
|
||||||
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
|
||||||
import ElectricView, { type ElectricSubTab } from './ElectricView';
|
|
||||||
import ETCView from './ETCView';
|
|
||||||
|
|
||||||
type TopTab = 'hydrogen' | 'electric' | 'etc';
|
|
||||||
type SubTabId = HydrogenSubTab | ElectricSubTab; // 'daily' | 'overview'
|
|
||||||
|
|
||||||
const TABS: { key: TopTab; label: string; icon: typeof Fuel }[] = [
|
|
||||||
{ key: 'hydrogen', label: '氢能', icon: Fuel },
|
|
||||||
{ key: 'electric', label: '电能', icon: BatteryCharging },
|
|
||||||
{ key: 'etc', label: 'ETC', icon: Receipt },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SUB_TABS: { id: SubTabId; label: string; icon: typeof LayoutDashboard }[] = [
|
|
||||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
|
||||||
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function EnergyModule() {
|
|
||||||
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
|
|
||||||
const [hydroSub, setHydroSub] = useState<HydrogenSubTab>('daily');
|
|
||||||
const [electricSub, setElectricSub] = useState<ElectricSubTab>('daily');
|
|
||||||
const showSubTabs = activeTab === 'hydrogen' || activeTab === 'electric';
|
|
||||||
const currentSub: SubTabId = activeTab === 'electric' ? electricSub : hydroSub;
|
|
||||||
const setSub = (id: SubTabId) => activeTab === 'electric' ? setElectricSub(id) : setHydroSub(id);
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
|
||||||
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
|
|
||||||
|
|
||||||
{/* 统一 sticky 头部:top tab + (氢能时) 子 tab;同一张卡片,无间隙 */}
|
|
||||||
{/* pb-4 留一点底部缓冲,避免下方快捷选按钮在滚动时贴着 sticky 半截露脸 */}
|
|
||||||
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
|
||||||
{/* 顶部 tab:氢能 / 电能 / ETC */}
|
|
||||||
<div className={`px-4 py-2 flex items-center gap-6 ${showSubTabs ? 'border-b border-slate-50' : ''}`}>
|
|
||||||
{TABS.map(tab => {
|
|
||||||
const Icon = tab.icon;
|
|
||||||
const active = activeTab === tab.key;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.key}
|
|
||||||
onClick={() => setActiveTab(tab.key)}
|
|
||||||
className={`flex items-center gap-2 py-1 transition-colors relative ${active ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'}`}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
<span className="text-[11px] font-bold">{tab.label}</span>
|
|
||||||
{active && (
|
|
||||||
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{/* 子 tab:氢能 / 电能 都显示 每日 / 总览 */}
|
|
||||||
{showSubTabs && (
|
|
||||||
<div className="p-1 flex gap-1">
|
|
||||||
{SUB_TABS.map(({ id, label, icon: Icon }) => {
|
|
||||||
const active = currentSub === id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
onClick={() => setSub(id)}
|
|
||||||
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
|
|
||||||
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
<span>{label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'hydrogen' && <HydrogenView sub={hydroSub} />}
|
|
||||||
{activeTab === 'electric' && <ElectricView sub={electricSub} />}
|
|
||||||
{activeTab === 'etc' && <ETCView />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Receipt } from 'lucide-react';
|
||||||
|
import ETCView from './ETCView';
|
||||||
|
import { PageFrame } from '../../components/ui/surface';
|
||||||
|
|
||||||
|
export default function EtcModule() {
|
||||||
|
return (
|
||||||
|
<PageFrame
|
||||||
|
title="ETC 通行费看板"
|
||||||
|
subtitle="通行明细、账单与回款状态"
|
||||||
|
icon={Receipt}
|
||||||
|
eyebrow="ETC BI"
|
||||||
|
meta="通行明细 · 账单"
|
||||||
|
>
|
||||||
|
<ETCView />
|
||||||
|
</PageFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import './styles/energy-bi-board.css';
|
||||||
|
|
||||||
|
export type HydrogenBoardScope = 'global' | 'station';
|
||||||
|
export type HydrogenBoardView = 'daily' | 'overview';
|
||||||
|
|
||||||
|
export function HydrogenBoardNotice() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HydrogenBoardChrome({
|
||||||
|
scope,
|
||||||
|
view,
|
||||||
|
rangeText,
|
||||||
|
onScopeChange,
|
||||||
|
onViewChange,
|
||||||
|
}: {
|
||||||
|
scope: HydrogenBoardScope;
|
||||||
|
view: HydrogenBoardView;
|
||||||
|
rangeText?: string;
|
||||||
|
onScopeChange: (scope: HydrogenBoardScope) => void;
|
||||||
|
onViewChange: (view: HydrogenBoardView) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className="ehb-chrome">
|
||||||
|
<div className="ehb-chrome__lead">
|
||||||
|
<div className="ehb-crumb">羚牛氢能 BI / 氢能</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 2, flexWrap: 'wrap' }}>
|
||||||
|
<h1 style={{ margin: 0 }}>氢能经营看板</h1>
|
||||||
|
{scope === 'global' && rangeText ? (
|
||||||
|
<span
|
||||||
|
className="ehb-time-range-pill"
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#0284c7',
|
||||||
|
background: '#eff6ff',
|
||||||
|
border: '1px solid #bae6fd',
|
||||||
|
padding: '3px 10px',
|
||||||
|
borderRadius: 16,
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
boxShadow: '0 1px 2px rgba(2, 132, 199, 0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📅 统计时间范围:{rangeText}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ehb-chrome__tools">
|
||||||
|
<div className="ehb-seg ehb-seg--wrap" role="tablist" aria-label="范围">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className={scope === 'global' ? 'is-active' : ''}
|
||||||
|
aria-selected={scope === 'global'}
|
||||||
|
onClick={() => onScopeChange('global')}
|
||||||
|
>
|
||||||
|
全局
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className={scope === 'station' ? 'is-active' : ''}
|
||||||
|
aria-selected={scope === 'station'}
|
||||||
|
onClick={() => onScopeChange('station')}
|
||||||
|
>
|
||||||
|
单站
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{scope === 'global' ? (
|
||||||
|
<div className="ehb-seg ehb-seg--wrap" role="tablist" aria-label="视图">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className={view === 'daily' ? 'is-active' : ''}
|
||||||
|
aria-selected={view === 'daily'}
|
||||||
|
onClick={() => onViewChange('daily')}
|
||||||
|
>
|
||||||
|
按日
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className={view === 'overview' ? 'is-active' : ''}
|
||||||
|
aria-selected={view === 'overview'}
|
||||||
|
onClick={() => onViewChange('overview')}
|
||||||
|
>
|
||||||
|
总览
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,228 +1,304 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronRight, Plug } from 'lucide-react';
|
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { fetchHydrogenDaily, fetchHydrogenDailyDetail, type HydrogenVerifyScope } from './api';
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
import type { CustomerType, DateQuickPick, HydrogenDailyDetailResponse, HydrogenDailyRow } from './types';
|
||||||
import TrendBadge from './TrendBadge';
|
import {
|
||||||
import { fetchHydrogenDaily } from './api';
|
buildHydrogenDailyTrend,
|
||||||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
filterHydrogenRowsByStation,
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
getHydrogenDailyStations,
|
||||||
|
getQuickRange,
|
||||||
|
getRangeModeLabel,
|
||||||
|
mergeHydrogenDailyRows,
|
||||||
|
normalizeRange,
|
||||||
|
summarizeHydrogenRows,
|
||||||
|
type HydrogenDailyBoardScope,
|
||||||
|
type HydrogenDailyVehicleScope,
|
||||||
|
type RangeMode,
|
||||||
|
} from './hydrogen-daily/model';
|
||||||
|
import DailyRangeControls from './daily-range/DailyRangeControls';
|
||||||
|
import { DailyDetailTable } from './hydrogen-daily/components/DailyDetailTable';
|
||||||
|
import { DailyKpiGrid } from './hydrogen-daily/components/DailyKpiGrid';
|
||||||
|
import { DailyTrendChart } from './hydrogen-daily/components/DailyTrendChart';
|
||||||
|
import { StationDailyOverview } from './hydrogen-daily/components/StationDailyOverview';
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from '../../components/ui/surface';
|
||||||
|
import './styles/energy-bi-board.css';
|
||||||
|
|
||||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
export interface HydrogenDailyProps {
|
||||||
{ id: 'thisWeek', label: '本周' },
|
scope?: HydrogenDailyBoardScope;
|
||||||
{ id: 'thisMonth', label: '本月' },
|
onRangeTextChange?: (rangeText: string) => void;
|
||||||
{ id: 'last15', label: '近 15 天' },
|
}
|
||||||
];
|
|
||||||
|
|
||||||
export default function HydrogenDaily() {
|
interface DailyDatasets {
|
||||||
const [pick, setPick] = useState<DateQuickPick>('last15');
|
lingniu: HydrogenDailyRow[] | null;
|
||||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
external: HydrogenDailyRow[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyDetailState {
|
||||||
|
loading: boolean;
|
||||||
|
data: HydrogenDailyDetailResponse | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUpdatedAt(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HydrogenDaily({ scope = 'global', onRangeTextChange }: HydrogenDailyProps) {
|
||||||
|
const [vehicleScope, setVehicleScope] = useState<HydrogenDailyVehicleScope>('all');
|
||||||
|
// Kept explicit even though this view has no verification toggle yet: drill
|
||||||
|
// requests always declare their data scope instead of silently defaulting.
|
||||||
|
const verifyScope: HydrogenVerifyScope = 'all';
|
||||||
|
const [pick, setPick] = useState<RangeMode>('last15');
|
||||||
|
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||||
|
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
const [highlightedDate, setHighlightedDate] = useState<string | null>(null);
|
||||||
|
const [datasets, setDatasets] = useState<DailyDatasets>({ lingniu: null, external: null });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
|
||||||
|
const [details, setDetails] = useState<Record<string, DailyDetailState>>({});
|
||||||
|
|
||||||
|
const effectiveRange = useMemo(
|
||||||
|
() => normalizeRange(dateRange.start, dateRange.end),
|
||||||
|
[dateRange.start, dateRange.end],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onRangeTextChange?.(`${effectiveRange.start} 至 ${effectiveRange.end}`);
|
||||||
|
}, [effectiveRange.start, effectiveRange.end, onRangeTextChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
fetchHydrogenDaily(pick, customer)
|
const query = pick === 'custom'
|
||||||
.then(r => { if (!cancelled) setRows(r); })
|
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
: { range: pick };
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
fetchHydrogenDaily(query, 'lingniu'),
|
||||||
|
fetchHydrogenDaily(query, 'external'),
|
||||||
|
])
|
||||||
|
.then(([lingniu, external]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setDatasets({ lingniu, external });
|
||||||
|
setUpdatedAt(formatUpdatedAt(new Date()));
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [pick, customer]);
|
}, [pick, effectiveRange.start, effectiveRange.end, refreshKey]);
|
||||||
|
|
||||||
// 柱图:按日期升序,用于"从左到右时间流"
|
const allRows = useMemo(
|
||||||
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
|
() => mergeHydrogenDailyRows(datasets.lingniu, datasets.external),
|
||||||
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
|
[datasets.external, datasets.lingniu],
|
||||||
|
);
|
||||||
|
const vehicleRows = useMemo(() => {
|
||||||
|
if (vehicleScope === 'lingniu') return datasets.lingniu;
|
||||||
|
if (vehicleScope === 'external') return datasets.external;
|
||||||
|
return allRows;
|
||||||
|
}, [allRows, datasets.external, datasets.lingniu, vehicleScope]);
|
||||||
|
const stationOptions = useMemo(() => getHydrogenDailyStations(vehicleRows), [vehicleRows]);
|
||||||
|
|
||||||
const toggle = (date: string) => setExpanded(prev => {
|
useEffect(() => {
|
||||||
const next = new Set(prev);
|
if (scope !== 'station') return;
|
||||||
next.has(date) ? next.delete(date) : next.add(date);
|
if (selectedStationId !== null && stationOptions.some((station) => station.id === selectedStationId)) return;
|
||||||
|
setSelectedStationId(stationOptions[0]?.id ?? null);
|
||||||
|
}, [scope, selectedStationId, stationOptions]);
|
||||||
|
|
||||||
|
const scopedRows = useMemo(() => {
|
||||||
|
if (scope !== 'station' || selectedStationId === null) return vehicleRows;
|
||||||
|
return filterHydrogenRowsByStation(vehicleRows, selectedStationId);
|
||||||
|
}, [scope, selectedStationId, vehicleRows]);
|
||||||
|
|
||||||
|
const summary = useMemo(
|
||||||
|
() => summarizeHydrogenRows(scopedRows),
|
||||||
|
[scopedRows],
|
||||||
|
);
|
||||||
|
const trend = useMemo(
|
||||||
|
() => buildHydrogenDailyTrend(datasets.lingniu, datasets.external, vehicleScope, scope === 'station' ? selectedStationId : null),
|
||||||
|
[datasets.external, datasets.lingniu, scope, selectedStationId, vehicleScope],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedStation = stationOptions.find((station) => station.id === selectedStationId);
|
||||||
|
|
||||||
|
const dayCount = useMemo(() => {
|
||||||
|
const start = new Date(effectiveRange.start).getTime();
|
||||||
|
const end = new Date(effectiveRange.end).getTime();
|
||||||
|
return Math.max(1, Math.round(Math.abs(end - start) / (24 * 3600 * 1000)) + 1);
|
||||||
|
}, [effectiveRange.end, effectiveRange.start]);
|
||||||
|
|
||||||
|
const lingniuSum = useMemo(() => {
|
||||||
|
return (datasets.lingniu ?? []).reduce((s, r) => s + r.totalKg, 0);
|
||||||
|
}, [datasets.lingniu]);
|
||||||
|
|
||||||
|
const externalSum = useMemo(() => {
|
||||||
|
return (datasets.external ?? []).reduce((s, r) => s + r.totalKg, 0);
|
||||||
|
}, [datasets.external]);
|
||||||
|
|
||||||
|
const loadDetail = async (date: string, force = false) => {
|
||||||
|
if (!force && details[date]?.data) return;
|
||||||
|
setDetails((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[date]: { loading: true, data: previous[date]?.data ?? null, error: null },
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const data = await fetchHydrogenDailyDetail(
|
||||||
|
date,
|
||||||
|
vehicleScope,
|
||||||
|
scope === 'station' ? selectedStationId : null,
|
||||||
|
verifyScope,
|
||||||
|
);
|
||||||
|
setDetails((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[date]: { loading: false, data, error: null },
|
||||||
|
}));
|
||||||
|
} catch (reason) {
|
||||||
|
setDetails((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[date]: {
|
||||||
|
loading: false,
|
||||||
|
data: previous[date]?.data ?? null,
|
||||||
|
error: reason instanceof Error ? reason.message : String(reason),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRow = (date: string) => {
|
||||||
|
setExpanded((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (next.has(date)) {
|
||||||
|
next.delete(date);
|
||||||
|
} else {
|
||||||
|
next.add(date);
|
||||||
|
void loadDetail(date);
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectDate = (date: string) => {
|
||||||
|
setExpanded((previous) => new Set(previous).add(date));
|
||||||
|
void loadDetail(date);
|
||||||
|
setHighlightedDate(date);
|
||||||
|
const element = document.getElementById(`hydrogen-daily-row-${date}`);
|
||||||
|
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !scopedRows) {
|
||||||
|
return <LoadingState label="正在加载氢气按日看板数据..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !scopedRows) {
|
||||||
|
return <ErrorState message={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* 日期速选 */}
|
<DailyRangeControls
|
||||||
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
|
pick={pick}
|
||||||
{QUICK_PICK_OPTIONS.map(opt => (
|
dateRange={dateRange}
|
||||||
<button
|
customer={vehicleScope === 'all' ? 'all' : vehicleScope === 'lingniu' ? 'lingniu' : 'external'}
|
||||||
key={opt.id}
|
stations={scope === 'station' ? stationOptions : undefined}
|
||||||
onClick={() => setPick(opt.id)}
|
selectedStationId={scope === 'station' ? selectedStationId : undefined}
|
||||||
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
vehicleScope={vehicleScope}
|
||||||
pick === opt.id
|
updatedAt={updatedAt}
|
||||||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
loading={loading}
|
||||||
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
|
onQuickPick={(p: DateQuickPick) => {
|
||||||
}`}
|
setPick(p);
|
||||||
>
|
setDateRange(getQuickRange(p));
|
||||||
{opt.label}
|
}}
|
||||||
</button>
|
onCustomPick={() => setPick('custom')}
|
||||||
))}
|
onDateRangeChange={(field, value) => {
|
||||||
</div>
|
setPick('custom');
|
||||||
|
setDateRange((prev) => ({ ...prev, [field]: value }));
|
||||||
{/* 客户类型 segmented */}
|
}}
|
||||||
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
onCustomerChange={(cust: CustomerType) => {
|
||||||
{(['lingniu', 'external'] as const).map(c => (
|
setVehicleScope(cust === 'all' ? 'all' : cust === 'lingniu' ? 'lingniu' : 'external');
|
||||||
<button
|
}}
|
||||||
key={c}
|
onStationChange={scope === 'station' ? setSelectedStationId : undefined}
|
||||||
onClick={() => setCustomer(c)}
|
onVehicleScopeChange={setVehicleScope}
|
||||||
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
|
onRefresh={() => setRefreshKey((k) => k + 1)}
|
||||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 外部车辆:新系统数据还没准备好 */}
|
|
||||||
{customer === 'external' && rows !== null && totalKg === 0 && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
|
||||||
>
|
|
||||||
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
|
|
||||||
<Plug size={22} className="text-blue-500" />
|
|
||||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
|
|
||||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
|
|
||||||
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
|
|
||||||
新系统的外部车辆加氢数据还在准备中
|
|
||||||
<br />
|
|
||||||
上线后此处将展示完整明细
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
|
||||||
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
|
|
||||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
|
||||||
</div>
|
|
||||||
<ResponsiveContainer width="100%" height={160}>
|
|
||||||
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
tickFormatter={(v: string) => v.slice(5)}
|
|
||||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
interval="preserveStartEnd"
|
|
||||||
minTickGap={8}
|
|
||||||
/>
|
/>
|
||||||
<YAxis hide />
|
|
||||||
<Tooltip
|
|
||||||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
|
|
||||||
labelFormatter={(d) => `日期 ${d}`}
|
|
||||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
|
||||||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
|
|
||||||
{trendData.map((_, i) => (
|
|
||||||
<Cell key={i} fill="url(#hydrogenBarGrad)" />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="hydrogenBarGrad" x1="0" x2="0" y1="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#22d3ee" />
|
|
||||||
<stop offset="100%" stopColor="#3b82f6" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
|
||||||
{!(customer === 'external' && rows !== null && totalKg === 0) && (
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
|
||||||
{/* 表头 */}
|
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
|
||||||
<span>日期 / 加氢站</span>
|
|
||||||
<span className="hidden md:block text-right">单价 (元/Kg)</span>
|
|
||||||
<span className="text-right">加氢量 (Kg)</span>
|
|
||||||
<span className="text-right">环比</span>
|
|
||||||
</div>
|
|
||||||
{/* 合计行 */}
|
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
|
|
||||||
<span>合计</span>
|
|
||||||
<span className="hidden md:block" />
|
|
||||||
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
|
|
||||||
<span />
|
|
||||||
</div>
|
|
||||||
{/* 主行 + 子行 */}
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">加载失败:{error}</div>
|
<div className="flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-4 py-2.5 text-xs text-amber-800">
|
||||||
) : rows === null ? (
|
<div className="flex items-center gap-2">
|
||||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">加载中…</div>
|
<AlertTriangle size={14} className="text-amber-600" />
|
||||||
) : rows.length === 0 ? (
|
<span>最新数据刷新失败,正展示上一版缓存内容:{error}</span>
|
||||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">暂无数据</div>
|
</div>
|
||||||
) : rows.map(r => {
|
|
||||||
const open = expanded.has(r.date);
|
|
||||||
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
|
|
||||||
const abnormalBg = isAbnormal
|
|
||||||
? r.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
|
|
||||||
: '';
|
|
||||||
return (
|
|
||||||
<div key={r.date} className={`border-t border-slate-100 ${abnormalBg}`}>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => toggle(r.date)}
|
type="button"
|
||||||
className="w-full grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2.5 text-left hover:bg-slate-50/60 transition-colors"
|
onClick={() => setRefreshKey((k) => k + 1)}
|
||||||
|
className="inline-flex items-center gap-1 font-bold text-amber-900 hover:underline"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
|
<RefreshCw size={12} /> 重试
|
||||||
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
|
||||||
{r.date}
|
|
||||||
</span>
|
|
||||||
<span className="hidden md:block text-right text-[12px] text-slate-300">—</span>
|
|
||||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
|
||||||
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
|
||||||
</span>
|
|
||||||
<span className="text-right"><TrendBadge value={r.chainPct} /></span>
|
|
||||||
</button>
|
</button>
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{open && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
className="overflow-hidden bg-slate-50/50"
|
|
||||||
>
|
|
||||||
{r.stations.map(s => (
|
|
||||||
<div
|
|
||||||
key={s.name}
|
|
||||||
className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 pl-6 md:pl-9 border-t border-slate-100 first:border-t-0 items-start"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
|
|
||||||
{s.name}
|
|
||||||
</div>
|
|
||||||
{s.pricePerKg > 0 && (
|
|
||||||
<div className="md:hidden mt-1">
|
|
||||||
<span className="inline-flex items-center text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded font-bold whitespace-nowrap">
|
|
||||||
单价 {s.pricePerKg} 元/Kg
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{scope === 'station' && selectedStation ? (
|
||||||
|
<StationDailyOverview
|
||||||
|
station={selectedStation}
|
||||||
|
totalKg={summary.totalKg}
|
||||||
|
totalFee={summary.totalFee}
|
||||||
|
averagePrice={summary.avgPrice}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DailyKpiGrid
|
||||||
|
rangeLabel={getRangeModeLabel(pick)}
|
||||||
|
rangeText={`${effectiveRange.start} 至 ${effectiveRange.end}`}
|
||||||
|
totalKg={summary.totalKg}
|
||||||
|
activeDays={summary.activeDays}
|
||||||
|
dayCount={dayCount}
|
||||||
|
averageKg={summary.avgKg}
|
||||||
|
stationCount={summary.stationCount}
|
||||||
|
vehicleScope={vehicleScope}
|
||||||
|
lingniuKg={lingniuSum}
|
||||||
|
externalKg={externalSum}
|
||||||
|
selectedStationName={scope === 'station' ? selectedStation?.name : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DailyTrendChart
|
||||||
|
rows={trend}
|
||||||
|
averageKg={summary.avgKg}
|
||||||
|
peakDay={summary.peakDay}
|
||||||
|
lowDay={summary.lowDay}
|
||||||
|
zeroDays={summary.zeroDays}
|
||||||
|
selectedDate={highlightedDate}
|
||||||
|
onSelectDate={handleSelectDate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{scopedRows && scopedRows.length > 0 ? (
|
||||||
|
<DailyDetailTable
|
||||||
|
rows={scopedRows}
|
||||||
|
totalKg={summary.totalKg}
|
||||||
|
totalFee={summary.totalFee}
|
||||||
|
expanded={expanded}
|
||||||
|
highlightedDate={highlightedDate}
|
||||||
|
details={details}
|
||||||
|
onToggle={toggleRow}
|
||||||
|
onRetryDetail={(date) => void loadDetail(date, true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="当前筛选条件下没有加氢记录"
|
||||||
|
description="可尝试切换统计区间、车辆范围或站点。"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden md:block text-right text-[12px] text-slate-500 font-bold tabular-nums">{s.pricePerKg > 0 ? s.pricePerKg : '—'}</span>
|
|
||||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
|
||||||
{s.kg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
|
||||||
</span>
|
|
||||||
<span className="text-right"><TrendBadge value={s.chainPct} /></span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<RotatingFooterHint />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { EnergyBiBoardApp } from '../../vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiBoardApp';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The accepted hydrogen fee BI surface. It is shared with the independent
|
||||||
|
* acceptance route so the menu entry and /energy/hydrogen-board cannot drift.
|
||||||
|
*/
|
||||||
|
export default function HydrogenModule() {
|
||||||
|
return <EnergyBiBoardApp />;
|
||||||
|
}
|
||||||
@@ -1,641 +1,282 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
fetchHydrogenOverview,
|
||||||
} from 'recharts';
|
fetchHydrogenOverviewDetail,
|
||||||
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw } from 'lucide-react';
|
type HydrogenOverviewResponse,
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
type HydrogenVehicleScope,
|
||||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
type HydrogenVerifyScope,
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
} from './api';
|
||||||
|
import type { HydrogenBoardScope } from './HydrogenBoardChrome';
|
||||||
|
import { DistributionCharts } from './hydrogen-overview/components/DistributionCharts';
|
||||||
|
import { HydrogenOverviewSkeleton } from './hydrogen-overview/components/HydrogenOverviewSkeleton';
|
||||||
|
import { InsightCards } from './hydrogen-overview/components/InsightCards';
|
||||||
|
import { KpiSection } from './hydrogen-overview/components/KpiSection';
|
||||||
|
import { MonthlyCharts } from './hydrogen-overview/components/MonthlyCharts';
|
||||||
|
import { OverviewDrillTreeDialog } from './hydrogen-overview/components/OverviewDrillTreeDialog';
|
||||||
|
import { OverviewHeader } from './hydrogen-overview/components/OverviewHeader';
|
||||||
|
import { RefreshOverlay } from './hydrogen-overview/components/RefreshOverlay';
|
||||||
|
import { CustomerSummaryTable, StationSummaryTable } from './hydrogen-overview/components/SummaryTables';
|
||||||
|
import {
|
||||||
|
deriveOverviewMetrics,
|
||||||
|
formatYuan,
|
||||||
|
type OverviewDrillRequest,
|
||||||
|
type OverviewScope,
|
||||||
|
} from './hydrogen-overview/model';
|
||||||
|
import type {
|
||||||
|
HydrogenOverviewDetailGroupBy,
|
||||||
|
HydrogenOverviewDetailResponse,
|
||||||
|
} from './types';
|
||||||
|
import './styles/energy-bi-board.css';
|
||||||
|
|
||||||
const REGION_COLORS = [
|
export interface HydrogenOverviewProps {
|
||||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
scope?: OverviewScope;
|
||||||
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
selectedStationId?: number | null;
|
||||||
'#94a3b8',
|
onScopeChange?: (scope: HydrogenBoardScope) => void;
|
||||||
];
|
onSubChange?: (sub: 'daily' | 'overview' | 'settlement') => void;
|
||||||
|
onSelectStation?: (stationId: number | null) => void;
|
||||||
interface YAxisTickProps {
|
onOpenDaily?: () => void;
|
||||||
x?: number;
|
onRangeTextChange?: (rangeText: string) => void;
|
||||||
y?: number;
|
|
||||||
index?: number;
|
|
||||||
payload?: { value: string };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
const DEFAULT_AVAILABLE_YEARS = [2026, 2025, 2024, 2023, 2022, 2021, 2020];
|
||||||
return (
|
|
||||||
<g transform={`translate(${x},${y})`}>
|
interface DrillSelection {
|
||||||
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
|
stationId?: number | null;
|
||||||
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
customerId?: number | null;
|
||||||
{index + 1}
|
customerName?: string | null;
|
||||||
</text>
|
plateNo?: string | null;
|
||||||
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
region?: string | null;
|
||||||
{payload?.value}
|
month?: string | null;
|
||||||
</text>
|
date?: string | null;
|
||||||
</g>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 数字格式化 ----------
|
export default function HydrogenOverview({
|
||||||
function fmtKg(kg: number): { value: string; unit: string } {
|
scope = 'global',
|
||||||
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
|
selectedStationId = null,
|
||||||
return { value: kg.toFixed(2), unit: 'Kg' };
|
onScopeChange,
|
||||||
}
|
onSubChange,
|
||||||
function fmtYuan(yuan: number): { value: string; unit: string } {
|
onSelectStation,
|
||||||
const abs = Math.abs(yuan);
|
onOpenDaily,
|
||||||
if (abs >= 100_000_000) return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
|
onRangeTextChange,
|
||||||
if (abs >= 10_000) {
|
}: HydrogenOverviewProps) {
|
||||||
const w = yuan / 10_000;
|
const currentYear = new Date().getFullYear();
|
||||||
return { value: w.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), unit: '万元' };
|
const [activeYear, setActiveYear] = useState<number>(() => {
|
||||||
}
|
return DEFAULT_AVAILABLE_YEARS.includes(currentYear) ? currentYear : 2026;
|
||||||
return { value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), unit: '元' };
|
});
|
||||||
}
|
const [vehicleScope, setVehicleScope] = useState<HydrogenVehicleScope>('all');
|
||||||
|
const [verifyScope, setVerifyScope] = useState<HydrogenVerifyScope>('all');
|
||||||
// ---------- KPI 卡 ----------
|
const [internalStationId, setInternalStationId] = useState<number | null>(selectedStationId);
|
||||||
interface KpiCardProps {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
hero: { value: string; unit: string };
|
|
||||||
rows: { label: string; value: string; valueClass?: string }[];
|
|
||||||
accentClass: string;
|
|
||||||
iconBg: string;
|
|
||||||
}
|
|
||||||
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<span className="text-[11px] font-bold text-slate-500">{label}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-baseline gap-1">
|
|
||||||
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
|
|
||||||
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5 pt-1 border-t border-slate-50">
|
|
||||||
{rows.map((r, i) => (
|
|
||||||
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
|
|
||||||
<span className="text-slate-400">{r.label}</span>
|
|
||||||
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
export default function HydrogenOverview() {
|
|
||||||
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [loading, setLoading] = useState(true);
|
||||||
const [year, setYear] = useState<number | null>(null);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [lastRefreshAt, setLastRefreshAt] = useState<number>(0);
|
const [lastRefreshAt, setLastRefreshAt] = useState<number>(0);
|
||||||
const refreshSeq = useRef(0);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async (selectedYear: number | null, force: boolean) => {
|
const [drillTree, setDrillTree] = useState<{
|
||||||
const seq = ++refreshSeq.current;
|
title: string;
|
||||||
setRefreshing(true);
|
selection: DrillSelection;
|
||||||
try {
|
} | null>(null);
|
||||||
const d = await fetchHydrogenOverview(selectedYear ?? undefined, force);
|
|
||||||
if (seq !== refreshSeq.current) return; // outdated
|
const effectiveStationId = scope === 'station' ? (internalStationId ?? selectedStationId) : null;
|
||||||
setData(d);
|
|
||||||
|
const load = useCallback(async (force = false) => {
|
||||||
|
if (force) setRefreshing(true);
|
||||||
|
else setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchHydrogenOverview({
|
||||||
|
year: activeYear,
|
||||||
|
vehicleScope,
|
||||||
|
verifyScope,
|
||||||
|
stationId: effectiveStationId,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
setData(result);
|
||||||
setLastRefreshAt(Date.now());
|
setLastRefreshAt(Date.now());
|
||||||
} catch (e) {
|
const rangeEnd = result.latestLedgerTime?.slice(0, 10) ?? (activeYear === currentYear ? '至今' : `${activeYear}-12-31`);
|
||||||
if (seq !== refreshSeq.current) return;
|
onRangeTextChange?.(`${activeYear}-01-01 至 ${rangeEnd}`);
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : String(reason));
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === refreshSeq.current) setRefreshing(false);
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [activeYear, currentYear, effectiveStationId, onRangeTextChange, vehicleScope, verifyScope]);
|
||||||
|
|
||||||
// 初始加载 + 年份切换:用 force=false 命中热缓存
|
|
||||||
useEffect(() => { void load(year, false); }, [year, load]);
|
|
||||||
|
|
||||||
// 客户端兜底自动刷新:每 60s 静默拉一次(命中后端热缓存,几乎零成本)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setInterval(() => { void load(year, false); }, 60_000);
|
void load(false);
|
||||||
return () => clearInterval(t);
|
}, [load]);
|
||||||
}, [year, load]);
|
|
||||||
|
|
||||||
if (error && !data) {
|
const handleDrillRequest = (request: OverviewDrillRequest) => {
|
||||||
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
const selection: DrillSelection = {};
|
||||||
|
if (request.entityId) {
|
||||||
|
if (request.kind === 'station') selection.stationId = request.entityId;
|
||||||
|
if (request.kind === 'customer') selection.customerId = request.entityId;
|
||||||
}
|
}
|
||||||
if (!data) {
|
if (request.kind === 'customer' && !request.entityId) selection.customerName = request.key;
|
||||||
|
if (request.kind === 'month') selection.month = request.key;
|
||||||
|
if (request.kind === 'region') selection.region = request.key;
|
||||||
|
|
||||||
|
setDrillTree({
|
||||||
|
title: request.label || '数据下钻穿透',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrillLoad = useCallback(async (
|
||||||
|
groupBy: HydrogenOverviewDetailGroupBy | null,
|
||||||
|
selection: DrillSelection,
|
||||||
|
scopeParam: HydrogenVehicleScope,
|
||||||
|
includeAll = false,
|
||||||
|
): Promise<HydrogenOverviewDetailResponse> => {
|
||||||
|
return fetchHydrogenOverviewDetail({
|
||||||
|
year: activeYear,
|
||||||
|
vehicleScope: scopeParam,
|
||||||
|
verifyScope,
|
||||||
|
stationId: selection.stationId ?? effectiveStationId,
|
||||||
|
customerId: selection.customerId ?? null,
|
||||||
|
customerName: selection.customerName ?? null,
|
||||||
|
plateNo: selection.plateNo ?? null,
|
||||||
|
month: selection.month ?? null,
|
||||||
|
date: selection.date ?? null,
|
||||||
|
region: selection.region ?? null,
|
||||||
|
groupBy,
|
||||||
|
limit: includeAll ? 500 : 50,
|
||||||
|
});
|
||||||
|
}, [activeYear, effectiveStationId, verifyScope]);
|
||||||
|
|
||||||
|
const derived = useMemo(() => {
|
||||||
|
if (!data) return null;
|
||||||
|
return deriveOverviewMetrics(data);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (loading && !data) {
|
||||||
return <HydrogenOverviewSkeleton />;
|
return <HydrogenOverviewSkeleton />;
|
||||||
}
|
}
|
||||||
const k = data.kpi;
|
|
||||||
const top5 = data.top5;
|
|
||||||
const regions = data.regions;
|
|
||||||
const monthly = data.monthly;
|
|
||||||
const customers = data.customers;
|
|
||||||
const stations = data.stations;
|
|
||||||
const availableYears = data.availableYears;
|
|
||||||
const activeYear = data.year;
|
|
||||||
|
|
||||||
const yearKgFmt = fmtKg(k.yearKg);
|
|
||||||
const yearFeeFmt = fmtYuan(k.yearFee);
|
|
||||||
const yearProfitFmt = fmtYuan(k.yearProfit);
|
|
||||||
const ourYearKgFmt = fmtKg(k.ourYearKg);
|
|
||||||
const customerYearKgFmt = fmtKg(k.customerYearKg);
|
|
||||||
const monthKgFmt = fmtKg(k.monthKg);
|
|
||||||
const monthFeeFmt = fmtYuan(k.monthFee);
|
|
||||||
const todayKgFmt = fmtKg(k.todayKg);
|
|
||||||
const todayFeeFmt = fmtYuan(k.todayFee);
|
|
||||||
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
|
|
||||||
const customerYearFeeFmt = fmtYuan(customerYearFee);
|
|
||||||
const yearRevenueFmt = fmtYuan(k.yearRevenue);
|
|
||||||
|
|
||||||
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
|
|
||||||
|
|
||||||
// 月度收支组合数据(推算"年内每月"图)
|
|
||||||
const monthlyDual = monthly.map(m => ({
|
|
||||||
...m,
|
|
||||||
monthLabel: m.month.slice(5).replace(/^0/, '') + '月',
|
|
||||||
}));
|
|
||||||
|
|
||||||
|
if (error && !data) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 relative">
|
<div className="rounded-xl border border-red-200 bg-red-50 p-6 text-sm text-red-700">
|
||||||
{/* 顶部说明条 + 年份切换 + 刷新按钮 */}
|
<div className="font-bold">氢能总览数据加载失败</div>
|
||||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
|
<p className="mt-1 text-xs text-red-600">{error}</p>
|
||||||
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
|
||||||
{availableYears.map(y => {
|
|
||||||
const active = y === activeYear;
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={y}
|
type="button"
|
||||||
onClick={() => setYear(y)}
|
onClick={() => void load(true)}
|
||||||
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
|
className="mt-3 inline-flex items-center rounded-lg bg-white px-3 py-1.5 text-xs font-bold text-slate-700 shadow-sm hover:bg-slate-50"
|
||||||
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{y}
|
重新加载
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => void load(year, true)}
|
|
||||||
disabled={refreshing}
|
|
||||||
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
|
||||||
title="手动刷新(绕过缓存)"
|
|
||||||
>
|
|
||||||
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
|
|
||||||
<span className="text-[11px] font-bold">刷新</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* KPI 5 卡 */}
|
const selectedStationName = data?.stations.find((s) => s.id === effectiveStationId)?.name ?? null;
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
const yearProfitFmt = data ? formatYuan(data.kpi.yearProfit) : { value: '0', unit: '元' };
|
||||||
<KpiCard
|
const yearRevenueFmt = data ? formatYuan(data.kpi.yearRevenue) : { value: '0', unit: '元' };
|
||||||
icon={<Fuel size={14} className="text-cyan-600" strokeWidth={2.4} />}
|
|
||||||
iconBg="bg-cyan-50"
|
|
||||||
accentClass="text-slate-800"
|
|
||||||
label="累计加氢量"
|
|
||||||
hero={yearKgFmt}
|
|
||||||
rows={[
|
|
||||||
{ label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` },
|
|
||||||
{ label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
|
|
||||||
iconBg="bg-blue-50"
|
|
||||||
accentClass="text-slate-800"
|
|
||||||
label="累计加氢费"
|
|
||||||
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
|
|
||||||
rows={[
|
|
||||||
{ label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
|
|
||||||
{ label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
|
|
||||||
iconBg="bg-emerald-50"
|
|
||||||
accentClass={profitColor}
|
|
||||||
label="时享加氢获利"
|
|
||||||
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
|
|
||||||
rows={[
|
|
||||||
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
|
|
||||||
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
icon={<CalendarDays size={14} className="text-amber-600" strokeWidth={2.4} />}
|
|
||||||
iconBg="bg-amber-50"
|
|
||||||
accentClass="text-amber-600"
|
|
||||||
label="本月加氢"
|
|
||||||
hero={monthKgFmt}
|
|
||||||
rows={[
|
|
||||||
{ label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` },
|
|
||||||
{ label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
icon={<Sparkles size={14} className="text-violet-600" strokeWidth={2.4} />}
|
|
||||||
iconBg="bg-violet-50"
|
|
||||||
accentClass="text-violet-600"
|
|
||||||
label="本日加氢"
|
|
||||||
hero={todayKgFmt}
|
|
||||||
rows={[
|
|
||||||
{ label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` },
|
|
||||||
{ label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 月度趋势:年内每月加氢量 */}
|
return (
|
||||||
{monthly.length > 0 && (
|
<>
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
<OverviewHeader
|
||||||
<div className="flex items-center justify-between mb-2">
|
activeYear={activeYear}
|
||||||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度加氢量</span>
|
availableYears={DEFAULT_AVAILABLE_YEARS}
|
||||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
vehicleScope={vehicleScope}
|
||||||
</div>
|
verifyScope={verifyScope}
|
||||||
<ResponsiveContainer width="100%" height={140}>
|
latestLedgerTime={data?.latestLedgerTime ?? null}
|
||||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
lastRefreshAt={lastRefreshAt}
|
||||||
<XAxis
|
refreshing={refreshing}
|
||||||
dataKey="monthLabel"
|
onSelectYear={(y) => setActiveYear(y)}
|
||||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
onVehicleScopeChange={(vs) => setVehicleScope(vs)}
|
||||||
tickLine={false}
|
onVerifyScopeChange={setVerifyScope}
|
||||||
axisLine={false}
|
onRefresh={() => void load(true)}
|
||||||
interval={0}
|
|
||||||
/>
|
/>
|
||||||
<YAxis hide />
|
|
||||||
<Tooltip
|
|
||||||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']}
|
|
||||||
labelFormatter={(d) => `${d}`}
|
|
||||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
|
||||||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
|
|
||||||
{monthlyDual.map((_, i) => (
|
|
||||||
<Cell key={i} fill="url(#monthlyBarGrad)" />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="monthlyBarGrad" x1="0" x2="0" y1="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#22d3ee" />
|
|
||||||
<stop offset="100%" stopColor="#3b82f6" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 月度收支对比 */}
|
{data && derived ? (
|
||||||
{monthly.length > 0 && (
|
<section className="ehb-host" aria-label="经营总览">
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
<KpiSection
|
||||||
<div className="flex items-center justify-between mb-2">
|
kpi={data.kpi}
|
||||||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度收支对比</span>
|
scope={scope}
|
||||||
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
scopeLabel={selectedStationName}
|
||||||
</div>
|
onDrillRequest={handleDrillRequest}
|
||||||
<ResponsiveContainer width="100%" height={180}>
|
|
||||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
|
||||||
<XAxis
|
|
||||||
dataKey="monthLabel"
|
|
||||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
interval={0}
|
|
||||||
/>
|
/>
|
||||||
<YAxis hide />
|
|
||||||
<Legend
|
<InsightCards
|
||||||
verticalAlign="top"
|
monthAvgKg={derived.monthAvgKg}
|
||||||
height={20}
|
bestMonth={derived.bestMonth}
|
||||||
iconSize={8}
|
latestMonth={derived.latestMonth}
|
||||||
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
|
monthMomentum={derived.monthMomentum}
|
||||||
/>
|
top5Share={derived.top5Share}
|
||||||
<Tooltip
|
customerGrossMarginPct={derived.customerGrossMarginPct}
|
||||||
formatter={(v, name) => {
|
stationAvgKg={derived.stationAvgKg}
|
||||||
const f = fmtYuan(Number(v ?? 0));
|
stationCount={data.stations.length}
|
||||||
return [`¥${f.value} ${f.unit}`, name];
|
yearProfitValue={yearProfitFmt.value}
|
||||||
|
yearProfitUnit={yearProfitFmt.unit}
|
||||||
|
yearRevenueValue={yearRevenueFmt.value}
|
||||||
|
yearRevenueUnit={yearRevenueFmt.unit}
|
||||||
|
stations={data.stations}
|
||||||
|
onSelectStation={(stId) => {
|
||||||
|
setInternalStationId(stId);
|
||||||
|
onSelectStation?.(stId);
|
||||||
}}
|
}}
|
||||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
onDrillRequest={handleDrillRequest}
|
||||||
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
|
/>
|
||||||
|
|
||||||
|
<div className="ehb-overview-charts">
|
||||||
|
<MonthlyCharts
|
||||||
|
activeYear={activeYear}
|
||||||
|
monthly={data.monthly}
|
||||||
|
monthlyDual={derived.monthlyDual}
|
||||||
|
scope={scope}
|
||||||
|
scopeLabel={selectedStationName}
|
||||||
|
onDrillRequest={handleDrillRequest}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DistributionCharts
|
||||||
|
top5={data.top5}
|
||||||
|
regions={data.regions}
|
||||||
|
stations={data.stations}
|
||||||
|
yearKg={data.kpi.yearKg}
|
||||||
|
onSelectStation={(stId) => {
|
||||||
|
setInternalStationId(stId);
|
||||||
|
onSelectStation?.(stId);
|
||||||
|
}}
|
||||||
|
scope={scope}
|
||||||
|
scopeLabel={selectedStationName}
|
||||||
|
onDrillRequest={handleDrillRequest}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
|
|
||||||
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StationSummaryTable
|
||||||
|
stations={data.stations}
|
||||||
|
onSelectStation={(stId) => {
|
||||||
|
setInternalStationId(stId);
|
||||||
|
onSelectStation?.(stId);
|
||||||
|
}}
|
||||||
|
scope={scope}
|
||||||
|
scopeLabel={selectedStationName}
|
||||||
|
onDrillRequest={handleDrillRequest}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomerSummaryTable
|
||||||
|
customers={data.customers}
|
||||||
|
scope={scope}
|
||||||
|
scopeLabel={selectedStationName}
|
||||||
|
onDrillRequest={handleDrillRequest}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{drillTree && (
|
||||||
|
<OverviewDrillTreeDialog
|
||||||
|
title={drillTree.title}
|
||||||
|
initialVehicleScope={vehicleScope}
|
||||||
|
initialSelection={drillTree.selection}
|
||||||
|
load={handleDrillLoad}
|
||||||
|
onClose={() => setDrillTree(null)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top5 + 区域占比 */}
|
<RefreshOverlay refreshing={refreshing} hasData={Boolean(data)} />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
</>
|
||||||
{/* Top5 加氢站 */}
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<span className="text-sm font-bold text-slate-700">加氢站加注量 Top5</span>
|
|
||||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
|
||||||
</div>
|
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
|
||||||
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
|
|
||||||
<XAxis type="number" hide />
|
|
||||||
<YAxis
|
|
||||||
type="category"
|
|
||||||
dataKey="name"
|
|
||||||
width={188}
|
|
||||||
tick={<RankYAxisTick />}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
|
|
||||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
|
|
||||||
{top5.map((_, i) => (
|
|
||||||
<Cell key={i} fill="url(#topBarGrad)" />
|
|
||||||
))}
|
|
||||||
<LabelList
|
|
||||||
dataKey="kg"
|
|
||||||
position="right"
|
|
||||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
|
||||||
fill="#475569"
|
|
||||||
fontSize={11}
|
|
||||||
fontWeight={700}
|
|
||||||
/>
|
|
||||||
</Bar>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
|
|
||||||
<stop offset="0%" stopColor="#3b82f6" />
|
|
||||||
<stop offset="100%" stopColor="#22d3ee" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 区域占比 */}
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
|
||||||
<span className="text-sm font-bold text-slate-700">各区域加氢占比</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="relative w-1/2 h-[200px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={regions}
|
|
||||||
dataKey="kg"
|
|
||||||
nameKey="region"
|
|
||||||
innerRadius={48}
|
|
||||||
outerRadius={80}
|
|
||||||
paddingAngle={1}
|
|
||||||
>
|
|
||||||
{regions.map((_, i) => (
|
|
||||||
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
|
||||||
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
|
|
||||||
<div className="text-base font-bold text-slate-700 leading-tight">{(k.yearKg / 1000).toFixed(2)}T</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
|
||||||
{regions.map((r, i) => (
|
|
||||||
<div key={r.region} className="flex items-center gap-1.5">
|
|
||||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
|
||||||
<span className="text-slate-600 truncate">{r.region}</span>
|
|
||||||
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 加氢站加氢汇总(全量) */}
|
|
||||||
{stations.length > 0 && (
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-sm font-bold text-slate-700">加氢站加氢汇总</span>
|
|
||||||
<span className="text-[11px] text-slate-400 font-bold">共 {stations.length} 站</span>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto -mx-1 px-1">
|
|
||||||
<table className="w-full text-[11px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
|
||||||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
|
||||||
<th className="text-left py-1.5">加氢站</th>
|
|
||||||
<th className="text-right py-1.5 w-20">加氢量</th>
|
|
||||||
<th className="text-right py-1.5 pl-2 hidden sm:table-cell">占比</th>
|
|
||||||
<th className="text-right py-1.5 pl-2 w-24">氢费收入</th>
|
|
||||||
<th className="text-right py-1.5 pr-1 hidden md:table-cell">收入占比</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{stations.map((s, i) => {
|
|
||||||
const kgFmt = fmtKg(s.kg);
|
|
||||||
const revFmt = fmtYuan(s.revenue);
|
|
||||||
return (
|
|
||||||
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
|
||||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
|
||||||
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
|
|
||||||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
|
||||||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 pl-2 text-right hidden sm:table-cell">
|
|
||||||
<div className="inline-flex items-center gap-1.5">
|
|
||||||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
|
||||||
<div className="h-full bg-gradient-to-r from-cyan-400 to-blue-500" style={{ width: `${Math.min(100, s.share * 100)}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 pl-2 text-right tabular-nums font-bold text-emerald-600">
|
|
||||||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 pr-1 text-right hidden md:table-cell">
|
|
||||||
<div className="inline-flex items-center gap-1.5">
|
|
||||||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
|
||||||
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: `${Math.min(100, s.revenueShare * 100)}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 客户账单汇总 Top */}
|
|
||||||
{customers.length > 0 && (
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-sm font-bold text-slate-700">客户账单汇总</span>
|
|
||||||
<span className="text-[11px] text-slate-400 font-bold">Top {customers.length}</span>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto -mx-1 px-1">
|
|
||||||
<table className="w-full text-[11px]">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
|
||||||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
|
||||||
<th className="text-left py-1.5">客户</th>
|
|
||||||
<th className="text-center py-1.5 w-14 hidden sm:table-cell">承担方</th>
|
|
||||||
<th className="text-right py-1.5 w-20">加氢量</th>
|
|
||||||
<th className="text-right py-1.5 pl-2 w-24">成本支出</th>
|
|
||||||
<th className="text-right py-1.5 pr-1 w-24 hidden md:table-cell">应收</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{customers.map((c2, i) => {
|
|
||||||
const kgFmt = fmtKg(c2.kg);
|
|
||||||
const costFmt = fmtYuan(c2.cost);
|
|
||||||
const revFmt = fmtYuan(c2.revenue);
|
|
||||||
return (
|
|
||||||
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
|
||||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
|
||||||
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
|
|
||||||
<td className="py-1.5 text-center hidden sm:table-cell">
|
|
||||||
{c2.payer === 'lingniu' ? (
|
|
||||||
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
|
|
||||||
) : (
|
|
||||||
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold">客户</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
|
||||||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 pl-2 text-right tabular-nums text-amber-600 font-bold">
|
|
||||||
¥{costFmt.value}<span className="text-slate-400 font-normal ml-0.5">{costFmt.unit}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 pr-1 text-right tabular-nums text-emerald-600 font-bold hidden md:table-cell">
|
|
||||||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<RotatingFooterHint />
|
|
||||||
|
|
||||||
{/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{refreshing && data && (
|
|
||||||
<motion.div
|
|
||||||
key="refresh-overlay"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
|
|
||||||
initial={{ x: '-100%' }}
|
|
||||||
animate={{ x: '100%' }}
|
|
||||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
|
|
||||||
style={{ width: '40%' }}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelative(ts: number): string {
|
|
||||||
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
||||||
if (s < 5) return '刚刚';
|
|
||||||
if (s < 60) return `${s} 秒前`;
|
|
||||||
const m = Math.floor(s / 60);
|
|
||||||
if (m < 60) return `${m} 分钟前`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h} 小时前`;
|
|
||||||
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
function HydrogenOverviewSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3 animate-pulse">
|
|
||||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
|
|
||||||
<div className="h-3 w-44 bg-slate-100 rounded" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 5 卡占位 */}
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-7 h-7 rounded-xl bg-slate-100" />
|
|
||||||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="h-7 w-24 bg-slate-200 rounded" />
|
|
||||||
<div className="space-y-1.5 pt-1 border-t border-slate-50">
|
|
||||||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
|
||||||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 月度柱图占位 */}
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
|
||||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end gap-2 h-[120px]">
|
|
||||||
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
|
|
||||||
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
|
||||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{[100, 78, 56, 40, 28].map((w, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-3">
|
|
||||||
<div className="w-5 h-5 rounded-full bg-slate-200" />
|
|
||||||
<div className="h-3 w-32 bg-slate-100 rounded" />
|
|
||||||
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
|
|
||||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
|
|
||||||
<div className="h-4 w-28 bg-slate-100 rounded" />
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-1/2 h-[200px] flex items-center justify-center">
|
|
||||||
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-slate-200" />
|
|
||||||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
|
||||||
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
|
|
||||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
|
||||||
正在加载氢能总览…
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Building2, CalendarDays, Landmark, ReceiptText, ShieldCheck, WalletCards } from 'lucide-react';
|
||||||
|
import { fetchHydrogenSettlement, type HydrogenSettlementRange, type HydrogenSettlementResponse } from './api';
|
||||||
|
import { getQuickRange } from './hydrogen-daily/model';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||||
|
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from './components/SortableColumnHeader';
|
||||||
|
|
||||||
|
type ViewRange = HydrogenSettlementRange | 'custom';
|
||||||
|
|
||||||
|
const RANGE_OPTIONS: { id: ViewRange; label: string }[] = [
|
||||||
|
{ id: 'latest', label: '最近有数据' },
|
||||||
|
{ id: 'thisWeek', label: '本周' },
|
||||||
|
{ id: 'thisMonth', label: '本月' },
|
||||||
|
{ id: 'last15', label: '近15日' },
|
||||||
|
{ id: 'custom', label: '自定义' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const matchModeText = {
|
||||||
|
exact: '精确匹配',
|
||||||
|
manual: '人工匹配',
|
||||||
|
group: '集团匹配',
|
||||||
|
unmatched: '待匹配',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function formatYuan(value: number) {
|
||||||
|
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HydrogenSettlement() {
|
||||||
|
const [range, setRange] = useState<ViewRange>('latest');
|
||||||
|
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||||
|
const [data, setData] = useState<HydrogenSettlementResponse | null>(null);
|
||||||
|
const [selectedStation, setSelectedStation] = useState<string>('');
|
||||||
|
const [sortKey, setSortKey] = useState<'date' | 'stationName' | 'matchMode' | 'paymentCount' | 'amount'>('date');
|
||||||
|
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setError(null);
|
||||||
|
const query = range === 'custom'
|
||||||
|
? { range: 'custom' as const, startDate: dateRange.start, endDate: dateRange.end }
|
||||||
|
: { range };
|
||||||
|
fetchHydrogenSettlement(query)
|
||||||
|
.then(result => { if (!cancelled) setData(result); })
|
||||||
|
.catch(reason => { if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason)); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [range, dateRange.start, dateRange.end]);
|
||||||
|
|
||||||
|
const stationOptions = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
return Array.from(new Set(data.rows.map(row => row.stationName))).sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||||
|
}, [data]);
|
||||||
|
const rows = useMemo(
|
||||||
|
() => data?.rows.filter(row => !selectedStation || row.stationName === selectedStation) ?? [],
|
||||||
|
[data, selectedStation],
|
||||||
|
);
|
||||||
|
const sortedRows = useMemo(() => sortBy(rows, sortKey, sortDirection, (row, key) => row[key]), [rows, sortDirection, sortKey]);
|
||||||
|
const selectedSummary = useMemo(() => ({
|
||||||
|
amount: rows.reduce((total, row) => total + row.amount, 0),
|
||||||
|
paymentCount: rows.reduce((total, row) => total + row.paymentCount, 0),
|
||||||
|
stationDayCount: rows.length,
|
||||||
|
}), [rows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedStation && !stationOptions.includes(selectedStation)) setSelectedStation('');
|
||||||
|
}, [selectedStation, stationOptions]);
|
||||||
|
|
||||||
|
const setPreset = (next: ViewRange) => {
|
||||||
|
setRange(next);
|
||||||
|
if (next !== 'latest' && next !== 'custom') setDateRange(getQuickRange(next));
|
||||||
|
};
|
||||||
|
const changeSort = (nextKey: typeof sortKey) => {
|
||||||
|
const next = toggleSort(sortKey, sortDirection, nextKey);
|
||||||
|
setSortKey(next.key);
|
||||||
|
setSortDirection(next.direction);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="px-1">
|
||||||
|
<h2 className="text-base font-black text-slate-900">站日现结台账</h2>
|
||||||
|
<p className="mt-0.5 text-[11px] font-bold text-slate-400">只读展示已入库的加氢站付款流水,不提供登记或审批操作</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SurfaceCard className="p-2 md:p-3">
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||||
|
{RANGE_OPTIONS.map(option => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
onClick={() => setPreset(option.id)}
|
||||||
|
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${range === option.id
|
||||||
|
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||||
|
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{range === 'custom' ? (
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
{(['start', 'end'] as const).map(field => (
|
||||||
|
<label key={field} className="rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||||
|
<span className="block text-[10px] font-black text-slate-400">{field === 'start' ? '开始日期' : '结束日期'}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateRange[field]}
|
||||||
|
onChange={event => setDateRange(previous => ({ ...previous, [field]: event.target.value }))}
|
||||||
|
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{stationOptions.length > 0 ? (
|
||||||
|
<label className="mt-2 flex min-h-10 items-center gap-2 rounded-xl border border-slate-100 bg-slate-50 px-3 text-slate-600">
|
||||||
|
<Building2 size={14} className="shrink-0 text-slate-400" />
|
||||||
|
<select
|
||||||
|
aria-label="筛选现结加氢站"
|
||||||
|
value={selectedStation}
|
||||||
|
onChange={event => setSelectedStation(event.target.value)}
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-[12px] font-black outline-none"
|
||||||
|
>
|
||||||
|
<option value="">全部加氢站</option>
|
||||||
|
{stationOptions.map(station => <option key={station} value={station}>{station}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</SurfaceCard>
|
||||||
|
|
||||||
|
{error ? <ErrorState message="站日现结台账暂时不可用,请稍后刷新。" /> : null}
|
||||||
|
{!data && !error ? <LoadingState label="正在读取站日现结台账" /> : null}
|
||||||
|
|
||||||
|
{data ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
<MetricTile icon={WalletCards} label="现结金额" value={formatYuan(selectedSummary.amount)} helper={`${data.range.start ?? '--'} 至 ${data.range.end ?? '--'}`} />
|
||||||
|
<MetricTile icon={ReceiptText} label="付款流水" value={selectedSummary.paymentCount.toLocaleString('zh-CN')} unit="笔" helper="按付款流水累计" tone="emerald" />
|
||||||
|
<MetricTile icon={CalendarDays} label="站日记录" value={selectedSummary.stationDayCount.toLocaleString('zh-CN')} unit="条" helper="日期与站点组合" tone="amber" />
|
||||||
|
<MetricTile icon={Landmark} label="最新付款日" value={data.summary.latestPaymentDate ?? '--'} helper={`涉及 ${selectedStation ? 1 : data.summary.stationCount} 个加氢站`} tone="slate" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data && rows.length === 0 ? <EmptyState title="当前统计范围没有付款流水" description="可选择“最近有数据”查看最近一次已入库的站日现结记录。" /> : null}
|
||||||
|
|
||||||
|
{data && rows.length > 0 ? (
|
||||||
|
<SurfaceCard className="overflow-hidden">
|
||||||
|
<div className="flex items-start justify-between gap-3 border-b border-slate-100 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-black text-slate-800">站日现结明细</h3>
|
||||||
|
<p className="mt-0.5 text-[11px] font-bold text-slate-400">付款日期、匹配站点及实际付款金额</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 rounded-lg bg-emerald-50 px-2 py-1 text-[10px] font-black text-emerald-600">
|
||||||
|
<ShieldCheck size={13} /> 只读台账
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-[620px] w-full text-left text-[12px]">
|
||||||
|
<thead className="bg-slate-50 text-[11px] font-black text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3"><SortableColumnHeader label="付款日期" sortKey="date" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
||||||
|
<th className="px-4 py-3"><SortableColumnHeader label="加氢站" sortKey="stationName" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
||||||
|
<th className="px-4 py-3"><SortableColumnHeader label="匹配方式" sortKey="matchMode" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
||||||
|
<th className="px-4 py-3 text-right"><SortableColumnHeader label="流水笔数" sortKey="paymentCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||||
|
<th className="px-4 py-3 text-right"><SortableColumnHeader label="现结金额" sortKey="amount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 font-bold text-slate-700">
|
||||||
|
{sortedRows.map(row => (
|
||||||
|
<tr key={`${row.date}-${row.stationId ?? row.stationName}`}>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-slate-500">{row.date}</td>
|
||||||
|
<td className="max-w-[260px] truncate px-4 py-3 text-slate-800" title={row.stationName}>{row.stationName}</td>
|
||||||
|
<td className="px-4 py-3"><span className="rounded-md bg-slate-100 px-2 py-1 text-[10px] text-slate-500">{matchModeText[row.matchMode]}</span></td>
|
||||||
|
<td className="px-4 py-3 text-right">{row.paymentCount}</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-right font-black text-emerald-600">{formatYuan(row.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</SurfaceCard>
|
||||||
|
) : null}
|
||||||
|
<RotatingFooterHint hints={['付款金额仅取已入库流水,不补算应付或未付金额', '登记、审批与付款操作不在 BI 页面开放']} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Calendar,
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Download,
|
||||||
|
Fuel,
|
||||||
|
RefreshCw,
|
||||||
|
Wallet,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { fetchHydrogenStationBoard } from './api';
|
||||||
|
import type {
|
||||||
|
HydrogenStationBoardCustomerMonth,
|
||||||
|
HydrogenStationBoardResponse,
|
||||||
|
HydrogenStationBoardStation,
|
||||||
|
HydrogenStationBoardSummaryDailyRow,
|
||||||
|
} from './types';
|
||||||
|
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from './components/SortableColumnHeader';
|
||||||
|
import './styles/energy-bi-board.css';
|
||||||
|
import './hydrogen-bi-v2/prototype-station-daily.css';
|
||||||
|
|
||||||
|
function formatYmd(date: Date): string {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRange() {
|
||||||
|
const end = new Date();
|
||||||
|
end.setHours(0, 0, 0, 0);
|
||||||
|
const start = new Date(end);
|
||||||
|
start.setDate(start.getDate() - 9);
|
||||||
|
return { start: formatYmd(start), end: formatYmd(end) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number, digits = 2): string {
|
||||||
|
return value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||||
|
}
|
||||||
|
|
||||||
|
function regionLabel(station: HydrogenStationBoardStation): string {
|
||||||
|
const parts = [station.province, station.city].filter(part => part && part !== '未归属');
|
||||||
|
return [...new Set(parts)].join(' · ') || '区域待补充';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPageScroll() {
|
||||||
|
document.documentElement.scrollTop = 0;
|
||||||
|
document.body.scrollTop = 0;
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onlyStationsWithHydrogenRecords(result: HydrogenStationBoardResponse): HydrogenStationBoardResponse {
|
||||||
|
const stations = result.stations.filter(station => station.recordCount > 0);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
stations,
|
||||||
|
summary: {
|
||||||
|
...result.summary,
|
||||||
|
stationCount: stations.length,
|
||||||
|
activeStationCount: stations.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HydrogenStationBoard({ embedded = false }: { embedded?: boolean }) {
|
||||||
|
const [dateRange, setDateRange] = useState(defaultRange);
|
||||||
|
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||||
|
const [data, setData] = useState<HydrogenStationBoardResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async (force = false) => {
|
||||||
|
setLoading(true);
|
||||||
|
// A new request invalidates the previous range immediately. Never leave
|
||||||
|
// stale figures visible while the current API request is pending or failed.
|
||||||
|
setData(null);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await fetchHydrogenStationBoard({
|
||||||
|
startDate: dateRange.start,
|
||||||
|
endDate: dateRange.end,
|
||||||
|
stationId: selectedStationId,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
setData(onlyStationsWithHydrogenRecords(result));
|
||||||
|
setError(null);
|
||||||
|
} catch (reason) {
|
||||||
|
setData(null);
|
||||||
|
setError(reason instanceof Error ? reason.message : String(reason));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [dateRange.end, dateRange.start, selectedStationId]);
|
||||||
|
|
||||||
|
useEffect(() => { void load(false); }, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 站点列表较长,切换列表/详情时统一从页面顶部开始展示。
|
||||||
|
resetPageScroll();
|
||||||
|
const frame = window.requestAnimationFrame(resetPageScroll);
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
|
}, [selectedStationId]);
|
||||||
|
|
||||||
|
const selectedStation = data?.stations.find(station => station.id === selectedStationId) ?? null;
|
||||||
|
|
||||||
|
if (error && !data) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 p-5 text-sm text-red-700">
|
||||||
|
<div>单站经营数据暂时不可用。</div>
|
||||||
|
<button type="button" onClick={() => void load(true)} className="mt-3 rounded-lg bg-white px-3 py-2 text-xs font-bold shadow-sm">重新加载</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={embedded ? 'sd-embedded' : 'relative flex flex-col gap-3'}>
|
||||||
|
{!(selectedStation && data?.selected) ? <StationBoardHeader
|
||||||
|
embedded={embedded}
|
||||||
|
selectedStation={selectedStation}
|
||||||
|
range={dateRange}
|
||||||
|
latestLedgerTime={data?.summary.latestLedgerTime ?? null}
|
||||||
|
loading={loading}
|
||||||
|
onBack={() => {
|
||||||
|
setSelectedStationId(null);
|
||||||
|
window.requestAnimationFrame(resetPageScroll);
|
||||||
|
}}
|
||||||
|
onRangeChange={setDateRange}
|
||||||
|
onRefresh={() => void load(true)}
|
||||||
|
onExport={() => data && exportStationEvidence(data, selectedStation)}
|
||||||
|
/> : null}
|
||||||
|
|
||||||
|
{data ? selectedStation && data.selected ? (
|
||||||
|
<StationDetail
|
||||||
|
station={selectedStation}
|
||||||
|
data={data}
|
||||||
|
latestLedgerTime={data.summary.latestLedgerTime}
|
||||||
|
loading={loading}
|
||||||
|
onBack={() => setSelectedStationId(null)}
|
||||||
|
onRangeChange={setDateRange}
|
||||||
|
onRefresh={() => void load(true)}
|
||||||
|
onExport={() => exportStationEvidence(data, selectedStation)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StationOverview
|
||||||
|
data={data}
|
||||||
|
onSelectStation={stationId => {
|
||||||
|
setSelectedStationId(stationId);
|
||||||
|
window.requestAnimationFrame(resetPageScroll);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StationBoardSkeleton />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && data ? (
|
||||||
|
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-white/45 backdrop-blur-[1px]">
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-xs font-bold text-slate-600 shadow-xl">
|
||||||
|
<RefreshCw size={15} className="animate-spin text-blue-600" /> 正在刷新单站数据
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StationBoardHeader({
|
||||||
|
embedded,
|
||||||
|
selectedStation,
|
||||||
|
range,
|
||||||
|
latestLedgerTime,
|
||||||
|
loading,
|
||||||
|
onBack,
|
||||||
|
onRangeChange,
|
||||||
|
onRefresh,
|
||||||
|
onExport,
|
||||||
|
}: {
|
||||||
|
embedded: boolean;
|
||||||
|
selectedStation: HydrogenStationBoardStation | null;
|
||||||
|
range: { start: string; end: string };
|
||||||
|
latestLedgerTime: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
onBack: () => void;
|
||||||
|
onRangeChange: (range: { start: string; end: string }) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onExport: () => void;
|
||||||
|
}) {
|
||||||
|
if (embedded && !selectedStation) {
|
||||||
|
return <header className="sd-topbar sd-topbar--embedded"><p className="sd-topbar__updated">最后更新时间 {latestLedgerTime ?? '暂无账本时间'}</p><div className="sd-topbar__tools"><StationRangePicker range={range} onChange={onRangeChange} /><button type="button" onClick={onRefresh} disabled={loading} className="sd-btn sd-btn--ghost"><RefreshCw size={14} className={loading ? 'animate-spin' : ''} /> 刷新</button></div></header>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<header className="rounded-[12px] border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{selectedStation ? (
|
||||||
|
<button type="button" onClick={onBack} className="inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 px-3 text-xs font-bold text-slate-600 hover:bg-slate-50">
|
||||||
|
<ArrowLeft size={15} /> 返回
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{selectedStation ? <h1 className="truncate text-[18px] font-black text-slate-950">{selectedStation.name}</h1> : null}
|
||||||
|
{selectedStation ? <p className="mt-0.5 text-[11px] font-medium text-slate-500">{regionLabel(selectedStation)} · {range.start} 至 {range.end}</p> : null}
|
||||||
|
<p className={`${selectedStation ? 'mt-1' : ''} text-[11px] font-medium text-slate-400`}>最后更新时间 {latestLedgerTime ?? '暂无账本时间'}</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 text-[11px] font-bold text-slate-600">
|
||||||
|
<Calendar size={14} className="text-blue-600" />
|
||||||
|
<span className="hidden sm:inline">查询日期</span>
|
||||||
|
<input aria-label="开始日期" type="date" value={range.start} onChange={event => onRangeChange({ ...range, start: event.target.value })} className="w-[110px] bg-transparent outline-none" />
|
||||||
|
<span>至</span>
|
||||||
|
<input aria-label="结束日期" type="date" value={range.end} onChange={event => onRangeChange({ ...range, end: event.target.value })} className="w-[110px] bg-transparent outline-none" />
|
||||||
|
</label>
|
||||||
|
<button type="button" onClick={onRefresh} disabled={loading} className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-slate-200 px-3 text-xs font-bold text-slate-600 hover:bg-slate-50 disabled:opacity-60">
|
||||||
|
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} /> 刷新
|
||||||
|
</button>
|
||||||
|
{selectedStation ? (
|
||||||
|
<button type="button" onClick={onExport} className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-blue-600 px-3 text-xs font-bold text-white shadow-sm hover:bg-blue-700">
|
||||||
|
<Download size={14} /> 导出取证
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StationRangePicker({ range, onChange }: { range: { start: string; end: string }; onChange: (range: { start: string; end: string }) => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [activeBoundary, setActiveBoundary] = useState<'start' | 'end'>('start');
|
||||||
|
const [draft, setDraft] = useState(range);
|
||||||
|
const initial = new Date(`${range.start}T00:00:00`);
|
||||||
|
const [displayMonth, setDisplayMonth] = useState({ year: initial.getFullYear(), month: initial.getMonth() + 1 });
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const closeWhenOutside = (event: MouseEvent) => {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', closeWhenOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', closeWhenOutside);
|
||||||
|
}, []);
|
||||||
|
const anchor = new Date(`${range.end}T00:00:00`);
|
||||||
|
const apply = (next: { start: string; end: string }, close = false) => {
|
||||||
|
const normalized = next.start <= next.end ? next : { start: next.end, end: next.start };
|
||||||
|
setDraft(normalized);
|
||||||
|
if (close) { onChange(normalized); setOpen(false); }
|
||||||
|
};
|
||||||
|
const today = formatYmd(anchor);
|
||||||
|
const startOfWeek = new Date(anchor);
|
||||||
|
startOfWeek.setDate(anchor.getDate() - ((anchor.getDay() + 6) % 7));
|
||||||
|
const shortcuts = {
|
||||||
|
today: { start: today, end: today },
|
||||||
|
week: { start: formatYmd(startOfWeek), end: formatYmd(new Date(startOfWeek.getFullYear(), startOfWeek.getMonth(), startOfWeek.getDate() + 6)) },
|
||||||
|
month: { start: `${anchor.getFullYear()}-${String(anchor.getMonth() + 1).padStart(2, '0')}-01`, end: formatYmd(new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0)) },
|
||||||
|
};
|
||||||
|
const daysInMonth = new Date(displayMonth.year, displayMonth.month, 0).getDate();
|
||||||
|
const leadingDays = new Date(displayMonth.year, displayMonth.month - 1, 1).getDay();
|
||||||
|
const selectDay = (day: number) => {
|
||||||
|
const value = `${displayMonth.year}-${String(displayMonth.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
if (activeBoundary === 'start') {
|
||||||
|
const next = { start: value, end: value > draft.end ? value : draft.end };
|
||||||
|
setDraft(next); setActiveBoundary('end'); return;
|
||||||
|
}
|
||||||
|
const next = value < draft.start ? { start: value, end: draft.start } : { start: draft.start, end: value };
|
||||||
|
setDraft(next); setActiveBoundary('start');
|
||||||
|
};
|
||||||
|
return <div className="sd-date sd-date--right" ref={rootRef}>
|
||||||
|
<button type="button" className={`sd-date__trigger ${open ? 'is-open' : ''}`} aria-label="查询日期" aria-haspopup="dialog" aria-expanded={open} onClick={() => {
|
||||||
|
if (!open) { setDraft(range); setActiveBoundary('start'); setDisplayMonth({ year: initial.getFullYear(), month: initial.getMonth() + 1 }); }
|
||||||
|
setOpen(value => !value);
|
||||||
|
}}><span className="sd-date__label">查询日期</span><span className="sd-date__value">{range.start} 至 {range.end}</span><ChevronDown size={15} aria-hidden className="sd-date__icon" /></button>
|
||||||
|
{open ? <div className="sd-date__popover sd-date__popover--range" role="dialog" aria-label="查询日期">
|
||||||
|
<div className="sd-date__shortcuts"><button type="button" onClick={() => apply(shortcuts.today, true)}>本日</button><button type="button" onClick={() => apply(shortcuts.week, true)}>本周</button><button type="button" onClick={() => apply(shortcuts.month, true)}>本月</button></div>
|
||||||
|
<div className="sd-date__range-tabs"><button type="button" className={activeBoundary === 'start' ? 'is-on' : ''} onClick={() => setActiveBoundary('start')}>开始 {draft.start}</button><button type="button" className={activeBoundary === 'end' ? 'is-on' : ''} onClick={() => setActiveBoundary('end')}>结束 {draft.end}</button></div>
|
||||||
|
<div className="sd-date__header"><button type="button" className="sd-date__nav" aria-label="上一月" onClick={() => setDisplayMonth(value => value.month === 1 ? { year: value.year - 1, month: 12 } : { ...value, month: value.month - 1 })}><ChevronLeft size={16} /></button><div className="sd-date__title">{displayMonth.year}年{String(displayMonth.month).padStart(2, '0')}月</div><button type="button" className="sd-date__nav" aria-label="下一月" onClick={() => setDisplayMonth(value => value.month === 12 ? { year: value.year + 1, month: 1 } : { ...value, month: value.month + 1 })}><ChevronRight size={16} /></button></div>
|
||||||
|
<div className="sd-date__week">{['日','一','二','三','四','五','六'].map(day => <span key={day}>{day}</span>)}</div><div className="sd-date__grid">{Array.from({ length: leadingDays }, (_, index) => <span key={`empty-${index}`} className="sd-date__day is-empty" />)}{Array.from({ length: daysInMonth }, (_, index) => { const day = index + 1; const value = `${displayMonth.year}-${String(displayMonth.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const classes = ['sd-date__day', value === draft.start || value === draft.end ? 'is-selected' : '', value >= draft.start && value <= draft.end ? 'is-in-range' : ''].filter(Boolean).join(' '); return <button key={day} type="button" className={classes} onClick={() => selectDay(day)}>{day}</button>; })}</div>
|
||||||
|
<div className="sd-date__footer sd-date__footer--range"><button type="button" className="sd-date__today" onClick={() => apply(shortcuts.today, true)}>本日</button><button type="button" className="sd-btn sd-btn--primary sd-date__apply" onClick={() => apply(draft, true)}>确定</button></div>
|
||||||
|
</div> : null}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StationOverview({ data, onSelectStation }: { data: HydrogenStationBoardResponse; onSelectStation: (id: number) => void }) {
|
||||||
|
const [displayMode, setDisplayMode] = useState<'all' | 'single'>('all');
|
||||||
|
const [displayStationId, setDisplayStationId] = useState(data.stations[0]?.id ?? 0);
|
||||||
|
const [dailyDrill, setDailyDrill] = useState<'kg' | 'fee' | 'payment' | null>(null);
|
||||||
|
const [partnersExpanded, setPartnersExpanded] = useState(false);
|
||||||
|
const { selfManagedStations, partnerStations } = useMemo(() => {
|
||||||
|
const selfManaged = data.stations
|
||||||
|
.filter(isSelfManagedStation)
|
||||||
|
.sort((left, right) => selfManagedStationRank(left) - selfManagedStationRank(right));
|
||||||
|
const selfManagedIds = new Set(selfManaged.map(station => station.id));
|
||||||
|
return {
|
||||||
|
selfManagedStations: selfManaged,
|
||||||
|
partnerStations: data.stations
|
||||||
|
.filter(station => station.kg > 0 && !selfManagedIds.has(station.id))
|
||||||
|
.sort((left, right) => right.kg - left.kg || left.name.localeCompare(right.name, 'zh-CN')),
|
||||||
|
};
|
||||||
|
}, [data.stations]);
|
||||||
|
const visiblePartnerStations = partnersExpanded ? partnerStations : partnerStations.slice(0, 10);
|
||||||
|
const selectedStation = data.stations.filter(station => station.id === displayStationId);
|
||||||
|
// Top5 的条形、图例与钻取入口必须共用同一份按加氢量降序的结果。
|
||||||
|
const shareStations = data.stations
|
||||||
|
.filter(station => station.kg > 0)
|
||||||
|
.sort((left, right) => right.kg - left.kg)
|
||||||
|
.slice(0, 5);
|
||||||
|
const shareTrackTotal = shareStations.reduce((sum, station) => sum + station.kg, 0);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section aria-label="全站核心指标" className="sd-hero-kpis">
|
||||||
|
<div className="sd-hero-kpi"><span className="sd-hero-kpi__label">加氢站</span><strong className="sd-hero-kpi__value">{data.summary.stationCount}<span>站</span></strong><span className="sd-hero-kpi__sub">统计站点数 · 活跃 {data.summary.activeStationCount} 站</span></div>
|
||||||
|
<StationSummaryMetric label="统计加氢总量" value={formatNumber(data.summary.totalKg)} unit="Kg" helper={`${data.summary.recordCount} 车次 · 点按查看日明细`} onClick={() => setDailyDrill('kg')} />
|
||||||
|
<StationSummaryMetric label="统计加氢量" value={formatNumber(data.summary.totalKg)} unit="Kg" helper={`¥${formatNumber(data.summary.totalFee)} · ${data.summary.daily.length} 天`} onClick={() => setDailyDrill('fee')} />
|
||||||
|
<StationSummaryMetric label="统计现结金额" value={formatNumber(data.summary.paymentAmount)} unit="元" helper={`${data.summary.daily.filter(item => item.paymentAmount > 0).length} 天有进账`} onClick={() => setDailyDrill('payment')} />
|
||||||
|
</section>
|
||||||
|
<section className="sd-share-panel" aria-label="加氢量占比Top5">
|
||||||
|
<div className="sd-share-panel__head"><h2 className="sd-share-panel__title">加氢量占比Top5 <span className="sd-share-panel__range">{data.range.start} 至 {data.range.end}</span></h2><span className="sd-share-panel__total">合计 <strong>{formatNumber(data.summary.totalKg)} Kg</strong></span></div>
|
||||||
|
<div className="sd-share-track">{shareStations.map((station, index) => <button key={station.id} type="button" className={`sd-share-seg sd-share-seg--${index}`} style={{ width: `${shareTrackTotal > 0 ? (station.kg / shareTrackTotal) * 100 : 0}%` }} onClick={() => onSelectStation(station.id)}><span className="sd-share-seg__pct">{(station.share * 100).toFixed(1)}%</span><span className="sd-share-seg__name">{station.name}</span></button>)}</div>
|
||||||
|
<ul className="sd-share-legend">{shareStations.map((station, index) => <li key={station.id}><button type="button" className="sd-share-legend__btn" onClick={() => onSelectStation(station.id)}><span className={`sd-share-legend__swatch sd-share-seg--${index}`} /><span className="sd-share-legend__rank" aria-label={`第 ${index + 1} 名`}>{index + 1}</span><span className="sd-share-legend__name">{station.name}</span><span className="sd-share-legend__val">{formatNumber(station.kg)} Kg · {(station.share * 100).toFixed(1)}%</span></button></li>)}</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="各站经营概况">
|
||||||
|
<div className="sd-section-head"><h2 className="sd-section-title">各站概况</h2><div className="sd-board-mode" role="tablist" aria-label="各站展示方式"><button type="button" role="tab" aria-selected={displayMode === 'all'} onClick={() => setDisplayMode('all')} className={displayMode === 'all' ? 'is-on' : ''}>全部</button><button type="button" role="tab" aria-selected={displayMode === 'single'} onClick={() => setDisplayMode('single')} className={displayMode === 'single' ? 'is-on' : ''}>单站</button>{displayMode === 'single' ? <select aria-label="选择站点" value={displayStationId} onChange={event => setDisplayStationId(Number(event.target.value))} className="sd-board-station-select">{data.stations.map(station => <option key={station.id} value={station.id}>{station.name}</option>)}</select> : null}</div></div>
|
||||||
|
{displayMode === 'all' ? <div className="sd-station-groups">
|
||||||
|
<StationGroup title="自营站" count={selfManagedStations.length} stations={selfManagedStations} onSelectStation={onSelectStation} emptyText="自营站数据待接入" />
|
||||||
|
<StationGroup title="合作站" count={partnerStations.length} stations={visiblePartnerStations} onSelectStation={onSelectStation} emptyText="本区间暂无合作站加氢量" />
|
||||||
|
{partnerStations.length > 10 ? <button type="button" className="sd-more-btn" onClick={() => setPartnersExpanded(value => !value)}>{partnersExpanded ? '收起合作站' : `更多合作站(还有 ${partnerStations.length - 10} 站)`} <ChevronDown size={14} aria-hidden className={partnersExpanded ? 'rotate-180' : ''} /></button> : null}
|
||||||
|
</div> : <div className="sd-station-single-card"><StationRows stations={selectedStation} onSelectStation={onSelectStation} /></div>}
|
||||||
|
</section>
|
||||||
|
<StationSummaryDailyDialog
|
||||||
|
mode={dailyDrill}
|
||||||
|
range={data.range}
|
||||||
|
rows={data.summary.daily}
|
||||||
|
onClose={() => setDailyDrill(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelfManagedStation(station: HydrogenStationBoardStation): boolean {
|
||||||
|
return station.name.includes('佛山南海羚牛') || station.name.includes('东鹏大道');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selfManagedStationRank(station: HydrogenStationBoardStation): number {
|
||||||
|
return station.name.includes('佛山南海羚牛') ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StationGroup({ title, count, stations, onSelectStation, emptyText }: {
|
||||||
|
title: string;
|
||||||
|
count: number;
|
||||||
|
stations: HydrogenStationBoardStation[];
|
||||||
|
onSelectStation: (id: number) => void;
|
||||||
|
emptyText: string;
|
||||||
|
}) {
|
||||||
|
return <section className="sd-station-group" aria-label={title}>
|
||||||
|
<div className="sd-station-group__head"><h3>{title}</h3><span>{count} 站</span></div>
|
||||||
|
{stations.length ? <div className="sd-station-single-card"><StationRows stations={stations} onSelectStation={onSelectStation} /></div> : <p className="sd-station-group__empty">{emptyText}</p>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StationRows({ stations, onSelectStation }: { stations: HydrogenStationBoardStation[]; onSelectStation: (id: number) => void }) {
|
||||||
|
return <>{stations.map(station => <button key={station.id} type="button" onClick={() => onSelectStation(station.id)} className="sd-station-row"><div className="sd-station-row__main"><span className="sd-station-card__icon"><Fuel size={17} /></span><div><div className="sd-station-card__name">{station.name}</div><div className="sd-station-card__region">{regionLabel(station)}</div></div>{station.kg > 0 ? <MiniSpark values={station.dailyKg.map(item => item.kg)} /> : <div className="sd-spark sd-spark--empty" />}<div className="sd-station-row__metrics"><StationRowMetric label="区间加氢" value={formatNumber(station.kg)} unit="Kg" /><StationRowMetric label="区间金额" value={formatNumber(station.fee)} unit="元" /><StationRowMetric label="现结进账" value={formatNumber(station.paymentAmount)} unit="元" /><div><span className="sd-station-card__m-label">加氢量占比</span><span className="sd-station-card__share">{(station.share * 100).toFixed(1)}%</span></div></div><span className="sd-station-card__go"><ChevronRight size={17} /></span></div></button>)}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StationSummaryMetric({ label, value, unit, helper, onClick }: { label: string; value: string; unit?: string; helper: string; onClick: () => void }) {
|
||||||
|
return <button type="button" onClick={onClick} className="sd-hero-kpi sd-hero-kpi--click"><span className="sd-hero-kpi__label">{label}</span><strong className="sd-hero-kpi__value">{value}{unit ? <span>{unit}</span> : null}</strong><span className="sd-hero-kpi__sub">{helper}</span></button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StationSummaryDailyDialog({
|
||||||
|
mode,
|
||||||
|
range,
|
||||||
|
rows,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
mode: 'kg' | 'fee' | 'payment' | null;
|
||||||
|
range: { start: string; end: string };
|
||||||
|
rows: HydrogenStationBoardSummaryDailyRow[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [sortKey, setSortKey] = useState<'date' | 'recordCount' | 'kg' | 'fee' | 'paymentCount' | 'paymentAmount'>('date');
|
||||||
|
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||||
|
const sortedRows = useMemo(() => sortBy(rows, sortKey, sortDirection, (row, key) => row[key]), [rows, sortDirection, sortKey]);
|
||||||
|
const changeSort = (nextKey: typeof sortKey) => {
|
||||||
|
const next = toggleSort(sortKey, sortDirection, nextKey);
|
||||||
|
setSortKey(next.key);
|
||||||
|
setSortDirection(next.direction);
|
||||||
|
};
|
||||||
|
if (!mode) return null;
|
||||||
|
const title = mode === 'kg'
|
||||||
|
? '统计加氢总量 · 日明细'
|
||||||
|
: mode === 'fee'
|
||||||
|
? '统计加氢金额 · 单日数据'
|
||||||
|
: '统计现结金额 · 单日数据';
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-slate-950/60 p-3 backdrop-blur-[4px]" role="presentation" onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
|
||||||
|
<section className="flex max-h-[82vh] w-full max-w-[720px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl" role="dialog" aria-modal="true" aria-labelledby="station-summary-drill-title">
|
||||||
|
<header className="flex items-center justify-between gap-3 border-b border-slate-100 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h3 id="station-summary-drill-title" className="text-[15px] font-black text-slate-900">{title}</h3>
|
||||||
|
<p className="mt-0.5 text-[11px] font-medium text-slate-400">{range.start} 至 {range.end}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200" aria-label="关闭"><X size={16} /></button>
|
||||||
|
</header>
|
||||||
|
<div className="overflow-auto">
|
||||||
|
{mode === 'payment' ? (
|
||||||
|
<table className="w-full min-w-[420px] text-[12px]">
|
||||||
|
<thead className="sticky top-0 bg-slate-50 text-slate-500"><tr><th className="px-4 py-2.5 text-left"><SortableColumnHeader label="日期" sortKey="date" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="现结笔数" sortKey="paymentCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="现结金额(元)" sortKey="paymentAmount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th></tr></thead>
|
||||||
|
<tbody>{sortedRows.map(row => <tr key={row.date} className="border-t border-slate-100"><td className="px-4 py-2.5 font-mono font-semibold text-slate-700">{row.date}</td><td className="px-4 py-2.5 text-right tabular-nums text-slate-600">{row.paymentCount}</td><td className="px-4 py-2.5 text-right font-semibold tabular-nums text-slate-800">{formatNumber(row.paymentAmount)}</td></tr>)}</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<table className="w-full min-w-[620px] text-[12px]">
|
||||||
|
<thead className="sticky top-0 bg-slate-50 text-slate-500"><tr><th className="px-4 py-2.5 text-left"><SortableColumnHeader label="日期" sortKey="date" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="加氢车次" sortKey="recordCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="加氢量(Kg)" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="加氢金额(元)" sortKey="fee" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th></tr></thead>
|
||||||
|
<tbody>{sortedRows.map(row => <tr key={row.date} className="border-t border-slate-100"><td className="px-4 py-2.5 font-mono font-semibold text-slate-700">{row.date}</td><td className="px-4 py-2.5 text-right tabular-nums text-slate-600">{row.recordCount}</td><td className="px-4 py-2.5 text-right font-semibold tabular-nums text-slate-800">{formatNumber(row.kg)}</td><td className="px-4 py-2.5 text-right font-semibold tabular-nums text-slate-800">{formatNumber(row.fee)}</td></tr>)}</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StationRowMetric({ label, value, unit }: { label: string; value: string; unit: string }) {
|
||||||
|
return <div><span className="sd-station-card__m-label">{label}</span><strong>{value}<span>{unit}</span></strong></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniSpark({ values }: { values: number[] }) {
|
||||||
|
const max = Math.max(...values, 0);
|
||||||
|
return <div className="sd-spark" aria-label="站点每日加氢趋势">{values.map((value, index) => <span key={index} className="sd-spark__col"><span className="sd-spark__bar" style={{ height: `${max > 0 ? Math.max(6, Math.round((value / max) * 100)) : 0}%` }} /></span>)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StationDetail({
|
||||||
|
station,
|
||||||
|
data,
|
||||||
|
latestLedgerTime,
|
||||||
|
loading,
|
||||||
|
onBack,
|
||||||
|
onRangeChange,
|
||||||
|
onRefresh,
|
||||||
|
onExport,
|
||||||
|
}: {
|
||||||
|
station: HydrogenStationBoardStation;
|
||||||
|
data: HydrogenStationBoardResponse;
|
||||||
|
latestLedgerTime: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
onBack: () => void;
|
||||||
|
onRangeChange: (range: { start: string; end: string }) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onExport: () => void;
|
||||||
|
}) {
|
||||||
|
const detail = data.selected!;
|
||||||
|
const [hoveredDate, setHoveredDate] = useState<string | null>(null);
|
||||||
|
const [dailySummaryExpanded, setDailySummaryExpanded] = useState(false);
|
||||||
|
const lastDaily = detail.daily.at(-1);
|
||||||
|
const visibleDailySummary = dailySummaryExpanded
|
||||||
|
? detail.daily
|
||||||
|
: detail.daily.slice(-30);
|
||||||
|
const monthKey = data.range.end.slice(0, 7);
|
||||||
|
const monthRows = detail.customerMonths.filter(item => item.month === monthKey);
|
||||||
|
const monthKg = monthRows.reduce((sum, row) => sum + row.kg, 0);
|
||||||
|
const intervalPayments = detail.daily.reduce((sum, row) => sum + row.paymentAmount, 0);
|
||||||
|
const trendMax = Math.max(...detail.daily.map(row => row.kg), 0);
|
||||||
|
const hovered = detail.daily.find(row => row.date === hoveredDate) ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="sd-detail-top">
|
||||||
|
<div className="sd-detail-top__lead"><button type="button" className="sd-btn sd-btn--ghost" onClick={onBack}><ArrowLeft size={16} /> 返回</button><div><h1 className="sd-detail-top__title">{station.name}</h1><p className="sd-detail-top__meta">{regionLabel(station)} · {data.range.start} 至 {data.range.end}</p><p className="sd-detail-top__updated">最后更新时间 {latestLedgerTime ?? '暂无账本时间'}</p></div></div>
|
||||||
|
<div className="sd-detail-top__tools"><StationRangePicker range={data.range} onChange={onRangeChange} /><button type="button" className="sd-btn sd-btn--ghost" onClick={onRefresh} disabled={loading}><RefreshCw size={14} className={loading ? 'animate-spin' : ''} /> 刷新</button><button type="button" className="sd-btn sd-btn--primary" onClick={onExport}><Download size={14} /> 导出取证</button></div>
|
||||||
|
</section>
|
||||||
|
<section className="sd-hero-kpis sd-hero-kpis--detail">
|
||||||
|
<DetailMetric label="当日加氢" value={formatNumber(lastDaily?.kg ?? 0)} unit="Kg" helper={`¥${formatNumber(lastDaily?.fee ?? 0)} · ${lastDaily?.recordCount ?? 0}车次`} />
|
||||||
|
<DetailMetric label={`近 ${detail.daily.length} 日加氢`} value={formatNumber(station.kg)} unit="Kg" helper={`¥${formatNumber(station.fee)}`} />
|
||||||
|
<DetailMetric label="本月加氢" value={formatNumber(monthKg)} unit="Kg" helper={monthKey} />
|
||||||
|
<DetailMetric label={`近 ${detail.daily.length} 日现结`} value={formatNumber(intervalPayments)} unit="元" helper={`${detail.daily.filter(row => row.paymentAmount > 0).length}天有进账`} />
|
||||||
|
</section>
|
||||||
|
<section className="sd-panel sd-panel--block">
|
||||||
|
<h2 className="sd-panel__title">加氢站每日加氢量汇总({data.range.start} 至 {data.range.end})</h2>
|
||||||
|
<div className="sd-table-scroll"><table className="sd-bi-table sd-bi-table--fill"><thead><tr><th>日期 ↓</th><th className="is-num">加氢量(Kg) ↕</th><th className="is-num">较昨日 ↕</th><th className="is-num">单价 ↕</th><th className="is-num">成本金额(元) ↕</th><th className="is-num">车次 ↕</th></tr></thead><tbody>
|
||||||
|
<tr className="is-total"><td>查询区间合计</td><td className="is-num">{formatNumber(detail.daily.reduce((sum, row) => sum + row.kg, 0))}</td><td className="is-num">—</td><td className="is-num">—</td><td className="is-num">{formatNumber(detail.daily.reduce((sum, row) => sum + row.fee, 0))}</td><td className="is-num">{detail.daily.reduce((sum, row) => sum + row.recordCount, 0)}</td></tr>
|
||||||
|
{[...visibleDailySummary].reverse().map(row => <tr key={row.date}><td className="is-mono">{row.date}</td><td className="is-num">{formatNumber(row.kg)}</td><td className={`is-num ${row.changeKg > 0 ? 'is-stock-up' : row.changeKg < 0 ? 'is-stock-down' : 'is-stock-flat'}`}>{row.changeKg > 0 ? '+' : ''}{formatNumber(row.changeKg)}{row.changeKg !== 0 ? <span className="sd-delta">{row.changeKg > 0 ? '▲' : '▼'}</span> : null}</td><td className="is-num">{formatNumber(row.avgPrice)}</td><td className="is-num">{formatNumber(row.fee)}</td><td className="is-num">{row.recordCount}</td></tr>)}
|
||||||
|
{detail.daily.length > 30 ? <tr><td colSpan={6} className="sd-table-more"><button type="button" onClick={() => setDailySummaryExpanded(value => !value)}>{dailySummaryExpanded ? '收起日期明细' : `更多(还有 ${detail.daily.length - 30} 天)`}</button></td></tr> : null}
|
||||||
|
</tbody></table></div>
|
||||||
|
</section>
|
||||||
|
<section className="sd-panel sd-panel--block"><div className="sd-panel__head-row"><h2 className="sd-panel__title">区间加氢量趋势</h2><span className="sd-trend-legend-chip"><span className="sd-trend-legend-dot" />{station.name}</span></div><div className="sd-trend">{hovered ? <div className="sd-trend-tip"><div className="sd-trend-tip__date">{hovered.date}</div><div className="sd-trend-tip__row"><span className="sd-trend-tip__name">加氢量</span><strong>{formatNumber(hovered.kg)} Kg</strong></div><div className="sd-trend-tip__sub">{hovered.recordCount}车次 · ¥{formatNumber(hovered.fee)}</div></div> : null}<div className="sd-trend--fill">{detail.daily.map(row => <div key={row.date} className={`sd-trend__col ${hoveredDate === row.date ? 'is-hover' : ''}`} onMouseEnter={() => setHoveredDate(row.date)} onMouseLeave={() => setHoveredDate(null)}><span className="sd-trend__val">{formatNumber(row.kg, 0)}</span><div className="sd-trend__bar-wrap"><span className="sd-trend__bar" style={{ height: `${trendMax > 0 ? Math.max(3, Math.round((row.kg / trendMax) * 100)) : 0}%` }} /></div><span className="sd-trend__date">{row.date.slice(5)}</span></div>)}</div></div></section>
|
||||||
|
|
||||||
|
<CustomerMonthMatrix title="客户月加氢量汇总(Kg)" valueKey="kg" rows={detail.customerMonths} />
|
||||||
|
<CustomerMonthMatrix title="客户月加氢费汇总(元)" valueKey="fee" rows={detail.customerMonths} />
|
||||||
|
<StationCashPanels />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailMetric({ label, value, unit, helper }: { label: string; value: string; unit: string; helper: string }) {
|
||||||
|
return <div className="sd-hero-kpi sd-hero-kpi--accent"><span className="sd-hero-kpi__label">{label}</span><strong className="sd-hero-kpi__value">{value}<span className="sd-unit">{unit}</span></strong><span className="sd-hero-kpi__sub">{helper}</span></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomerMonthMatrix({ title, valueKey, rows }: { title: string; valueKey: 'kg' | 'fee'; rows: HydrogenStationBoardCustomerMonth[] }) {
|
||||||
|
const months = useMemo(() => [...new Set(rows.map(row => row.month))].sort(), [rows]);
|
||||||
|
const customers = useMemo(() => [...new Set(rows.map(row => row.customerName))].sort((left, right) => left.localeCompare(right, 'zh-CN')), [rows]);
|
||||||
|
const [selectedCustomers, setSelectedCustomers] = useState<string[]>([]);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const values = useMemo(() => new Map(rows.map(row => [`${row.customerName}\u0000${row.month}`, row[valueKey]])), [rows, valueKey]);
|
||||||
|
const totals = useMemo(() => new Map(months.map(month => [month, rows.filter(row => row.month === month).reduce((sum,row)=>sum+row[valueKey],0)])), [months, rows, valueKey]);
|
||||||
|
const visibleCustomers = selectedCustomers.length === 0 ? customers : customers.filter(customer => selectedCustomers.includes(customer));
|
||||||
|
const shownCustomers = expanded ? visibleCustomers : visibleCustomers.slice(0, 10);
|
||||||
|
const trendClass = (current: number, previous: number | undefined) => previous == null || current === previous ? '' : current > previous ? 'is-stock-up' : 'is-stock-down';
|
||||||
|
const trendMark = (current: number, previous: number | undefined) => previous == null || current === previous ? '' : current > previous ? '▲' : '▼';
|
||||||
|
return (
|
||||||
|
<section className="sd-panel sd-panel--block">
|
||||||
|
<div className="sd-panel__head-row"><h2 className="sd-panel__title">{title}</h2><CustomerMultiSelect options={customers} value={selectedCustomers} onChange={setSelectedCustomers} /></div>
|
||||||
|
<div className="sd-table-scroll sd-table-scroll--matrix"><table className={`sd-bi-table sd-bi-table--matrix ${valueKey === 'fee' ? 'is-amount' : ''}`}><thead><tr><th>客户</th>{months.map(month => <th key={month} className="is-num">{month.replace('-', '年')}月</th>)}</tr></thead><tbody>
|
||||||
|
<tr className="is-total"><td>合计</td>{months.map((month, index) => { const current = totals.get(month) ?? 0; const previous = index > 0 ? totals.get(months[index - 1]) : undefined; return <td key={month} className={`is-num ${trendClass(current, previous)}`}>{formatNumber(current)}{trendMark(current, previous)}</td>; })}</tr>
|
||||||
|
{shownCustomers.map(customer => <tr key={customer}><td title={customer}>{customer}</td>{months.map((month, index) => { const current = values.get(`${customer}\u0000${month}`) ?? 0; const previous = index > 0 ? values.get(`${customer}\u0000${months[index - 1]}`) : undefined; return <td key={month} className={`is-num ${trendClass(current, previous)}`}>{formatNumber(current)}{trendMark(current, previous)}</td>; })}</tr>)}
|
||||||
|
</tbody></table></div>{visibleCustomers.length > 10 ? <button type="button" className="sd-more-btn" onClick={() => setExpanded(value => !value)}>{expanded ? '收起' : `更多(还有 ${visibleCustomers.length - 10} 条)`} <ChevronDown size={14} aria-hidden className={expanded ? 'rotate-180' : ''} /></button> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StationCashPanels() {
|
||||||
|
return <section className="sd-dual sd-dual--cash">
|
||||||
|
<section className="sd-panel sd-panel--grow">
|
||||||
|
<div className="sd-panel__head-row"><h2 className="sd-panel__title">客户氢费收支汇总</h2></div>
|
||||||
|
<div className="sd-table-scroll sd-table-scroll--cash-lines"><table className="sd-bi-table sd-bi-table--ledger"><thead><tr><th>客户</th><th className="is-num">充值/现金结算</th><th className="is-num">扣预付</th><th className="is-num">现结</th><th className="is-num">余额</th><th>备注</th></tr></thead><tbody><tr className="sd-pending-row"><td colSpan={6}>数据写入中,敬请期待。</td></tr></tbody></table></div>
|
||||||
|
</section>
|
||||||
|
<section className="sd-panel sd-panel--grow">
|
||||||
|
<div className="sd-panel__head-row"><h2 className="sd-panel__title">氢费充值/现结进账明细</h2><span className="sd-panel__meta">数据写入中</span></div>
|
||||||
|
<div className="sd-table-scroll sd-table-scroll--cash-lines"><table className="sd-bi-table sd-bi-table--ledger"><thead><tr><th>充值日期</th><th>客户</th><th>付款方式</th><th className="is-num">金额(元)</th></tr></thead><tbody><tr className="sd-pending-row"><td colSpan={4}>数据写入中,敬请期待。</td></tr></tbody></table></div>
|
||||||
|
</section>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomerMultiSelect({ options, value, onChange }: { options: string[]; value: string[]; onChange: (value: string[]) => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const allSelected = value.length === 0 || value.length === options.length;
|
||||||
|
const selectedLabel = allSelected ? '全部客户' : value.length <= 2 ? value.join('、') : `已选 ${value.length} 家`;
|
||||||
|
const visibleOptions = search.trim() ? options.filter(option => option.includes(search.trim())) : options;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = (event: MouseEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', close);
|
||||||
|
return () => document.removeEventListener('mousedown', close);
|
||||||
|
}, [open]);
|
||||||
|
const toggle = (customer: string) => {
|
||||||
|
if (allSelected) { onChange([customer]); return; }
|
||||||
|
if (value.includes(customer)) { const next = value.filter(item => item !== customer); onChange(next.length === 0 ? [] : next); return; }
|
||||||
|
const next = [...value, customer];
|
||||||
|
onChange(next.length === options.length ? [] : next);
|
||||||
|
};
|
||||||
|
return <div className={`sd-msel ${open ? 'is-open' : ''}`} ref={rootRef}><button type="button" className="sd-msel__trigger" aria-haspopup="listbox" aria-expanded={open} onClick={() => setOpen(current => !current)}><span className="sd-msel__label">客户</span><span className="sd-msel__value" title={selectedLabel}>{selectedLabel}</span><ChevronDown size={14} aria-hidden className="sd-msel__chev" /></button>{open ? <div className="sd-msel__panel" role="listbox" aria-multiselectable="true"><div className="sd-msel__search"><input type="search" value={search} onChange={event => setSearch(event.target.value)} placeholder="搜索客户" aria-label="搜索客户" /></div><div className="sd-msel__actions"><button type="button" onClick={() => onChange([])}>全选</button><button type="button" onClick={() => { onChange([]); setOpen(false); }}>重置</button></div><ul className="sd-msel__list">{visibleOptions.length === 0 ? <li className="sd-msel__empty">无匹配客户</li> : visibleOptions.map(option => { const on = allSelected || value.includes(option); return <li key={option}><button type="button" className={`sd-msel__opt ${on ? 'is-on' : ''}`} role="option" aria-selected={on} onClick={() => toggle(option)}><span className="sd-msel__check" aria-hidden>{on ? <Check size={12} /> : null}</span><span className="sd-msel__name">{option}</span></button></li>; })}</ul></div> : null}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportStationEvidence(data: HydrogenStationBoardResponse, station: HydrogenStationBoardStation | null) {
|
||||||
|
if (!station || !data.selected) return;
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
const daily = [['日期','加氢量(Kg)','较昨日(Kg)','平均单价','加氢金额(元)','车次','现结金额(元)','现结笔数'], ...data.selected.daily.map(row => [row.date,row.kg,row.changeKg,row.avgPrice,row.fee,row.recordCount,row.paymentAmount,row.paymentCount])];
|
||||||
|
const customers = [['月份','客户','加氢量(Kg)','加氢费(元)','车次'], ...data.selected.customerMonths.map(row => [row.month,row.customerName,row.kg,row.fee,row.recordCount])];
|
||||||
|
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(daily), '每日汇总');
|
||||||
|
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(customers), '客户月汇总');
|
||||||
|
XLSX.writeFile(workbook, `${station.name}_${data.range.start}_${data.range.end}_取证.xlsx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StationBoardSkeleton() {
|
||||||
|
return <div className="grid gap-3"><div className="h-28 animate-pulse rounded-xl bg-slate-200/70" /><div className="h-72 animate-pulse rounded-xl bg-slate-200/70" /></div>;
|
||||||
|
}
|
||||||
@@ -1,12 +1,34 @@
|
|||||||
import HydrogenOverview from './HydrogenOverview';
|
import HydrogenOverview from './HydrogenOverview';
|
||||||
import HydrogenDaily from './HydrogenDaily';
|
import HydrogenDaily from './HydrogenDaily';
|
||||||
|
import HydrogenSettlement from './HydrogenSettlement';
|
||||||
|
import type { HydrogenBoardScope } from './HydrogenBoardChrome';
|
||||||
|
|
||||||
export type HydrogenSubTab = 'daily' | 'overview';
|
export type HydrogenSubTab = 'daily' | 'overview' | 'settlement';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sub: HydrogenSubTab;
|
sub: HydrogenSubTab;
|
||||||
|
onSubChange?: (sub: HydrogenSubTab) => void;
|
||||||
|
onScopeChange?: (scope: HydrogenBoardScope) => void;
|
||||||
|
onDailyRangeTextChange?: (rangeText: string) => void;
|
||||||
|
onOverviewRangeTextChange?: (rangeText: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HydrogenView({ sub }: Props) {
|
export default function HydrogenView({
|
||||||
return sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily />;
|
sub,
|
||||||
|
onSubChange,
|
||||||
|
onScopeChange,
|
||||||
|
onDailyRangeTextChange,
|
||||||
|
onOverviewRangeTextChange,
|
||||||
|
}: Props) {
|
||||||
|
if (sub === 'overview') {
|
||||||
|
return (
|
||||||
|
<HydrogenOverview
|
||||||
|
onSubChange={onSubChange}
|
||||||
|
onScopeChange={onScopeChange}
|
||||||
|
onRangeTextChange={onOverviewRangeTextChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sub === 'settlement') return <HydrogenSettlement />;
|
||||||
|
return <HydrogenDaily scope="global" onRangeTextChange={onDailyRangeTextChange} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import { SegmentedNav } from '../../components/ui/surface';
|
||||||
|
|
||||||
|
interface SubTab<T extends string> {
|
||||||
|
id: T;
|
||||||
|
label: string;
|
||||||
|
icon: ComponentType<{ size?: number; className?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props<T extends string> {
|
||||||
|
tabs: readonly SubTab<T>[];
|
||||||
|
active: T;
|
||||||
|
onChange: (id: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubTabs<T extends string>({ tabs, active, onChange }: Props<T>) {
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
|
||||||
|
<SegmentedNav tabs={tabs} active={active} onChange={onChange} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildHydrogenSettlementQuery } from './api.js';
|
||||||
|
|
||||||
|
test('现结自定义日期明确传递 custom 范围,避免服务端回退到最近有数据', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildHydrogenSettlementQuery({
|
||||||
|
range: 'custom',
|
||||||
|
startDate: '2026-05-26',
|
||||||
|
endDate: '2026-05-26',
|
||||||
|
}),
|
||||||
|
'range=custom&startDate=2026-05-26&endDate=2026-05-26',
|
||||||
|
);
|
||||||
|
});
|
||||||
+162
-7
@@ -4,6 +4,10 @@ import type {
|
|||||||
HydrogenCustomerRow, HydrogenStationFull,
|
HydrogenCustomerRow, HydrogenStationFull,
|
||||||
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
|
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
|
||||||
CustomerType, DateQuickPick,
|
CustomerType, DateQuickPick,
|
||||||
|
HydrogenStationBoardResponse,
|
||||||
|
HydrogenDailyDetailResponse,
|
||||||
|
HydrogenOverviewDetailGroupBy,
|
||||||
|
HydrogenOverviewDetailResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const BASE = '/api/energy';
|
const BASE = '/api/energy';
|
||||||
@@ -17,31 +21,182 @@ export interface HydrogenOverviewResponse {
|
|||||||
stations: HydrogenStationFull[];
|
stations: HydrogenStationFull[];
|
||||||
availableYears: number[];
|
availableYears: number[];
|
||||||
year: number;
|
year: number;
|
||||||
|
latestLedgerTime: string | null;
|
||||||
|
filter: {
|
||||||
|
stationId: number | null;
|
||||||
|
vehicleScope: HydrogenVehicleScope;
|
||||||
|
verifyScope: HydrogenVerifyScope;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchHydrogenOverview(year?: number, force = false): Promise<HydrogenOverviewResponse> {
|
export type HydrogenVehicleScope = 'all' | 'lingniu' | 'external';
|
||||||
|
export type HydrogenVerifyScope = 'all' | 'verified';
|
||||||
|
|
||||||
|
export interface HydrogenOverviewQuery {
|
||||||
|
year?: number;
|
||||||
|
stationId?: number | null;
|
||||||
|
vehicleScope?: HydrogenVehicleScope;
|
||||||
|
verifyScope?: HydrogenVerifyScope;
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchHydrogenOverview(query: HydrogenOverviewQuery = {}): Promise<HydrogenOverviewResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (year) params.set('year', String(year));
|
if (query.year) params.set('year', String(query.year));
|
||||||
if (force) params.set('force', '1');
|
if (query.stationId) params.set('stationId', String(query.stationId));
|
||||||
|
if (query.vehicleScope && query.vehicleScope !== 'all') params.set('vehicleScope', query.vehicleScope);
|
||||||
|
if (query.verifyScope === 'verified') params.set('verifyScope', 'verified');
|
||||||
|
if (query.force) params.set('force', '1');
|
||||||
const q = params.toString();
|
const q = params.toString();
|
||||||
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
|
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
export interface HydrogenOverviewDetailQuery {
|
||||||
const q = new URLSearchParams({ range, customer });
|
year: number;
|
||||||
|
vehicleScope?: HydrogenVehicleScope;
|
||||||
|
verifyScope?: HydrogenVerifyScope;
|
||||||
|
stationId?: number | null;
|
||||||
|
month?: string | null;
|
||||||
|
date?: string | null;
|
||||||
|
customerName?: string | null;
|
||||||
|
customerId?: number | null;
|
||||||
|
plateNo?: string | null;
|
||||||
|
region?: string | null;
|
||||||
|
regionGranularity?: 'province' | 'city';
|
||||||
|
groupBy?: HydrogenOverviewDetailGroupBy | null;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchHydrogenOverviewDetail(query: HydrogenOverviewDetailQuery): Promise<HydrogenOverviewDetailResponse> {
|
||||||
|
const params = new URLSearchParams({ year: String(query.year) });
|
||||||
|
if (query.vehicleScope && query.vehicleScope !== 'all') params.set('vehicleScope', query.vehicleScope);
|
||||||
|
if (query.verifyScope === 'verified') params.set('verifyScope', 'verified');
|
||||||
|
if (query.stationId) params.set('stationId', String(query.stationId));
|
||||||
|
if (query.month) params.set('month', query.month);
|
||||||
|
if (query.date) params.set('date', query.date);
|
||||||
|
if (query.customerName) params.set('customerName', query.customerName);
|
||||||
|
if (query.customerId !== null && query.customerId !== undefined) params.set('customerId', String(query.customerId));
|
||||||
|
if (query.plateNo) params.set('plateNo', query.plateNo);
|
||||||
|
if (query.region) params.set('region', query.region);
|
||||||
|
if (query.regionGranularity) params.set('regionGranularity', query.regionGranularity);
|
||||||
|
if (query.groupBy) params.set('groupBy', query.groupBy);
|
||||||
|
if (query.limit) params.set('limit', String(query.limit));
|
||||||
|
return fetchJson<HydrogenOverviewDetailResponse>(`${BASE}/hydrogen/overview-detail?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydrogenDailyQuery {
|
||||||
|
range?: DateQuickPick;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
verifyScope?: HydrogenVerifyScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||||
|
const q = new URLSearchParams({ customer });
|
||||||
|
if (query.range) q.set('range', query.range);
|
||||||
|
if (query.startDate) q.set('startDate', query.startDate);
|
||||||
|
if (query.endDate) q.set('endDate', query.endDate);
|
||||||
|
if (query.verifyScope === 'verified') q.set('verifyScope', 'verified');
|
||||||
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
|
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchHydrogenDailyDetail(
|
||||||
|
date: string,
|
||||||
|
customer: CustomerType,
|
||||||
|
stationId?: number | null,
|
||||||
|
verifyScope: HydrogenVerifyScope = 'all',
|
||||||
|
): Promise<HydrogenDailyDetailResponse> {
|
||||||
|
const params = new URLSearchParams({ date, customer });
|
||||||
|
if (stationId) params.set('stationId', String(stationId));
|
||||||
|
if (verifyScope === 'verified') params.set('verifyScope', 'verified');
|
||||||
|
return fetchJson<HydrogenDailyDetailResponse>(`${BASE}/hydrogen/daily-detail?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydrogenStationBoardQuery {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
stationId?: number | null;
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchHydrogenStationBoard(query: HydrogenStationBoardQuery): Promise<HydrogenStationBoardResponse> {
|
||||||
|
const params = new URLSearchParams({ startDate: query.startDate, endDate: query.endDate });
|
||||||
|
if (query.stationId) params.set('stationId', String(query.stationId));
|
||||||
|
if (query.force) params.set('force', '1');
|
||||||
|
return fetchJson<HydrogenStationBoardResponse>(`${BASE}/hydrogen/station-board?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectricOverviewResponse {
|
export interface ElectricOverviewResponse {
|
||||||
kpi: ElectricKpi;
|
kpi: ElectricKpi;
|
||||||
trend: ElectricDailyRow[];
|
trend: ElectricDailyRow[];
|
||||||
|
latestChargeTime: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
||||||
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchElectricMonthly(customer: CustomerType, range: DateQuickPick = 'last15'): Promise<ElectricMonthGroup[]> {
|
export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
|
||||||
const q = new URLSearchParams({ customer, range });
|
const q = new URLSearchParams({ customer });
|
||||||
|
if (query.range) q.set('range', query.range);
|
||||||
|
if (query.startDate) q.set('startDate', query.startDate);
|
||||||
|
if (query.endDate) q.set('endDate', query.endDate);
|
||||||
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EtcOverviewResponse {
|
||||||
|
tollRecordCount: number;
|
||||||
|
vehicleCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
latestTollTime: string | null;
|
||||||
|
billCount: number;
|
||||||
|
receivableAmount: number;
|
||||||
|
paidAmount: number;
|
||||||
|
hasData: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchEtcOverview(force = false): Promise<EtcOverviewResponse> {
|
||||||
|
return fetchJson<EtcOverviewResponse>(`${BASE}/etc/overview${force ? '?force=1' : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HydrogenSettlementRange = DateQuickPick | 'latest';
|
||||||
|
|
||||||
|
export interface HydrogenSettlementRow {
|
||||||
|
date: string;
|
||||||
|
stationId: number | null;
|
||||||
|
stationName: string;
|
||||||
|
amount: number;
|
||||||
|
paymentCount: number;
|
||||||
|
matchMode: 'exact' | 'manual' | 'group' | 'unmatched';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydrogenSettlementResponse {
|
||||||
|
range: { start: string | null; end: string | null; mode: HydrogenSettlementRange | 'custom' };
|
||||||
|
summary: {
|
||||||
|
amount: number;
|
||||||
|
paymentCount: number;
|
||||||
|
stationDayCount: number;
|
||||||
|
stationCount: number;
|
||||||
|
latestPaymentDate: string | null;
|
||||||
|
};
|
||||||
|
rows: HydrogenSettlementRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydrogenSettlementQuery {
|
||||||
|
range?: HydrogenSettlementRange | 'custom';
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHydrogenSettlementQuery(query: HydrogenSettlementQuery = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query.range) params.set('range', query.range);
|
||||||
|
if (query.startDate) params.set('startDate', query.startDate);
|
||||||
|
if (query.endDate) params.set('endDate', query.endDate);
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchHydrogenSettlement(query: HydrogenSettlementQuery = {}): Promise<HydrogenSettlementResponse> {
|
||||||
|
const q = buildHydrogenSettlementQuery(query);
|
||||||
|
return fetchJson<HydrogenSettlementResponse>(`${BASE}/hydrogen/settlement${q ? `?${q}` : ''}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ArrowDown, ArrowDownUp, ArrowUp } from 'lucide-react';
|
||||||
|
|
||||||
|
export type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
export function SortableColumnHeader<Key extends string>({
|
||||||
|
label,
|
||||||
|
sortKey,
|
||||||
|
activeSortKey,
|
||||||
|
sortDirection,
|
||||||
|
onSort,
|
||||||
|
align = 'left',
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
sortKey: Key;
|
||||||
|
activeSortKey: Key;
|
||||||
|
sortDirection: SortDirection;
|
||||||
|
onSort: (sortKey: Key) => void;
|
||||||
|
align?: 'left' | 'right' | 'center';
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const active = activeSortKey === sortKey;
|
||||||
|
const Icon = active ? (sortDirection === 'asc' ? ArrowUp : ArrowDown) : ArrowDownUp;
|
||||||
|
const order = active ? (sortDirection === 'asc' ? '升序' : '降序') : '未排序';
|
||||||
|
const justify = align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSort(sortKey)}
|
||||||
|
className={`inline-flex w-full items-center ${justify} gap-1 rounded px-1 py-0.5 transition-colors ${active ? 'text-blue-600' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-600'} ${className}`}
|
||||||
|
title={`${label}:${order},点击切换`}
|
||||||
|
aria-label={`${label}:${order},点击切换`}
|
||||||
|
aria-sort={active ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<Icon size={12} strokeWidth={active ? 2.5 : 2} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleSort<Key extends string>(
|
||||||
|
currentKey: Key,
|
||||||
|
currentDirection: SortDirection,
|
||||||
|
nextKey: Key,
|
||||||
|
): { key: Key; direction: SortDirection } {
|
||||||
|
return nextKey === currentKey
|
||||||
|
? { key: currentKey, direction: currentDirection === 'asc' ? 'desc' : 'asc' }
|
||||||
|
: { key: nextKey, direction: 'desc' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortBy<Key extends string, Row>(
|
||||||
|
rows: Row[],
|
||||||
|
sortKey: Key,
|
||||||
|
sortDirection: SortDirection,
|
||||||
|
valueOf: (row: Row, key: Key) => string | number | null | undefined,
|
||||||
|
): Row[] {
|
||||||
|
const multiplier = sortDirection === 'asc' ? 1 : -1;
|
||||||
|
// Keep the caller's row order immutable while remaining compatible with the
|
||||||
|
// ES2022 target used by this dashboard (Array.prototype.toSorted is ES2023).
|
||||||
|
return [...rows].sort((left, right) => {
|
||||||
|
const leftValue = valueOf(left, sortKey) ?? '';
|
||||||
|
const rightValue = valueOf(right, sortKey) ?? '';
|
||||||
|
if (typeof leftValue === 'number' && typeof rightValue === 'number') return (leftValue - rightValue) * multiplier;
|
||||||
|
return String(leftValue).localeCompare(String(rightValue), 'zh-CN', { numeric: true }) * multiplier;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { Calendar, Fuel, RefreshCw, Truck } from 'lucide-react';
|
||||||
|
import type { CustomerType, DateQuickPick } from '../types';
|
||||||
|
import type { HydrogenDailyVehicleScope } from '../hydrogen-daily/model';
|
||||||
|
import { QUICK_PICK_OPTIONS, type RangeMode } from './model';
|
||||||
|
|
||||||
|
interface DailyRangeControlsProps {
|
||||||
|
pick: RangeMode;
|
||||||
|
dateRange: { start: string; end: string };
|
||||||
|
customer: CustomerType;
|
||||||
|
stations?: { id: number; name: string }[];
|
||||||
|
selectedStationId?: number | null;
|
||||||
|
vehicleScope?: HydrogenDailyVehicleScope;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
loading?: boolean;
|
||||||
|
onQuickPick: (pick: DateQuickPick) => void;
|
||||||
|
onCustomPick: () => void;
|
||||||
|
onDateRangeChange: (field: 'start' | 'end', value: string) => void;
|
||||||
|
onCustomerChange: (customer: CustomerType) => void;
|
||||||
|
onStationChange?: (stationId: number | null) => void;
|
||||||
|
onVehicleScopeChange?: (scope: HydrogenDailyVehicleScope) => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DailyRangeControls({
|
||||||
|
pick,
|
||||||
|
dateRange,
|
||||||
|
customer,
|
||||||
|
stations = [],
|
||||||
|
selectedStationId = null,
|
||||||
|
vehicleScope,
|
||||||
|
updatedAt,
|
||||||
|
loading = false,
|
||||||
|
onQuickPick,
|
||||||
|
onCustomPick,
|
||||||
|
onDateRangeChange,
|
||||||
|
onCustomerChange,
|
||||||
|
onStationChange,
|
||||||
|
onVehicleScopeChange,
|
||||||
|
onRefresh,
|
||||||
|
}: DailyRangeControlsProps) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[14px] border border-slate-200/70 bg-white px-3 py-3 shadow-sm md:px-4">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<div className="flex rounded-md bg-slate-100 p-0.5">
|
||||||
|
{QUICK_PICK_OPTIONS.map(option => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onQuickPick(option.id)}
|
||||||
|
className={`h-7 rounded px-3 text-[12px] font-medium transition-colors ${
|
||||||
|
pick === option.id
|
||||||
|
? 'bg-white font-semibold text-blue-600 shadow-sm'
|
||||||
|
: 'text-slate-500 hover:text-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCustomPick}
|
||||||
|
className={`h-7 rounded px-3 text-[12px] font-medium transition-colors ${
|
||||||
|
pick === 'custom'
|
||||||
|
? 'bg-white font-semibold text-blue-600 shadow-sm'
|
||||||
|
: 'text-slate-500 hover:text-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
自定义
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DateField label="开始日期" value={dateRange.start} onChange={value => onDateRangeChange('start', value)} />
|
||||||
|
<DateField label="结束日期" value={dateRange.end} onChange={value => onDateRangeChange('end', value)} />
|
||||||
|
|
||||||
|
{onStationChange ? (
|
||||||
|
<label className="inline-flex h-8 min-w-[190px] max-w-full items-center gap-2 rounded-md border border-slate-300 bg-white px-2.5 text-slate-600 transition-colors focus-within:border-sky-600 focus-within:ring-2 focus-within:ring-sky-100">
|
||||||
|
<Fuel size={13} className="shrink-0 text-slate-400" />
|
||||||
|
<select
|
||||||
|
aria-label="选择加氢站"
|
||||||
|
value={selectedStationId ?? ''}
|
||||||
|
onChange={event => onStationChange(event.target.value === '' ? null : Number(event.target.value))}
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-[12px] font-semibold text-slate-700 outline-none"
|
||||||
|
>
|
||||||
|
<option value="">请选择加氢站</option>
|
||||||
|
{stations.map((station, index) => (
|
||||||
|
<option key={`${station.id}-${station.name}-${index}`} value={station.id}>{station.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
{onVehicleScopeChange && vehicleScope ? (
|
||||||
|
<div className="flex rounded-md bg-slate-100 p-0.5" aria-label="车辆归属">
|
||||||
|
{([
|
||||||
|
['all', '全部车辆'],
|
||||||
|
['lingniu', '仅羚牛车辆'],
|
||||||
|
['external', '仅外部车辆'],
|
||||||
|
] as const).map(([id, label]) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onVehicleScopeChange(id)}
|
||||||
|
className={`inline-flex h-8 items-center gap-1.5 rounded px-2.5 text-[12px] font-medium transition-colors ${
|
||||||
|
vehicleScope === id
|
||||||
|
? 'bg-white font-semibold text-blue-600 shadow-sm'
|
||||||
|
: 'text-slate-500 hover:text-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Truck size={13} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex rounded-md bg-slate-100 p-0.5">
|
||||||
|
{(['lingniu', 'external'] as const).map(option => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCustomerChange(option)}
|
||||||
|
className={`inline-flex h-8 items-center gap-1.5 rounded px-3 text-[12px] font-medium transition-colors ${
|
||||||
|
customer === option
|
||||||
|
? 'bg-white font-semibold text-blue-600 shadow-sm'
|
||||||
|
: 'text-slate-500 hover:text-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Truck size={13} />
|
||||||
|
{option === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
{updatedAt ? <span className="font-mono text-[11px] text-slate-400">{updatedAt}</span> : null}
|
||||||
|
{onRefresh ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
title="刷新数据"
|
||||||
|
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-slate-200 bg-white px-3 text-[12px] font-semibold text-slate-600 transition-colors hover:border-blue-200 hover:text-blue-600 disabled:cursor-wait disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||||
|
return (
|
||||||
|
<label className="inline-flex h-8 items-center gap-2 rounded-md border border-slate-300 bg-white px-2.5 transition-colors focus-within:border-sky-600 focus-within:ring-2 focus-within:ring-sky-100">
|
||||||
|
<Calendar size={13} className="shrink-0 text-sky-600" />
|
||||||
|
<span className="hidden text-[11px] font-medium text-slate-400 sm:inline">{label}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
aria-label={label}
|
||||||
|
value={value}
|
||||||
|
onChange={event => onChange(event.target.value)}
|
||||||
|
className="h-7 min-w-[112px] bg-transparent font-mono text-[11px] font-semibold text-slate-700 outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { DateQuickPick } from '../types';
|
||||||
|
|
||||||
|
export const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||||
|
{ id: 'thisWeek', label: '本周' },
|
||||||
|
{ id: 'thisMonth', label: '本月' },
|
||||||
|
{ id: 'last15', label: '近 15 天' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type RangeMode = DateQuickPick | 'custom';
|
||||||
|
|
||||||
|
export function formatYmd(date: Date): string {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date: Date, days: number): Date {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setDate(next.getDate() + days);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Energy quick ranges follow the browser's local calendar day, not UTC. */
|
||||||
|
export function getQuickRange(
|
||||||
|
pick: DateQuickPick,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): { start: string; end: string } {
|
||||||
|
const today = new Date(now);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
if (pick === 'thisWeek') {
|
||||||
|
const day = today.getDay() || 7;
|
||||||
|
return { start: formatYmd(addDays(today, -(day - 1))), end: formatYmd(today) };
|
||||||
|
}
|
||||||
|
if (pick === 'thisMonth') {
|
||||||
|
return {
|
||||||
|
start: formatYmd(new Date(today.getFullYear(), today.getMonth(), 1)),
|
||||||
|
end: formatYmd(today),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { start: formatYmd(addDays(today, -14)), end: formatYmd(today) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRange(start: string, end: string): { start: string; end: string } {
|
||||||
|
return start <= end ? { start, end } : { start: end, end: start };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRangeModeLabel(mode: RangeMode): string {
|
||||||
|
if (mode === 'custom') return '自定义区间';
|
||||||
|
return QUICK_PICK_OPTIONS.find(item => item.id === mode)?.label ?? '当前时段';
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { ElectricMonthGroup } from '../types';
|
||||||
|
import { summarizeElectricMonths } from './model';
|
||||||
|
|
||||||
|
test('electric daily summary preserves totals, active-day and 30 percent boundaries', () => {
|
||||||
|
const months: ElectricMonthGroup[] = [
|
||||||
|
{
|
||||||
|
month: '2026-08',
|
||||||
|
kwh: 150,
|
||||||
|
fee: 75,
|
||||||
|
rows: [
|
||||||
|
{ date: '2026-08-01', kwh: 100, fee: 50, chainPct: 0.3 },
|
||||||
|
{ date: '2026-08-02', kwh: 50, fee: 25, chainPct: -0.29 },
|
||||||
|
{ date: '2026-08-03', kwh: 0, fee: 0, chainPct: -1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(summarizeElectricMonths(months), {
|
||||||
|
totalKwh: 150,
|
||||||
|
totalFee: 75,
|
||||||
|
activeDays: 2,
|
||||||
|
abnormalDays: 2,
|
||||||
|
avgKwh: 75,
|
||||||
|
avgPrice: 0.5,
|
||||||
|
hasFeeDetail: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('electric daily summary preserves empty and missing-fee fallbacks', () => {
|
||||||
|
assert.deepEqual(summarizeElectricMonths(null), {
|
||||||
|
totalKwh: 0,
|
||||||
|
totalFee: 0,
|
||||||
|
activeDays: 0,
|
||||||
|
abnormalDays: 0,
|
||||||
|
avgKwh: 0,
|
||||||
|
avgPrice: 0,
|
||||||
|
hasFeeDetail: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ElectricMonthGroup } from '../types';
|
||||||
|
|
||||||
|
export function summarizeElectricMonths(months: ElectricMonthGroup[] | null) {
|
||||||
|
const source = months ?? [];
|
||||||
|
const totalKwh = source.reduce((sum, month) => sum + (month.kwh || 0), 0);
|
||||||
|
const totalFee = source.reduce((sum, month) => sum + (month.fee || 0), 0);
|
||||||
|
const activeDays = source.reduce(
|
||||||
|
(sum, month) => sum + month.rows.filter(row => row.kwh > 0).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const abnormalDays = source.reduce(
|
||||||
|
(sum, month) => sum + month.rows.filter(row => Math.abs(row.chainPct) >= 0.3).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalKwh,
|
||||||
|
totalFee,
|
||||||
|
activeDays,
|
||||||
|
abnormalDays,
|
||||||
|
avgKwh: activeDays > 0 ? totalKwh / activeDays : 0,
|
||||||
|
avgPrice: totalKwh > 0 ? totalFee / totalKwh : 0,
|
||||||
|
hasFeeDetail: totalFee > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Calendar, ChevronRight, Fuel, TrendingUp, Truck, Wallet, X, Zap } from "lucide-react";
|
||||||
|
import { fetchH2BiDaily, fetchH2BiDrill, fetchH2BiMeta, fetchH2BiOverview } from "./api";
|
||||||
|
import { downloadExcelAoa } from "./prototype-download";
|
||||||
|
import { finiteNumber, formatNumber as number, formatScaled } from "./display-format";
|
||||||
|
import type { H2BiDailyResponse, H2BiDrillResponse, H2BiMetaResponse, H2BiOverviewResponse, H2BiQuery, H2BiVehicleScope, H2BiVerifyScope } from "./types";
|
||||||
|
import "./energy-operations-board.css";
|
||||||
|
|
||||||
|
const tons = (kg: unknown) => formatScaled(kg, 1000);
|
||||||
|
const wan = (yuan: unknown) => formatScaled(yuan, 10000);
|
||||||
|
const stamp = (value: string | null) => value ? value.replace("T", " ").slice(0, 19) : "—";
|
||||||
|
|
||||||
|
export const monthlyChange = (monthly: H2BiOverviewResponse["monthly"]) => {
|
||||||
|
const valid = monthly
|
||||||
|
.filter((item) => finiteNumber(item.totalKg) !== null)
|
||||||
|
.sort((a, b) => a.month.localeCompare(b.month))
|
||||||
|
.slice(-2);
|
||||||
|
const previous = finiteNumber(valid[0]?.totalKg);
|
||||||
|
const current = finiteNumber(valid[1]?.totalKg);
|
||||||
|
if (valid.length < 2 || previous === null || current === null || previous === 0) return { value: "—", detail: "暂不可用" };
|
||||||
|
const change = (current - previous) / previous * 100;
|
||||||
|
return {
|
||||||
|
value: `${change > 0 ? "+" : ""}${number(change, 1)}%`,
|
||||||
|
detail: `${Number(valid[1].month.slice(-2))}月较${Number(valid[0].month.slice(-2))}月`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type View = "overview" | "daily";
|
||||||
|
type Scope = "global" | "station";
|
||||||
|
type DrillLevel = "station" | "customer" | "vehicle" | "record";
|
||||||
|
type Drill = { title: string; level: DrillLevel; stationId?: string; stationName?: string; customerId?: number; customerName?: string; plateNo?: string } | null;
|
||||||
|
|
||||||
|
export default function EnergyOperationsBoard() {
|
||||||
|
const [meta, setMeta] = useState<H2BiMetaResponse | null>(null);
|
||||||
|
const [overview, setOverview] = useState<H2BiOverviewResponse | null>(null);
|
||||||
|
const [daily, setDaily] = useState<H2BiDailyResponse | null>(null);
|
||||||
|
const [scope, setScope] = useState<Scope>("global");
|
||||||
|
const [view, setView] = useState<View>("overview");
|
||||||
|
const [year, setYear] = useState(new Date().getFullYear());
|
||||||
|
const [stationId, setStationId] = useState<string>("");
|
||||||
|
const [vehicleScope, setVehicleScope] = useState<H2BiVehicleScope>("all");
|
||||||
|
const [verifyScope, setVerifyScope] = useState<H2BiVerifyScope>("all");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [drill, setDrill] = useState<Drill>(null);
|
||||||
|
const [drillData, setDrillData] = useState<H2BiDrillResponse | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchH2BiMeta().then((result) => {
|
||||||
|
setMeta(result);
|
||||||
|
if (result.years.length && !result.years.some((item) => item.value === year)) setYear(result.years[0].value);
|
||||||
|
}).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "筛选项加载失败"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const query = useMemo<H2BiQuery>(() => ({
|
||||||
|
year,
|
||||||
|
stationId: scope === "station" && stationId ? stationId : null,
|
||||||
|
vehicleScope,
|
||||||
|
verifyScope,
|
||||||
|
}), [scope, stationId, vehicleScope, verifyScope, year]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
Promise.all([fetchH2BiOverview(query), fetchH2BiDaily(query)])
|
||||||
|
.then(([nextOverview, nextDaily]) => {
|
||||||
|
if (!active) return;
|
||||||
|
setOverview(nextOverview);
|
||||||
|
setDaily(nextDaily);
|
||||||
|
})
|
||||||
|
.catch((reason: unknown) => active && setError(reason instanceof Error ? reason.message : "能源数据加载失败"))
|
||||||
|
.finally(() => active && setLoading(false));
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [query, refreshKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!drill) { setDrillData(null); return; }
|
||||||
|
fetchH2BiDrill({ ...query, stationId: drill.stationId ?? query.stationId, customerId: drill.customerId, plateNo: drill.plateNo, groupBy: drill.level, page: 1, pageSize: 100 })
|
||||||
|
.then(setDrillData)
|
||||||
|
.catch(() => setDrillData(null));
|
||||||
|
}, [drill, query]);
|
||||||
|
|
||||||
|
const kpi = overview?.kpis;
|
||||||
|
const kpis = [
|
||||||
|
{ label: "累计加氢量", value: tons(kpi?.totalKg), unit: "T", icon: Fuel, sub: [["我司承担", `${tons(kpi?.companyBearingKg)} T`], ["客户承担", `${tons(kpi?.customerBearingKg)} T`], ["其他", `${tons(kpi?.otherBearingKg)} T`]], drill: { title: "累计加氢量", level: "station" as const }, featured: true },
|
||||||
|
{ label: "累计加氢费", value: wan(kpi?.totalCost), unit: "万", prefix: "¥", icon: Wallet, sub: [["我司承担", `¥${wan(kpi?.companyCost)} 万`], ["客户承担", `¥${wan(kpi?.customerCost)} 万`], ["其他", `¥${wan(kpi?.otherCost)} 万`]], drill: { title: "累计加氢费", level: "station" as const } },
|
||||||
|
{ label: "加氢利润", value: wan(kpi?.customerGrossProfit), unit: "万", prefix: "¥", icon: TrendingUp, sub: `对客 ¥${wan(kpi?.customerRevenue)}万 · 成本 ¥${wan(kpi?.customerCost)}万`, drill: { title: "加氢利润", level: "station" as const } },
|
||||||
|
{ label: "本月加氢量", value: tons(kpi?.monthKg), unit: "T", icon: Truck, sub: `加氢费 ¥${wan(kpi?.monthCost)} 万 · 占累计 ${number(kpi?.monthShareOfRange)}%`, drill: { title: "本月加氢量", level: "station" as const }, featured: true },
|
||||||
|
{ label: "今日加氢量", value: number(kpi?.todayKg), unit: "Kg", icon: Zap, sub: `加氢费 ¥${number(kpi?.todayCost)} · 占本月 ${number(kpi?.todayShareOfMonth)}%`, drill: { title: "今日加氢量", level: "station" as const } },
|
||||||
|
];
|
||||||
|
const displayMonthly = useMemo(() => {
|
||||||
|
const source = new Map((overview?.monthly ?? []).map((item) => [item.month, item]));
|
||||||
|
const finalMonth = overview?.range.endDate?.startsWith(String(year))
|
||||||
|
? Number(overview.range.endDate.slice(5, 7))
|
||||||
|
: 12;
|
||||||
|
return Array.from({ length: Math.max(finalMonth, 1) }, (_, index) => {
|
||||||
|
const month = `${year}-${String(index + 1).padStart(2, "0")}`;
|
||||||
|
const item = source.get(month);
|
||||||
|
return {
|
||||||
|
month,
|
||||||
|
totalKg: finiteNumber(item?.totalKg) ?? 0,
|
||||||
|
lingniuKg: finiteNumber(item?.lingniuKg) ?? 0,
|
||||||
|
externalKg: finiteNumber(item?.externalKg) ?? 0,
|
||||||
|
customerRevenue: finiteNumber(item?.customerRevenue) ?? 0,
|
||||||
|
cost: finiteNumber(item?.cost) ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [overview, year]);
|
||||||
|
const maxMonth = Math.max(...displayMonthly.map((item) => item.totalKg), 1);
|
||||||
|
const maxDay = Math.max(...(daily?.trend.map((item) => finiteNumber(item.kg) ?? 0) ?? []), 1);
|
||||||
|
const topTotal = overview?.stations.reduce((sum, item) => sum + (finiteNumber(item.kg) ?? 0), 0) ?? 0;
|
||||||
|
const topFive = overview?.stations.slice().sort((a, b) => b.kg - a.kg).slice(0, 5) ?? [];
|
||||||
|
const topShare = topTotal ? number(topFive.reduce((sum, item) => sum + item.kg, 0) / topTotal * 100, 1) : "—";
|
||||||
|
const safeTotalKg = finiteNumber(kpi?.totalKg);
|
||||||
|
const safeProfit = finiteNumber(kpi?.customerGrossProfit);
|
||||||
|
const unitProfit = safeTotalKg && safeProfit !== null ? number(safeProfit / safeTotalKg) : "—";
|
||||||
|
const maxFinance = Math.max(...displayMonthly.flatMap((item) => [item.customerRevenue, item.cost]), 1);
|
||||||
|
const regionTotal = overview?.regions.reduce((sum, item) => sum + (finiteNumber(item.kg) ?? 0), 0) ?? 0;
|
||||||
|
const bearerTotal = finiteNumber(kpi?.totalKg) ?? 0;
|
||||||
|
const bearerPct = (value: unknown) => bearerTotal ? (finiteNumber(value) ?? 0) / bearerTotal * 100 : 0;
|
||||||
|
const monthChange = monthlyChange(overview?.monthly ?? []);
|
||||||
|
|
||||||
|
const exportOverview = () => overview && downloadExcelAoa([
|
||||||
|
["加氢站", "加氢量(Kg)", "成本(元)", "对客金额(元)", "流水笔数"],
|
||||||
|
...overview.stations.map((row) => [row.name, row.kg, row.cost, row.customerRevenue, row.recordCount]),
|
||||||
|
], `氢能经营看板_${year}.xlsx`, "氢能经营看板");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="eob" data-component="energy-operations-board-v1">
|
||||||
|
<header className="eob-hero">
|
||||||
|
<div className="eob-brand"><span><Fuel size={21} /></span><div><div className="eob-title-line"><h1>氢能经营看板</h1><b>实时运营</b></div><p>统计时间范围:{overview?.range.startDate ?? "—"} 至 {overview?.range.endDate ?? "—"}</p></div></div>
|
||||||
|
<div className="eob-hero-nav">
|
||||||
|
<div className="eob-scope" aria-label="看板范围">
|
||||||
|
<button className={scope === "global" ? "is-active" : ""} onClick={() => setScope("global")}>全局网络</button>
|
||||||
|
<button className={scope === "station" ? "is-active" : ""} onClick={() => setScope("station")}>单站视角</button>
|
||||||
|
</div>
|
||||||
|
<div className="eob-tabs"><button className={view === "overview" ? "is-active" : ""} onClick={() => setView("overview")}>经营总览</button><button className={view === "daily" ? "is-active" : ""} onClick={() => setView("daily")}>日期</button></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="eob-filters" aria-label="筛选条件">
|
||||||
|
<div className="eob-filter-left">
|
||||||
|
<label className="eob-year"><Calendar size={14}/><select aria-label="年份" value={year} onChange={(e) => setYear(Number(e.target.value))}>{(meta?.years ?? []).map((item) => <option value={item.value} key={item.value}>{item.value} 年度</option>)}</select></label>
|
||||||
|
<div className="eob-vehicle-tabs" aria-label="车辆范围"><button className={vehicleScope === "all" ? "is-active" : ""} onClick={() => setVehicleScope("all")}>全部车辆</button><button className={vehicleScope === "lingniu" ? "is-active" : ""} onClick={() => setVehicleScope("lingniu")}><i/>羚牛车辆</button><button className={vehicleScope === "external" ? "is-active" : ""} onClick={() => setVehicleScope("external")}><i/>外部车辆</button></div>
|
||||||
|
{scope === "station" && <label>站点<select value={stationId} onChange={(e) => setStationId(e.target.value)}><option value="">请选择站点</option>{(meta?.stations ?? []).map((item) => <option value={String(item.id)} key={String(item.id)}>{item.name}</option>)}</select></label>}
|
||||||
|
</div>
|
||||||
|
<div className="eob-filter-right">
|
||||||
|
<select aria-label="核对状态" value={verifyScope} onChange={(e) => setVerifyScope(e.target.value as H2BiVerifyScope)}><option value="all">全量订单</option><option value="verified">仅已核对</option></select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && <div className="eob-state is-error">{error}<button onClick={() => setRefreshKey((value) => value + 1)}>重新加载</button></div>}
|
||||||
|
{loading && <div className="eob-state">正在加载真实能源数据…</div>}
|
||||||
|
|
||||||
|
{!error && !loading && view === "overview" && <>
|
||||||
|
<section className="eob-mobile-overview" aria-label="累计经营概览"><header><h2>累计经营概览</h2><span>{year} 年累计</span></header><div className="eob-mobile-totals"><button onClick={() => setDrill({ title: "累计加氢量", level: "station" })}><span>累计加氢量</span><strong>{tons(kpi?.totalKg)}<small>T</small></strong></button><button onClick={() => setDrill({ title: "累计成本金额", level: "station" })}><span>累计成本金额</span><strong>¥{wan(kpi?.totalCost)}<small>万</small></strong></button></div><div className="eob-bearer-bar"><i style={{width:`${bearerPct(kpi?.companyBearingKg)}%`}}/><i style={{width:`${bearerPct(kpi?.customerBearingKg)}%`}}/><i style={{width:`${bearerPct(kpi?.otherBearingKg)}%`}}/></div><div className="eob-bearers"><span>我司<strong>{tons(kpi?.companyBearingKg)}T</strong><small>{number(bearerPct(kpi?.companyBearingKg),1)}%</small></span><span>客户<strong>{tons(kpi?.customerBearingKg)}T</strong><small>{number(bearerPct(kpi?.customerBearingKg),1)}%</small></span><span>其他<strong>{tons(kpi?.otherBearingKg)}T</strong><small>{number(bearerPct(kpi?.otherBearingKg),1)}%</small></span></div></section>
|
||||||
|
<button className="eob-mobile-profit" onClick={() => setDrill({ title: "加氢利润", level: "station" })}><span><TrendingUp size={22}/></span><div><small>加氢利润</small><strong>¥{wan(kpi?.customerGrossProfit)}<i>万</i></strong></div><dl><div><dt>收入</dt><dd>¥{wan(kpi?.customerRevenue)}万</dd></div><div><dt>成本</dt><dd>¥{wan(kpi?.customerCost)}万</dd></div></dl></button>
|
||||||
|
<section className="eob-mobile-period"><button onClick={() => setDrill({ title: "本月加氢", level: "station" })}><span>本月加氢</span><strong>{tons(kpi?.monthKg)}<small>T</small></strong><p>费用 ¥{wan(kpi?.monthCost)}万</p></button><button onClick={() => setDrill({ title: "本日加氢", level: "station" })}><span>本日加氢</span><strong>{number(kpi?.todayKg)}<small>Kg</small></strong><p>费用 ¥{number(kpi?.todayCost)}</p></button></section>
|
||||||
|
<section className="eob-kpis" aria-label="五项经营指标">{kpis.map(({ icon: Icon, ...item }) => <button key={item.label} className={`eob-kpi ${item.featured ? "is-featured" : ""}`} onClick={() => setDrill(item.drill)}><span className="eob-kpi-head"><span>{item.label}<small>⌕ 查看明细</small></span><i><Icon size={17} /></i></span><strong>{item.value === "—" ? <b>—</b> : <>{item.prefix}<b>{item.value}</b><small>{item.unit}</small></>}</strong>{Array.isArray(item.sub) ? <div className="eob-kpi-breakdown">{item.sub.map(([label,value])=><span key={label}><small>{label}</small><b>{value}</b></span>)}</div> : <p>{item.sub}</p>}</button>)}</section>
|
||||||
|
<section className="eob-diagnosis-desktop" aria-label="经营诊断"><b>经营诊断</b><article><span>月度环比</span><strong>{monthChange.value}</strong><small>{monthChange.detail}</small></article><article><span>单公斤毛利</span><strong>{unitProfit === "—" ? "—" : `¥${unitProfit}/kg`}</strong><small>{unitProfit === "—" ? "暂不可用" : "按累计加氢量计算"}</small></article><article><span>头部站点占比</span><strong>{topShare === "—" ? "—" : `${topShare}%`}</strong><small>{topShare === "—" ? "暂不可用" : "前5站占总量"}</small></article><article><span>待核对订单</span><strong>—</strong><small>暂不可用</small></article></section>
|
||||||
|
<details className="eob-diagnosis" open={false}><summary>经营诊断 <span>展开查看</span></summary><div><article><span>月度环比</span><strong>{monthChange.value}</strong><small>{monthChange.detail}</small></article><article><span>单公斤毛利</span><strong>{unitProfit === "—" ? "—" : `¥${unitProfit}/kg`}</strong><small>{unitProfit === "—" ? "暂不可用" : "按累计加氢量计算"}</small></article><article><span>头部站点占比</span><strong>{topShare === "—" ? "—" : `${topShare}%`}</strong><small>{topShare === "—" ? "暂不可用" : "前5站占总量"}</small></article><article><span>待核对订单</span><strong>—</strong><small>暂不可用</small></article></div></details>
|
||||||
|
<section className="eob-charts">
|
||||||
|
<article className="eob-panel"><header><h2>{year} 年月度加氢量</h2><span><i className="is-blue" />羚牛车辆 <i className="is-light-blue" />外部车辆 统计范围:{overview?.range.startDate} 至 {overview?.range.endDate} · 单位 Kg</span></header><div className="eob-bars">{displayMonthly.map((item) => <div key={item.month}><span>{number(item.totalKg / 1000, 1)}k</span><b style={{ height: `${item.totalKg ? Math.max(item.totalKg / maxMonth * 100, 2) : 0}%` }}><i className="is-light-blue" style={{ height: `${item.totalKg ? item.externalKg / item.totalKg * 100 : 0}%` }} /><i className="is-blue" /></b><small>{Number(item.month.slice(-2))}月</small></div>)}</div></article>
|
||||||
|
<article className="eob-panel eob-finance"><header><h2>{year} 年月度收支对比</h2><span><i className="is-cyan" />客户收入 <i className="is-purple" />成本支出 统计范围:{overview?.range.startDate} 至 {overview?.range.endDate} · 单位 元</span></header><div className="eob-finance-bars">{displayMonthly.map((item) => <div key={item.month}><span><i className="is-purple" style={{height:`${item.cost ? Math.max(item.cost/maxFinance*100,2) : 0}%`}}/><i className="is-cyan" style={{height:`${item.customerRevenue ? Math.max(item.customerRevenue/maxFinance*100,2) : 0}%`}}/></span><small>{Number(item.month.slice(-2))}月</small></div>)}</div></article>
|
||||||
|
<article className="eob-panel"><header><h2>加氢站加氢量 Top5</h2><button onClick={() => setDrill({ title: "加氢站排名", level: "station" })}>查看明细</button></header><ol className="eob-ranking">{topFive.map((item, index) => <li key={String(item.id)}><b>{index + 1}</b><span>{item.name}</span><i><em style={{ width: `${finiteNumber(topFive[0]?.kg) ? (finiteNumber(item.kg) ?? 0) / (finiteNumber(topFive[0]?.kg) ?? 1) * 100 : 0}%` }} /></i><strong>{number(item.kg, 0)}</strong></li>)}</ol></article>
|
||||||
|
<article className="eob-panel eob-regions"><header><h2>各区域加氢占比</h2><strong>合计 {tons(regionTotal)} T</strong></header><div>{overview?.regions.map((item,index)=><article key={item.region}><b>{index+1}</b><span>{item.region || "未归属"}</span><i><em style={{width:`${regionTotal ? (finiteNumber(item.kg) ?? 0)/regionTotal*100 : 0}%`}}/></i><strong>{number(finiteNumber(item.share) ?? (regionTotal ? (finiteNumber(item.kg) ?? 0)/regionTotal*100 : null),1)}%</strong></article>)}{!overview?.regions.length&&<p>暂无区域数据</p>}</div></article>
|
||||||
|
</section>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{!error && !loading && view === "daily" && <section className="eob-panel eob-daily"><header><h2>每日加氢趋势</h2><span>{daily?.range.startDate ?? "—"} 至 {daily?.range.endDate ?? "—"}</span></header><div className="eob-bars">{daily?.trend.map((item) => <div key={item.date}><span>{number(item.kg, 0)}</span><b style={{ height: `${Math.max((finiteNumber(item.kg) ?? 0) / maxDay * 100, 2)}%` }}><i className="is-blue" /></b><small>{item.date.slice(5)}</small></div>)}</div><div className="eob-daily-table">{daily?.days.map((item) => <button key={item.date} onClick={() => setDrill({ title: `${item.date} 明细`, level: "station" })}><span>{item.date}</span><strong>{number(item.kg)} Kg</strong><small>¥{number(item.cost)} · {number(item.recordCount, 0)} 笔</small></button>)}</div></section>}
|
||||||
|
|
||||||
|
{drill && <div className="eob-modal" role="dialog" aria-modal="true" data-drill-level={drill.level}><section><header><div><h2>{drill.title}</h2><p>站点 → 客户 → 车辆 → 订单</p></div><button onClick={() => setDrill(null)} aria-label="关闭"><X /></button></header><nav className="eob-drill-crumbs"><button onClick={() => setDrill({ title: drill.title, level: "station" })}>站点</button>{drill.stationName && <><ChevronRight size={14}/><button onClick={() => setDrill({ ...drill, level: "customer", customerId: undefined, customerName: undefined, plateNo: undefined })}>{drill.stationName}</button></>}{drill.customerName && <><ChevronRight size={14}/><button onClick={() => setDrill({ ...drill, level: "vehicle", plateNo: undefined })}>{drill.customerName}</button></>}{drill.plateNo && <><ChevronRight size={14}/><span>{drill.plateNo}</span></>}</nav><div className="eob-modal-body">{!drillData ? <div className="eob-state">正在加载明细…</div> : drill.level === "record" ? <table><thead><tr><th>订单</th><th>加氢量</th><th>成本</th><th>状态</th></tr></thead><tbody>{drillData.records.map((row, index) => <tr key={String(row.id ?? index)}><td>{String(row.orderNo ?? row.id ?? "—")}</td><td>{number(row.kg)} Kg</td><td>¥{number(row.cost)}</td><td>{String(row.verifyStatus ?? row.status ?? "—")}</td></tr>)}{drillData.records.length === 0 && <tr><td colSpan={4}>暂无可用订单</td></tr>}</tbody></table> : <table><thead><tr><th>{drill.level === "station" ? "站点" : drill.level === "customer" ? "客户" : "车辆"}</th><th>加氢量</th><th>成本</th><th>流水</th></tr></thead><tbody>{drillData.groups.map((row) => <tr className="eob-drill-row" key={row.id} onClick={() => setDrill(drill.level === "station" ? { ...drill, level: "customer", stationId: row.id, stationName: row.name } : drill.level === "customer" ? { ...drill, level: "vehicle", customerId: Number(row.id), customerName: row.name } : { ...drill, level: "record", plateNo: row.name })}><td>{row.name}<ChevronRight size={15}/></td><td>{number(row.kg)} Kg</td><td>¥{number(row.cost)}</td><td>{number(row.recordCount, 0)}</td></tr>)}{drillData.groups.length === 0 && <tr><td colSpan={4}>暂无可用数据</td></tr>}</tbody></table>}</div></section></div>}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
|||||||
|
import { fetchJson } from '../../../auth/api-client';
|
||||||
|
import type {
|
||||||
|
H2BiDailyResponse,
|
||||||
|
H2BiDailyTreeQuery,
|
||||||
|
H2BiDailyTreeResponse,
|
||||||
|
H2BiDrillReadOptions,
|
||||||
|
H2BiFullDrillOptions,
|
||||||
|
H2BiFullDrillResponse,
|
||||||
|
H2BiDrillQuery,
|
||||||
|
H2BiDrillResponse,
|
||||||
|
H2BiMetaResponse,
|
||||||
|
H2BiOverviewResponse,
|
||||||
|
H2BiQuery,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const BASE = '/api/energy/h2/v2';
|
||||||
|
|
||||||
|
function queryString(query: Record<string, unknown>) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (value !== undefined && value !== null && value !== '') params.set(key, String(value));
|
||||||
|
}
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function request<T>(path: string, query: object = {}, options?: RequestInit) {
|
||||||
|
const qs = queryString(query as Record<string, unknown>);
|
||||||
|
return fetchJson<T>(`${BASE}/${path}${qs ? `?${qs}` : ''}`, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchH2BiMeta() {
|
||||||
|
return request<H2BiMetaResponse>('meta');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchH2BiOverview(query: H2BiQuery) {
|
||||||
|
return request<H2BiOverviewResponse>('overview', query);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchH2BiDaily(query: H2BiQuery) {
|
||||||
|
return request<H2BiDailyResponse>('daily', query).catch(async () => {
|
||||||
|
// The date-group drill is backed by the same read-only ledger and has a
|
||||||
|
// simpler query plan. Keep the date view usable when the aggregate daily
|
||||||
|
// endpoint times out/fails, without substituting mock data.
|
||||||
|
const drill = await request<H2BiDrillResponse>('drill', {
|
||||||
|
...query,
|
||||||
|
groupBy: 'date',
|
||||||
|
pageSize: 400,
|
||||||
|
});
|
||||||
|
const startDate = query.startDate ?? `${query.year}-01-01`;
|
||||||
|
const endDate = query.endDate ?? new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(new Date());
|
||||||
|
const byDate = new Map(drill.groups.map((row) => [row.name, row]));
|
||||||
|
const days = [] as H2BiDailyResponse['trend'];
|
||||||
|
for (let cursor = new Date(`${startDate}T00:00:00Z`); cursor <= new Date(`${endDate}T00:00:00Z`); cursor.setUTCDate(cursor.getUTCDate() + 1)) {
|
||||||
|
const date = cursor.toISOString().slice(0, 10);
|
||||||
|
const row = byDate.get(date);
|
||||||
|
days.push({
|
||||||
|
date,
|
||||||
|
kg: Number(row?.kg ?? 0),
|
||||||
|
lingniuKg: Number(row?.lingniuKg ?? 0),
|
||||||
|
externalKg: Number(row?.externalKg ?? 0),
|
||||||
|
cost: Number(row?.cost ?? 0),
|
||||||
|
recordCount: Number(row?.recordCount ?? 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const totalKg = days.reduce((sum, row) => sum + row.kg, 0);
|
||||||
|
const totalCost = days.reduce((sum, row) => sum + row.cost, 0);
|
||||||
|
return {
|
||||||
|
range: { startDate, endDate },
|
||||||
|
watermark: { ledgerAt: null, paymentAt: null },
|
||||||
|
filters: query,
|
||||||
|
kpis: {
|
||||||
|
totalKg,
|
||||||
|
totalCost,
|
||||||
|
averageDailyKg: totalKg / Math.max(1, days.length),
|
||||||
|
stationCount: Number(drill.summary.stationCount ?? 0),
|
||||||
|
activeDays: days.filter((row) => row.kg > 0).length,
|
||||||
|
},
|
||||||
|
trend: days,
|
||||||
|
days: [...days].reverse(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchH2BiDailyTree(date: string, query: H2BiDailyTreeQuery) {
|
||||||
|
return request<H2BiDailyTreeResponse>('daily-tree', { date, ...query });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchH2BiDrill(
|
||||||
|
query: H2BiDrillQuery,
|
||||||
|
options?: H2BiDrillReadOptions,
|
||||||
|
) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = Math.min(Math.max(1, query.pageSize ?? 100), 200);
|
||||||
|
const response = await request<H2BiDrillResponse>(
|
||||||
|
'drill',
|
||||||
|
{ ...query, page, pageSize },
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
const itemCount = query.groupBy === 'record'
|
||||||
|
? response.records.length
|
||||||
|
: response.groups.length;
|
||||||
|
// Older servers only set hasMore for record pages. A full page is therefore
|
||||||
|
// also treated as potentially incomplete; the final empty/short page proves
|
||||||
|
// completion without silently dropping grouped rows.
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
page: {
|
||||||
|
...response.page,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
itemCount,
|
||||||
|
hasMore: Boolean(response.page?.hasMore) || itemCount === pageSize,
|
||||||
|
},
|
||||||
|
} satisfies H2BiDrillResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortError() {
|
||||||
|
const error = new Error('已取消全量读取');
|
||||||
|
error.name = 'AbortError';
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads every page only after a caller explicitly asks for a complete result.
|
||||||
|
* It never returns a partial collection: server errors, cancellation, and the
|
||||||
|
* page guard all reject before an export can be created.
|
||||||
|
*/
|
||||||
|
export async function fetchAllH2BiDrill(
|
||||||
|
query: Omit<H2BiDrillQuery, 'page' | 'pageSize'>,
|
||||||
|
options: H2BiFullDrillOptions = {},
|
||||||
|
): Promise<H2BiFullDrillResponse> {
|
||||||
|
const pageSize = Math.min(Math.max(1, options.pageSize ?? 200), 200);
|
||||||
|
const maxPages = Math.max(1, options.maxPages ?? 250);
|
||||||
|
const maxRows = Math.max(1, options.maxRows ?? 50_000);
|
||||||
|
const groups: H2BiDrillResponse['groups'] = [];
|
||||||
|
const records: H2BiDrillResponse['records'] = [];
|
||||||
|
let first: H2BiDrillResponse | null = null;
|
||||||
|
|
||||||
|
for (let page = 1; page <= maxPages; page += 1) {
|
||||||
|
if (options.signal?.aborted) throw abortError();
|
||||||
|
const result = await fetchH2BiDrill(
|
||||||
|
{ ...query, page, pageSize },
|
||||||
|
{ signal: options.signal },
|
||||||
|
);
|
||||||
|
if (!first) first = result;
|
||||||
|
groups.push(...result.groups);
|
||||||
|
records.push(...result.records);
|
||||||
|
const itemCount = query.groupBy === 'record' ? records.length : groups.length;
|
||||||
|
if (itemCount > maxRows)
|
||||||
|
throw new Error(`全量读取超过 ${maxRows} 条保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
|
||||||
|
if (!result.page.hasMore) {
|
||||||
|
return {
|
||||||
|
...first,
|
||||||
|
groups,
|
||||||
|
records,
|
||||||
|
page: {
|
||||||
|
...result.page,
|
||||||
|
page: 1,
|
||||||
|
pageSize,
|
||||||
|
itemCount,
|
||||||
|
hasMore: false,
|
||||||
|
pagesRead: page,
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`全量读取超过 ${maxPages} 页保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reusable contract for station-detail consumers that need every raw record. */
|
||||||
|
export function fetchAllH2BiDrillRecords(
|
||||||
|
query: Omit<H2BiDrillQuery, 'groupBy' | 'page' | 'pageSize'>,
|
||||||
|
options?: H2BiFullDrillOptions,
|
||||||
|
) {
|
||||||
|
return fetchAllH2BiDrill({ ...query, groupBy: 'record' }, options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { bearingLabels } from "./bearing-labels";
|
||||||
|
|
||||||
|
test("records display their ledger bearing type", () => {
|
||||||
|
assert.deepEqual(bearingLabels({ settlementType: 1 }).map(x => x.label), ["客户承担"]);
|
||||||
|
assert.deepEqual(bearingLabels({ settlementType: "2" }).map(x => x.label), ["我司承担"]);
|
||||||
|
assert.deepEqual(bearingLabels({ settlementType: 3 }).map(x => x.label), ["客户自行结算"]);
|
||||||
|
});
|
||||||
|
test("groups display every distinct bearing type, including unknown", () => {
|
||||||
|
assert.deepEqual(bearingLabels({ settlementTypes: "1,2,3,unknown,1" }).map(x => x.label),
|
||||||
|
["客户承担", "我司承担", "客户自行结算", "未明确"]);
|
||||||
|
});
|
||||||
|
test("missing and unrecognized values do not imply an actual payer", () => {
|
||||||
|
for (const settlementType of [null, undefined, "", 4, "all"]) {
|
||||||
|
assert.deepEqual(bearingLabels({ settlementType }).map(x => x.label), ["未明确"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const labels: Record<string, { label: string; className: string }> = {
|
||||||
|
"1": { label: "客户承担", className: "is-cust" },
|
||||||
|
"2": { label: "我司承担", className: "is-lingniu" },
|
||||||
|
"3": { label: "客户自行结算", className: "is-other" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use ledger settlement types, never the selected filter or monetary amounts.
|
||||||
|
export function bearingLabels(row: { settlementTypes?: unknown; settlementType?: unknown }) {
|
||||||
|
const types = String(row.settlementTypes ?? row.settlementType ?? "")
|
||||||
|
.split(",").map((value) => value.trim());
|
||||||
|
const results = types.map((value) => labels[value] ?? { label: "未明确", className: "is-other" });
|
||||||
|
return [...new Map(results.map((item) => [item.label, item])).values()];
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
export function DailyTreeButton({ open, label, children, onClick }: {
|
||||||
|
open: boolean; label: string; children: ReactNode; onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const Icon = open ? ChevronDown : ChevronRight;
|
||||||
|
return <button type="button" className="ehb-daily-disclosure" aria-expanded={open}
|
||||||
|
aria-label={`${open ? "收起" : "展开"}${label}`} onClick={onClick}>
|
||||||
|
<Icon size={15} aria-hidden="true" /><span>{children}</span>
|
||||||
|
</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DailyBranchState({ columns, error, empty, onRetry }: {
|
||||||
|
columns: number; error?: string; empty?: boolean; onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
return <tr className="ehb-daily-branch-state"><td colSpan={columns}>
|
||||||
|
<div role={error ? "alert" : "status"}>
|
||||||
|
{error || (empty ? "当前范围暂无明细" : "正在加载明细…")}
|
||||||
|
{error ? <button type="button" onClick={onRetry}><RefreshCw size={14} aria-hidden="true" />重试</button> : null}
|
||||||
|
</div>
|
||||||
|
</td></tr>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { H2BiDailyResponse } from "./types";
|
||||||
|
|
||||||
|
export function formatDailyChange(value: unknown): string {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) return "环比 —";
|
||||||
|
return `环比 ${value > 0 ? "+" : ""}${value.toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export the entire selected range, independently of which branches were opened. */
|
||||||
|
export function dailySummaryRows(daily: H2BiDailyResponse): Array<Array<string | number>> {
|
||||||
|
return [
|
||||||
|
["日期", "加氢站数", "加氢量(Kg)", "成本(元)"],
|
||||||
|
["区间合计", daily.kpis.stationCount, daily.kpis.totalKg, daily.kpis.totalCost],
|
||||||
|
...daily.days.map(day => [day.date, day.stationCount ?? "—", day.kg, day.cost]),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { DailyBranchState, DailyTreeButton } from "./daily-detail-controls";
|
||||||
|
import { dailySummaryRows, formatDailyChange } from "./daily-detail-format";
|
||||||
|
import type { H2BiDailyResponse } from "./types";
|
||||||
|
|
||||||
|
test("每日环比区分真实零值、缺失值和非数值", () => {
|
||||||
|
for (const value of [null, undefined, NaN, Infinity, "0"]) assert.equal(formatDailyChange(value), "环比 —");
|
||||||
|
assert.equal(formatDailyChange(0), "环比 0.00%");
|
||||||
|
assert.equal(formatDailyChange(-12.5), "环比 -12.50%");
|
||||||
|
assert.equal(formatDailyChange(12.5), "环比 +12.50%");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("日期汇总导出包含全部日期,不依赖已展开明细并保留真零", () => {
|
||||||
|
const daily = {
|
||||||
|
kpis: { stationCount: 2, totalKg: 15.25, totalCost: 450 },
|
||||||
|
days: [
|
||||||
|
{ date: "2026-09-03", stationCount: 2, kg: 15.25, cost: 450 },
|
||||||
|
{ date: "2026-09-02", stationCount: 0, kg: 0, cost: 0 },
|
||||||
|
],
|
||||||
|
} as H2BiDailyResponse;
|
||||||
|
const rows = dailySummaryRows(daily);
|
||||||
|
assert.equal(rows.length, 4);
|
||||||
|
assert.deepEqual(rows[1], ["区间合计", 2, 15.25, 450]);
|
||||||
|
assert.deepEqual(rows[3], ["2026-09-02", 0, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("层级按钮包含键盘原生语义与明确展开状态", () => {
|
||||||
|
for (const open of [false, true]) {
|
||||||
|
const html = renderToStaticMarkup(createElement(DailyTreeButton, {
|
||||||
|
open, label: "测试站客户明细", children: "测试站", onClick() {},
|
||||||
|
}));
|
||||||
|
assert.match(html, /<button type="button"/);
|
||||||
|
assert.ok(html.includes(`aria-expanded="${open}"`));
|
||||||
|
assert.ok(html.includes(`${open ? "收起" : "展开"}测试站客户明细`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("展开状态区分加载、空数据、失败及重试入口", () => {
|
||||||
|
const render = (props: Partial<Parameters<typeof DailyBranchState>[0]>) => renderToStaticMarkup(createElement(DailyBranchState, {
|
||||||
|
columns: 3, onRetry() {}, ...props,
|
||||||
|
}));
|
||||||
|
assert.match(render({}), /正在加载明细/);
|
||||||
|
assert.match(render({ empty: true }), /当前范围暂无明细/);
|
||||||
|
const error = render({ error: "站点明细加载失败,请重试" });
|
||||||
|
assert.match(error, /role="alert"/);
|
||||||
|
assert.match(error, /<button type="button"/);
|
||||||
|
assert.match(error, /colSpan="3"/i);
|
||||||
|
assert.doesNotMatch(error, /正在加载明细/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { devMockResponse } from "./dev-mock-api";
|
||||||
|
|
||||||
|
test("开发 mock 覆盖健康检查与氢能 v2 只读接口", () => {
|
||||||
|
for (const path of [
|
||||||
|
"/api/health",
|
||||||
|
"/api/energy/h2/v2/meta",
|
||||||
|
"/api/energy/h2/v2/overview",
|
||||||
|
"/api/energy/h2/v2/daily",
|
||||||
|
"/api/energy/h2/v2/daily-tree",
|
||||||
|
"/api/energy/h2/v2/drill",
|
||||||
|
]) assert.ok(devMockResponse(path), path);
|
||||||
|
|
||||||
|
assert.equal(devMockResponse("/api/unknown"), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import type { Plugin } from "vite";
|
||||||
|
|
||||||
|
const station = {
|
||||||
|
id: "101",
|
||||||
|
name: "本地验收加氢站",
|
||||||
|
province: "浙江省",
|
||||||
|
city: "嘉兴市",
|
||||||
|
kg: 12345.67,
|
||||||
|
lingniuKg: 10000,
|
||||||
|
externalKg: 2345.67,
|
||||||
|
cost: 23456.78,
|
||||||
|
revenue: 34567.89,
|
||||||
|
customerRevenue: 34567.89,
|
||||||
|
customerCost: 18000,
|
||||||
|
companyCost: 4000,
|
||||||
|
otherCost: 1456.78,
|
||||||
|
recordCount: 42,
|
||||||
|
customerCount: 1,
|
||||||
|
share: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const customer = {
|
||||||
|
id: 1,
|
||||||
|
name: "本地验收客户",
|
||||||
|
kg: 12345.67,
|
||||||
|
customerBearingKg: 10000,
|
||||||
|
companyBearingKg: 2000,
|
||||||
|
otherBearingKg: 345.67,
|
||||||
|
bearer: "both",
|
||||||
|
cost: 23456.78,
|
||||||
|
revenue: 34567.89,
|
||||||
|
customerRevenue: 34567.89,
|
||||||
|
customerCost: 18000,
|
||||||
|
companyCost: 4000,
|
||||||
|
otherCost: 1456.78,
|
||||||
|
recordCount: 42,
|
||||||
|
};
|
||||||
|
|
||||||
|
const range = { startDate: "2026-01-01", endDate: "2026-08-31" };
|
||||||
|
const watermark = { ledgerAt: "2026-08-31 16:00:00", paymentAt: null };
|
||||||
|
const kpis = {
|
||||||
|
totalKg: 12345.67,
|
||||||
|
totalCost: 23456.78,
|
||||||
|
customerBearingKg: 10000,
|
||||||
|
companyBearingKg: 2000,
|
||||||
|
otherBearingKg: 345.67,
|
||||||
|
customerRevenue: 34567.89,
|
||||||
|
customerCost: 18000,
|
||||||
|
companyCost: 4000,
|
||||||
|
otherCost: 1456.78,
|
||||||
|
totalRevenue: 34567.89,
|
||||||
|
customerGrossProfit: 16567.89,
|
||||||
|
monthKg: 3456.78,
|
||||||
|
monthCost: 6789.01,
|
||||||
|
todayKg: 123.45,
|
||||||
|
todayCost: 234.56,
|
||||||
|
monthShareOfRange: 28,
|
||||||
|
todayShareOfMonth: 3.57,
|
||||||
|
recordCount: 42,
|
||||||
|
stationCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const overview = {
|
||||||
|
range,
|
||||||
|
watermark,
|
||||||
|
filters: {},
|
||||||
|
kpis,
|
||||||
|
monthly: [{
|
||||||
|
month: "2026-08",
|
||||||
|
totalKg: 12345.67,
|
||||||
|
lingniuKg: 10000,
|
||||||
|
externalKg: 2345.67,
|
||||||
|
cost: 23456.78,
|
||||||
|
customerCost: 18000,
|
||||||
|
companyCost: 4000,
|
||||||
|
otherCost: 1456.78,
|
||||||
|
revenue: 34567.89,
|
||||||
|
customerRevenue: 34567.89,
|
||||||
|
customerGrossProfit: 16567.89,
|
||||||
|
}],
|
||||||
|
topStations: [station],
|
||||||
|
regions: [{ region: "嘉兴市", kg: 12345.67, share: 100 }],
|
||||||
|
stations: [station],
|
||||||
|
customers: [customer],
|
||||||
|
};
|
||||||
|
|
||||||
|
const dailyPoint = {
|
||||||
|
date: "2026-08-31",
|
||||||
|
kg: 123.45,
|
||||||
|
lingniuKg: 100,
|
||||||
|
externalKg: 23.45,
|
||||||
|
cost: 234.56,
|
||||||
|
recordCount: 2,
|
||||||
|
stationCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function devMockResponse(pathname: string) {
|
||||||
|
if (pathname === "/api/health") return { status: "ok", source: "dev-mock" };
|
||||||
|
if (pathname.endsWith("/meta")) return {
|
||||||
|
years: [{ value: 2026, startDate: range.startDate, endDate: range.endDate }],
|
||||||
|
stations: [{ id: station.id, name: station.name }],
|
||||||
|
watermark,
|
||||||
|
};
|
||||||
|
if (pathname.endsWith("/overview")) return overview;
|
||||||
|
if (pathname.endsWith("/daily-tree")) return {
|
||||||
|
date: dailyPoint.date,
|
||||||
|
stations: [{
|
||||||
|
id: station.id,
|
||||||
|
name: station.name,
|
||||||
|
kg: dailyPoint.kg,
|
||||||
|
cost: dailyPoint.cost,
|
||||||
|
recordCount: 2,
|
||||||
|
customers: [{ id: customer.id, name: customer.name, kg: dailyPoint.kg, cost: dailyPoint.cost, recordCount: 2 }],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
if (pathname.endsWith("/daily")) return {
|
||||||
|
range,
|
||||||
|
watermark,
|
||||||
|
filters: {},
|
||||||
|
kpis: { totalKg: 12345.67, totalCost: 23456.78, averageDailyKg: 823.04, stationCount: 1, activeDays: 15 },
|
||||||
|
trend: [dailyPoint],
|
||||||
|
days: [dailyPoint],
|
||||||
|
};
|
||||||
|
if (pathname.endsWith("/drill")) return {
|
||||||
|
groupBy: "record",
|
||||||
|
amountScope: "all",
|
||||||
|
filters: {},
|
||||||
|
summary: { recordCount: 1, kg: 123.45, cost: 4000, revenue: 4320 },
|
||||||
|
groups: [{ ...station, stationCount: 1, customerCount: 1 }],
|
||||||
|
records: [{
|
||||||
|
id: 1,
|
||||||
|
time: "2026-08-31 12:00:00",
|
||||||
|
orderNo: "DEV-001",
|
||||||
|
stationId: station.id,
|
||||||
|
stationName: station.name,
|
||||||
|
customerId: customer.id,
|
||||||
|
customerName: customer.name,
|
||||||
|
plateNo: "浙FDEV01",
|
||||||
|
source: "dev-mock",
|
||||||
|
verifyStatus: "verified",
|
||||||
|
vehicleId: 1,
|
||||||
|
kg: 123.45,
|
||||||
|
unitPrice: 35,
|
||||||
|
cost: 4000,
|
||||||
|
revenue: 4320,
|
||||||
|
}],
|
||||||
|
page: { page: 1, pageSize: 100, hasMore: false },
|
||||||
|
};
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(response: ServerResponse, body: unknown) {
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||||
|
response.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function energyDevMockApi(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "energy-dev-mock-api",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((request: IncomingMessage, response: ServerResponse, next) => {
|
||||||
|
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
|
||||||
|
if (pathname === "/favicon.ico") {
|
||||||
|
response.statusCode = 204;
|
||||||
|
return response.end();
|
||||||
|
}
|
||||||
|
const body = devMockResponse(pathname);
|
||||||
|
if (body === undefined) return next();
|
||||||
|
sendJson(response, body);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { finiteNumber, formatNumber, formatScaled } from "./display-format";
|
||||||
|
|
||||||
|
test("能源看板格式化边界区分真实零值与不可用值", () => {
|
||||||
|
for (const value of [null, undefined, NaN, Infinity, -Infinity, "0"]) {
|
||||||
|
assert.equal(finiteNumber(value), null);
|
||||||
|
assert.equal(formatNumber(value), "—");
|
||||||
|
assert.equal(formatScaled(value, 1000), "—");
|
||||||
|
}
|
||||||
|
assert.equal(formatNumber(0), "0.00");
|
||||||
|
assert.equal(formatScaled(0, 1000), "0.00");
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user