Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eb38a4b05 | ||
|
|
2518d9ee54 | ||
|
|
62efef0ab9 | ||
|
|
37a9f303ec | ||
|
|
06fe75b4c7 | ||
|
|
d610e4b841 | ||
|
|
7fce6b8474 | ||
|
|
bd0627d00f |
@@ -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
|
||||
@@ -10,11 +10,6 @@ services:
|
||||
DB_USER: "oneos_db_prod"
|
||||
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
|
||||
DB_NAME: "ln_asset_management"
|
||||
HYDROGEN_DB_HOST: "47.99.185.173"
|
||||
HYDROGEN_DB_PORT: "3306"
|
||||
HYDROGEN_DB_USER: "root"
|
||||
HYDROGEN_DB_PASSWORD: "lnMysql."
|
||||
HYDROGEN_DB_NAME: "ln_asset_management"
|
||||
MILEAGE_DB_HOST: "101.133.130.65"
|
||||
MILEAGE_DB_PORT: "3306"
|
||||
MILEAGE_DB_USER: "bi_reader_02"
|
||||
|
||||
@@ -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,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 的兼容处理
|
||||
- 看似可合并但必须保持差异的流程
|
||||
|
||||
命名和类型能够表达清楚的代码不额外添加注释。
|
||||
|
||||
## 已识别但不在本轮修复的问题
|
||||
|
||||
- 统计页实时车辆与历史日期车辆共用同一个缓存槽,存在状态覆盖风险。
|
||||
- 监控页不同入口的请求参数目前不完全一致。
|
||||
- 数据库连接配置与部署凭据需要独立安全治理。
|
||||
|
||||
这些问题需要单独建缺陷、明确期望行为后再修复,避免借重构改变线上结果。
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.13",
|
||||
"version": "1.1.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.13",
|
||||
"version": "1.1.15",
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"private": true,
|
||||
"version": "1.1.13",
|
||||
"version": "1.1.15",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||
@@ -9,7 +9,9 @@
|
||||
"dev:client": "vite --port=3000 --host=0.0.0.0",
|
||||
"build": "vite build",
|
||||
"start": "node --import tsx src/server/index.ts",
|
||||
"lint": "tsc --noEmit"
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "node --import tsx --test $(find src -type f -name '*.test.ts' -print | sort)",
|
||||
"test:mileage-report": "node --import tsx --test src/server/routes/mileage/daily-report-model.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+15
-141
@@ -1,142 +1,29 @@
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { Truck, Route, Activity, Fuel, BatteryCharging, Receipt, MapPinned } from "lucide-react";
|
||||
import { Shell, type ModuleConfig } from "./components/Shell";
|
||||
import { Shell } from "./components/Shell";
|
||||
import AuthProvider from "./auth/AuthProvider";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
||||
import { canAccessScheduling, canAccessEnergy } from "./shared/auth/roles";
|
||||
import { canAccessEnergy } from "./shared/auth/roles";
|
||||
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
|
||||
import { buildModules } from "./app/modules";
|
||||
import {
|
||||
normalizeBrowserPath,
|
||||
readBrowserRoute,
|
||||
} from "./app/routing";
|
||||
|
||||
const AssetsModule = lazy(() => import("./modules/assets/AssetsModule"));
|
||||
const MileageModule = lazy(() => import("./modules/mileage/MileageModule"));
|
||||
const SchedulingModule = lazy(() => import("./modules/scheduling/SchedulingModule"));
|
||||
const HydrogenModule = lazy(() => import("./modules/energy/HydrogenModule"));
|
||||
const ElectricModule = lazy(() => import("./modules/energy/ElectricModule"));
|
||||
const EtcModule = lazy(() => import("./modules/energy/EtcModule"));
|
||||
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
|
||||
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
|
||||
const VehicleHeatmapModule = lazy(() => import("./modules/vehicle-heatmap/VehicleHeatmapModule"));
|
||||
const HydrogenHeatmapModule = lazy(() => import("./modules/hydrogen-heatmap/HydrogenHeatmapModule"));
|
||||
|
||||
const ASSETS_MODULE: ModuleConfig = {
|
||||
id: "assets",
|
||||
label: "资产管理",
|
||||
icon: Truck,
|
||||
component: AssetsModule,
|
||||
};
|
||||
|
||||
const MILEAGE_MODULE: ModuleConfig = {
|
||||
id: "mileage",
|
||||
label: "里程管理",
|
||||
icon: Route,
|
||||
component: MileageModule,
|
||||
};
|
||||
|
||||
const VEHICLE_HEATMAP_MODULE: ModuleConfig = {
|
||||
id: "vehicle-heatmap",
|
||||
label: "车辆",
|
||||
icon: MapPinned,
|
||||
component: VehicleHeatmapModule,
|
||||
};
|
||||
|
||||
const SCHEDULING_MODULE: ModuleConfig = {
|
||||
id: "scheduling",
|
||||
label: "智能调度",
|
||||
icon: Activity,
|
||||
component: SchedulingModule,
|
||||
};
|
||||
|
||||
const HYDROGEN_MODULE: ModuleConfig = {
|
||||
id: "hydrogen",
|
||||
label: "氢能",
|
||||
icon: Fuel,
|
||||
component: HydrogenModule,
|
||||
};
|
||||
|
||||
const HYDROGEN_HEATMAP_MODULE: ModuleConfig = {
|
||||
id: "hydrogen-heatmap",
|
||||
label: "加氢",
|
||||
icon: MapPinned,
|
||||
component: HydrogenHeatmapModule,
|
||||
};
|
||||
|
||||
const ELECTRIC_MODULE: ModuleConfig = {
|
||||
id: "electric",
|
||||
label: "电能",
|
||||
icon: BatteryCharging,
|
||||
component: ElectricModule,
|
||||
};
|
||||
|
||||
const ETC_MODULE: ModuleConfig = {
|
||||
id: "etc",
|
||||
label: "ETC",
|
||||
icon: Receipt,
|
||||
component: EtcModule,
|
||||
};
|
||||
|
||||
/**
|
||||
* 把旧路径 / 根路径归一化到 `/asset` 或 `/energy` 主路径,
|
||||
* 必要时携带 hash 一段定位到具体模块。已是主路径或后台管理页则不动。
|
||||
*/
|
||||
function normalizePath() {
|
||||
if (typeof window === "undefined") return;
|
||||
const { pathname, hash, search } = window.location;
|
||||
|
||||
// 主路径 & 隐藏后台页保持不变
|
||||
if (pathname === "/asset" || pathname === "/energy") return;
|
||||
if (pathname === "/ele/import" || pathname === "/admin/feedback") return;
|
||||
|
||||
const legacyMap: Record<string, { path: string; hash?: string }> = {
|
||||
"/": { path: "/asset" },
|
||||
"/vehicle": { path: "/asset", hash: "assets" },
|
||||
"/assets": { path: "/asset", hash: "assets" },
|
||||
"/mileage": { path: "/asset", hash: "mileage" },
|
||||
"/scheduling": { path: "/asset", hash: "scheduling" },
|
||||
};
|
||||
|
||||
// 未知路径兜底到 /asset(保留原 hash 让 Shell 内部继续解析)
|
||||
const target = legacyMap[pathname] ?? { path: "/asset" };
|
||||
const finalHash = target.hash ? `#${target.hash}` : hash || "";
|
||||
window.history.replaceState(null, "", `${target.path}${search}${finalHash}`);
|
||||
}
|
||||
|
||||
normalizePath();
|
||||
|
||||
type PathSet = "asset" | "energy";
|
||||
|
||||
function getPathSet(): PathSet {
|
||||
return window.location.pathname === "/energy" ? "energy" : "asset";
|
||||
}
|
||||
|
||||
function getRouteKey(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
const path = window.location.pathname;
|
||||
const hash = window.location.hash;
|
||||
if (
|
||||
path === "/ele/import" ||
|
||||
hash === "#/ele/import" ||
|
||||
hash === "#ele/import"
|
||||
)
|
||||
return "ele/import";
|
||||
if (
|
||||
path === "/admin/feedback" ||
|
||||
hash === "#/admin/feedback" ||
|
||||
hash === "#admin/feedback"
|
||||
)
|
||||
return "admin/feedback";
|
||||
return "";
|
||||
}
|
||||
normalizeBrowserPath();
|
||||
|
||||
function AuthGate() {
|
||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||
const [routeKey, setRouteKey] = useState(getRouteKey);
|
||||
const [pathSet, setPathSet] = useState<PathSet>(getPathSet);
|
||||
const [route, setRoute] = useState(readBrowserRoute);
|
||||
const { routeKey, pathSet } = route;
|
||||
|
||||
// 监听 hashchange / popstate,让 a href="#/..." 跳转与浏览器前进后退能即时生效
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
setRouteKey(getRouteKey());
|
||||
setPathSet(getPathSet());
|
||||
setRoute(readBrowserRoute());
|
||||
};
|
||||
window.addEventListener("hashchange", update);
|
||||
window.addEventListener("popstate", update);
|
||||
@@ -150,23 +37,10 @@ function AuthGate() {
|
||||
document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI";
|
||||
}, [pathSet]);
|
||||
|
||||
const modules = useMemo<ModuleConfig[]>(() => {
|
||||
if (pathSet === "energy") {
|
||||
return [HYDROGEN_MODULE, ELECTRIC_MODULE, ETC_MODULE];
|
||||
}
|
||||
const heatmapChildren = canAccessEnergy(user?.roles)
|
||||
? [HYDROGEN_HEATMAP_MODULE, VEHICLE_HEATMAP_MODULE]
|
||||
: [VEHICLE_HEATMAP_MODULE];
|
||||
const heatmapModule: ModuleConfig = {
|
||||
id: "heatmap",
|
||||
label: "热力图",
|
||||
icon: MapPinned,
|
||||
children: heatmapChildren,
|
||||
};
|
||||
const result: ModuleConfig[] = [ASSETS_MODULE, MILEAGE_MODULE, heatmapModule];
|
||||
if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
|
||||
return result;
|
||||
}, [pathSet, user?.roles]);
|
||||
const modules = useMemo(
|
||||
() => buildModules(pathSet, user?.roles),
|
||||
[pathSet, user?.roles],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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'],
|
||||
);
|
||||
});
|
||||
@@ -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: '氢能', 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,20 @@
|
||||
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);
|
||||
});
|
||||
|
||||
test('derives path groups and hidden routes from path or hash', () => {
|
||||
assert.equal(getPathSet('/energy'), 'energy');
|
||||
assert.equal(getPathSet('/asset'), 'asset');
|
||||
assert.equal(getRouteKey('/asset', '#/ele/import'), 'ele/import');
|
||||
assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback');
|
||||
assert.equal(getRouteKey('/asset', '#mileage'), '');
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
export type PathSet = 'asset' | 'energy';
|
||||
|
||||
interface BrowserLocation {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
}
|
||||
const ROOT_PATHS = new Set(['/asset', '/energy', '/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' ? 'energy' : 'asset';
|
||||
}
|
||||
|
||||
export function getRouteKey(pathname: string, hash: string): string {
|
||||
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),
|
||||
};
|
||||
}
|
||||
+36
-350
@@ -1,24 +1,15 @@
|
||||
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 { useAuth } from '../auth/useAuth';
|
||||
import { canManageFeedback } from '../shared/auth/roles';
|
||||
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
|
||||
import RotatingFooterHint from './RotatingFooterHint';
|
||||
import FeedbackLauncher from './feedback/FeedbackLauncher';
|
||||
import FeedbackModal from './feedback/FeedbackModal';
|
||||
import type { FeedbackStep, FeedbackType, UploadedImg } from './feedback/types';
|
||||
|
||||
const MAX_SCREENSHOTS = 6;
|
||||
const MAX_IMG_SIZE_MB = 5;
|
||||
|
||||
interface UploadedImg {
|
||||
url: string;
|
||||
thumbDataUrl: string;
|
||||
}
|
||||
|
||||
async function uploadImage(file: File): Promise<string> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
@@ -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 {
|
||||
const hash = (window.location.hash || '').slice(1);
|
||||
const path = window.location.pathname;
|
||||
@@ -94,7 +53,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1); // 1=选类型, 2=写内容, 3=成功页
|
||||
const [step, setStep] = useState<FeedbackStep>(1);
|
||||
const [type, setType] = useState<FeedbackType | null>(null);
|
||||
const [mod, setMod] = useState<string>('');
|
||||
const [content, setContent] = useState('');
|
||||
@@ -128,14 +87,12 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
// Open: detect current module
|
||||
useEffect(() => {
|
||||
if (open && step === 1) {
|
||||
setMod(moduleProp ?? detectModule());
|
||||
}
|
||||
}, [open, step, moduleProp]);
|
||||
|
||||
// Lock scroll when open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
@@ -153,7 +110,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setTimeout(reset, 300); // 等动画完
|
||||
setTimeout(reset, 300);
|
||||
};
|
||||
|
||||
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 progress = step >= 3 ? 100 : (step / 2) * 100;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Action Button */}
|
||||
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
|
||||
<motion.button
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
|
||||
whileHover={{ scale: 1.08 }}
|
||||
whileTap={{ scale: 0.92 }}
|
||||
onClick={() => setMenuOpen(m => !m)}
|
||||
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
|
||||
aria-label="反馈"
|
||||
title="提建议 / 我的反馈"
|
||||
>
|
||||
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
|
||||
>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); setOpen(true); }}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
|
||||
>
|
||||
<Sparkles size={14} className="text-blue-500" /> 提个建议
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
|
||||
>
|
||||
<Inbox size={14} className="text-emerald-500" /> 我的反馈
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="h-px bg-slate-100 my-0.5" />
|
||||
<a
|
||||
href="#/admin/feedback"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
|
||||
>
|
||||
<Settings2 size={14} className="text-violet-500" /> 反馈管理
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 点击外面关菜单 */}
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[-1]"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
<FeedbackLauncher
|
||||
isAdmin={isAdmin}
|
||||
menuOpen={menuOpen}
|
||||
onCloseMenu={() => setMenuOpen(false)}
|
||||
onOpenFeedback={() => { setMenuOpen(false); setOpen(true); }}
|
||||
onOpenHistory={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
||||
onToggleMenu={() => setMenuOpen(current => !current)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||
|
||||
{/* Modal */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||
// 不允许点击背景关闭:避免用户输入到一半误触遮罩丢失内容
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: '100%', opacity: 0 }}
|
||||
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
||||
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-2.5 pb-1">
|
||||
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||
</div>
|
||||
|
||||
{/* Header + progress */}
|
||||
<div className="px-4 pb-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
|
||||
<Sparkles size={14} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">
|
||||
{step === 3 ? '收到啦~' : '提个建议'}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 font-bold">
|
||||
{step === 1 && '第一步 / 共 2 步'}
|
||||
{step === 2 && '第二步 / 共 2 步'}
|
||||
{step === 3 && '感谢你的反馈'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={close} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
|
||||
<FeedbackModal
|
||||
canNext={canNext}
|
||||
content={content}
|
||||
error={error}
|
||||
fileRef={fileRef}
|
||||
maxScreenshots={MAX_SCREENSHOTS}
|
||||
module={mod}
|
||||
open={open}
|
||||
progress={progress}
|
||||
screenshots={shots}
|
||||
step={step}
|
||||
submitting={submitting}
|
||||
textareaRef={taRef}
|
||||
type={type}
|
||||
uploading={uploading}
|
||||
onAddFiles={addFiles}
|
||||
onBack={back}
|
||||
onChangeContent={setContent}
|
||||
onChangeModule={setMod}
|
||||
onClose={close}
|
||||
onNext={next}
|
||||
onRemoveScreenshot={(index) => setShots(prev => prev.filter((_, current) => current !== index))}
|
||||
onSelectType={(selectedType) => { setType(selectedType); setStep(2); }}
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,6 +83,9 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
|
||||
const { user } = useAuth();
|
||||
const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
|
||||
// 氢能经营看板已在模块内提供原型一致的标题与导航,避免桌面端重复显示全局面包屑。
|
||||
const showDesktopBreadcrumb = activeModule !== 'hydrogen';
|
||||
const isHydrogenPrototype = activeModule === 'hydrogen';
|
||||
const watermarkText = useMemo(() => {
|
||||
const name = user?.userName || '未登录';
|
||||
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
|
||||
@@ -92,14 +95,16 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
return (
|
||||
<DemoModeProvider enabled={false}>
|
||||
<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="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>`)}")`,
|
||||
backgroundRepeat: 'repeat',
|
||||
}} />
|
||||
</div>
|
||||
{/* Web 侧边栏 (md 及以上) */}
|
||||
) : null}
|
||||
{/* 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} />
|
||||
@@ -118,7 +123,8 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
aria-expanded={isGroup ? isOpen : undefined}
|
||||
aria-haspopup={isGroup ? 'menu' : undefined}
|
||||
className={cn(
|
||||
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
|
||||
'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}
|
||||
@@ -157,15 +163,17 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
|
||||
{/* 内容区 */}
|
||||
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
|
||||
<div className="pointer-events-none fixed left-20 right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex">
|
||||
{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 />}
|
||||
</Suspense>
|
||||
<FeedbackFab module={activeModule} />
|
||||
{!isHydrogenPrototype ? <FeedbackFab module={activeModule} /> : null}
|
||||
</main>
|
||||
|
||||
{/* 移动端底部导航 (md 以下) */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export function PageFrame({
|
||||
children,
|
||||
maxWidth = 'max-w-6xl',
|
||||
compactInfo = false,
|
||||
hideHeader = false,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
@@ -25,13 +26,14 @@ export function PageFrame({
|
||||
children: ReactNode;
|
||||
maxWidth?: string;
|
||||
compactInfo?: boolean;
|
||||
hideHeader?: boolean;
|
||||
}) {
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--app-bg)] text-slate-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||
<div className={cn(maxWidth, 'mx-auto flex flex-col gap-4 pb-20 md:pb-6')}>
|
||||
<motion.header
|
||||
{!hideHeader ? <motion.header
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28, ease: 'easeOut' }}
|
||||
@@ -106,7 +108,7 @@ export function PageFrame({
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
</motion.header>
|
||||
</motion.header> : null}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,7 +122,7 @@ export function SurfaceCard({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title?: string;
|
||||
title?: ReactNode;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
@@ -149,6 +151,8 @@ export function MetricTile({
|
||||
helper,
|
||||
icon: Icon,
|
||||
tone = 'blue',
|
||||
className,
|
||||
compact = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
@@ -156,6 +160,8 @@ export function MetricTile({
|
||||
helper?: string;
|
||||
icon?: SurfaceIcon;
|
||||
tone?: 'blue' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const toneClass = {
|
||||
blue: 'bg-blue-50 text-blue-600 ring-blue-100',
|
||||
@@ -166,22 +172,29 @@ export function MetricTile({
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md">
|
||||
<div className={cn(
|
||||
'rounded-2xl border border-slate-100 bg-white shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md',
|
||||
compact ? 'p-3' : 'p-4',
|
||||
className,
|
||||
)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 text-[11px] font-black text-slate-400">{label}</div>
|
||||
{Icon ? (
|
||||
<span className={cn('inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1', toneClass)}>
|
||||
<Icon size={18} />
|
||||
<span className={cn('inline-flex shrink-0 items-center justify-center rounded-xl ring-1', compact ? 'h-8 w-8' : 'h-9 w-9', toneClass)}>
|
||||
<Icon size={compact ? 16 : 18} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex min-w-0 items-end gap-1">
|
||||
<span className="min-w-0 whitespace-nowrap text-[clamp(1.65rem,6vw,2rem)] font-black leading-none tracking-tight text-slate-950 tabular-nums">
|
||||
<div className={cn('flex min-w-0 items-end gap-1', compact ? 'mt-2' : 'mt-3')}>
|
||||
<span className={cn(
|
||||
'min-w-0 whitespace-nowrap font-black leading-none tracking-tight text-slate-950 tabular-nums',
|
||||
compact ? 'text-[clamp(1.45rem,6vw,1.7rem)]' : 'text-[clamp(1.65rem,6vw,2rem)]',
|
||||
)}>
|
||||
{value}
|
||||
</span>
|
||||
{unit ? <span className="shrink-0 pb-0.5 text-[11px] font-black text-slate-400">{unit}</span> : null}
|
||||
</div>
|
||||
{helper ? <div className="mt-3 text-[11px] font-bold leading-relaxed text-slate-500">{helper}</div> : null}
|
||||
{helper ? <div className={cn('font-bold leading-relaxed text-slate-500', compact ? 'mt-2 text-[10px]' : 'mt-3 text-[11px]')}>{helper}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+155
-1693
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -1,64 +1,28 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Upload, FileSpreadsheet, RotateCcw, CheckCircle2, AlertCircle,
|
||||
Truck, ExternalLink, Layers, Zap, ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { ArrowLeft, Upload, Zap } from 'lucide-react';
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import { useAuth } from '../../auth/useAuth';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
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 {
|
||||
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> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
@@ -83,7 +47,7 @@ export default function EleImportPage() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [overall, setOverall] = useState<OverallRow[]>([]);
|
||||
const [batches, setBatches] = useState<BatchRow[]>([]);
|
||||
const [filter, setFilter] = useState<'' | 'internal' | 'external'>('');
|
||||
const [filter, setFilter] = useState<VehicleKindFilter>('');
|
||||
const [batchFilter, setBatchFilter] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
@@ -110,13 +74,13 @@ export default function EleImportPage() {
|
||||
reload().catch(e => console.error(e));
|
||||
}, [reload]);
|
||||
|
||||
const handleUpload = async (f: File) => {
|
||||
const handleUpload = async (selectedFile: File) => {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await uploadFile(f);
|
||||
setResult(r);
|
||||
const nextResult = await uploadFile(selectedFile);
|
||||
setResult(nextResult);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -125,16 +89,11 @@ export default function EleImportPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onPick = (f: File | null) => {
|
||||
setFile(f);
|
||||
if (f) handleUpload(f);
|
||||
const onPick = (selectedFile: File | null) => {
|
||||
setFile(selectedFile);
|
||||
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 (
|
||||
<PageFrame
|
||||
title="充电记录导入"
|
||||
@@ -164,203 +123,49 @@ export default function EleImportPage() {
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
|
||||
{/* 上传区 */}
|
||||
<section
|
||||
<UploadDropzone
|
||||
inputRef={inputRef}
|
||||
file={file}
|
||||
uploading={uploading}
|
||||
dragOver={dragOver}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) onPick(f);
|
||||
const droppedFile = e.dataTransfer.files?.[0];
|
||||
if (droppedFile) onPick(droppedFile);
|
||||
}}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
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={(e) => onPick(e.target.files?.[0] || null)}
|
||||
onInputChange={(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>
|
||||
|
||||
{/* 上传结果提示 */}
|
||||
<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={() => 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"
|
||||
<UploadFeedback
|
||||
result={result}
|
||||
error={error}
|
||||
onCloseResult={() => setResult(null)}
|
||||
onCloseError={() => setError(null)}
|
||||
/>
|
||||
<div className="flex gap-1 bg-white p-0.5 rounded-lg border border-slate-200">
|
||||
{([['', '全部'], ['internal', '内部'], ['external', '外部']] as const).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setFilter(k as typeof filter)}
|
||||
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'}`}
|
||||
>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<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(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>
|
||||
|
||||
<SummaryKpis overall={overall} />
|
||||
|
||||
<BatchTable
|
||||
batches={batches}
|
||||
batchFilter={batchFilter}
|
||||
onClearFilter={() => setBatchFilter('')}
|
||||
onSelectBatch={(batchId) => setBatchFilter(batchFilter === batchId ? '' : batchId)}
|
||||
/>
|
||||
|
||||
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
<RecordFilters
|
||||
total={total}
|
||||
searchInput={searchInput}
|
||||
filter={filter}
|
||||
onSearchInputChange={(e) => setSearchInput(e.target.value)}
|
||||
onSearchKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
|
||||
onFilterChange={setFilter}
|
||||
/>
|
||||
<RecordTable items={items} />
|
||||
</section>
|
||||
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
@@ -368,29 +173,3 @@ export default function EleImportPage() {
|
||||
</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 { Construction, Hammer } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BadgeCheck, CarFront, FileText, ReceiptText, WalletCards } from 'lucide-react';
|
||||
import { fetchEtcOverview, type EtcOverviewResponse } from './api';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
const ETC_HINTS = [
|
||||
'ETC 通行费数据正在与发卡方系统打通…',
|
||||
'工人 GG 正在搭脚手架,敬请期待 ~',
|
||||
'马上能看到每月通行费明细啦',
|
||||
'想看哪个维度的 ETC?反馈一下嘛',
|
||||
'上线时机:等数据接通的那一天',
|
||||
];
|
||||
function formatYuan(value: number) {
|
||||
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
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 className="flex flex-col gap-3">
|
||||
<EmptyState title="暂无 ETC 通行台账" description="当前账单和通行明细均未入库,接入后会自动展示统计数据。" />
|
||||
<RotatingFooterHint hints={['ETC 台账状态已按数据库实时核对', '待通行明细入库后自动生成统计']} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="text-base font-black text-slate-800 mb-1.5">ETC 模块建设中</div>
|
||||
<div className="text-[12px] text-slate-500 font-bold leading-relaxed max-w-[280px]">
|
||||
通行费明细、按车按月统计、运营成本拆分
|
||||
<br />
|
||||
这些数据都在路上啦
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<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 className="mt-6 w-full max-w-xs space-y-2">
|
||||
{[
|
||||
{ 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>
|
||||
))}
|
||||
<SurfaceCard className="p-4">
|
||||
<div className="flex items-start gap-2 text-xs font-bold text-slate-500">
|
||||
<BadgeCheck size={17} className="mt-0.5 shrink-0 text-emerald-500" />
|
||||
<div>当前统计直接读取 ETC 通行明细与账单台账;金额、应收和已收均不做推算。</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<RotatingFooterHint hints={ETC_HINTS} />
|
||||
</SurfaceCard>
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +1,19 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Truck, Wallet } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { BatteryCharging, CalendarDays, ChevronRight, TrendingUp, Wallet } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchElectricMonthly } from './api';
|
||||
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
{ id: 'thisMonth', label: '本月' },
|
||||
{ id: 'last15', label: '近 15 天' },
|
||||
];
|
||||
|
||||
type RangeMode = DateQuickPick | 'custom';
|
||||
|
||||
function fmtYmd(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function addDays(d: Date, days: number): Date {
|
||||
const next = new Date(d);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function getQuickRange(pick: DateQuickPick): { start: string; end: string } {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (pick === 'thisWeek') {
|
||||
const day = today.getDay() || 7;
|
||||
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
|
||||
}
|
||||
if (pick === 'thisMonth') {
|
||||
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
|
||||
}
|
||||
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
|
||||
}
|
||||
|
||||
function normalizeRange(start: string, end: string): { start: string; end: string } {
|
||||
return start <= end ? { start, end } : { start: end, end: start };
|
||||
}
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile } from '../../components/ui/surface';
|
||||
import DailyRangeControls from './daily-range/DailyRangeControls';
|
||||
import {
|
||||
getQuickRange,
|
||||
getRangeModeLabel,
|
||||
normalizeRange,
|
||||
type RangeMode,
|
||||
} from './daily-range/model';
|
||||
import { summarizeElectricMonths } from './electric-daily/model';
|
||||
|
||||
export default function ElectricDaily() {
|
||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||
@@ -75,18 +48,17 @@ export default function ElectricDaily() {
|
||||
return next;
|
||||
});
|
||||
|
||||
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
|
||||
const totalFee = useMemo(() => (months ?? []).reduce((s, m) => s + (m.fee || 0), 0), [months]);
|
||||
const activeDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => r.kwh > 0).length, 0), [months]);
|
||||
const abnormalDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => Math.abs(r.chainPct) >= 0.3).length, 0), [months]);
|
||||
const avgKwh = activeDays > 0 ? totalKwh / activeDays : 0;
|
||||
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 0;
|
||||
const scopeLabel = pick === 'custom'
|
||||
? '自定义区间'
|
||||
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
|
||||
const {
|
||||
totalKwh,
|
||||
totalFee,
|
||||
activeDays,
|
||||
abnormalDays,
|
||||
avgKwh,
|
||||
avgPrice,
|
||||
hasFeeDetail,
|
||||
} = useMemo(() => summarizeElectricMonths(months), [months]);
|
||||
const scopeLabel = getRangeModeLabel(pick);
|
||||
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||
const hasFeeDetail = totalFee > 0;
|
||||
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
||||
|
||||
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||
setPick(nextPick);
|
||||
@@ -101,108 +73,30 @@ export default function ElectricDaily() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SurfaceCard className="p-2 md:p-3">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{QUICK_PICK_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => applyQuickPick(opt.id)}
|
||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||
pick === opt.id
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPick('custom')}
|
||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||
pick === 'custom'
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.start}
|
||||
onChange={e => updateDateRange('start', e.target.value)}
|
||||
onInput={e => updateDateRange('start', e.currentTarget.value)}
|
||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||
<DailyRangeControls
|
||||
pick={pick}
|
||||
dateRange={dateRange}
|
||||
customer={customer}
|
||||
onQuickPick={applyQuickPick}
|
||||
onCustomPick={() => setPick('custom')}
|
||||
onDateRangeChange={updateDateRange}
|
||||
onCustomerChange={setCustomer}
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<span className="block text-[10px] font-black text-slate-400">结束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.end}
|
||||
onChange={e => updateDateRange('end', e.target.value)}
|
||||
onInput={e => updateDateRange('end', e.currentTarget.value)}
|
||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||
{(['lingniu', 'external'] as const).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCustomer(c)}
|
||||
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
|
||||
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Truck size={14} />
|
||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={BatteryCharging} label={`${scopeLabel}充电量`} value={totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={rangeText} />
|
||||
<MetricTile
|
||||
icon={Wallet}
|
||||
label="充电费用"
|
||||
value={hasFeeDetail ? `¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '待同步'}
|
||||
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前明细仅返回充电量'}
|
||||
value={`¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||||
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前范围未记录费用'}
|
||||
tone={hasFeeDetail ? 'emerald' : 'slate'}
|
||||
/>
|
||||
<MetricTile icon={CalendarDays} label="有效天数" value={`${activeDays}`} unit="天" helper={`日均 ${avgKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} tone="amber" />
|
||||
<MetricTile icon={TrendingUp} label="波动提醒" value={abnormalDays} unit="天" helper="环比超过 30% 标记" tone={abnormalDays > 0 ? 'rose' : 'slate'} />
|
||||
</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="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>
|
||||
@@ -268,7 +162,6 @@ export default function ElectricDaily() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useHashSubTab } from './useHashSubTab';
|
||||
import { FadeIn, PageFrame } from '../../components/ui/surface';
|
||||
|
||||
const SUB_TABS = [
|
||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||
{ id: 'daily', label: '按日', icon: CalendarDays },
|
||||
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
||||
] as const satisfies readonly { id: ElectricSubTab; label: string; icon: typeof CalendarDays }[];
|
||||
|
||||
@@ -16,11 +16,11 @@ export default function ElectricModule() {
|
||||
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
|
||||
return (
|
||||
<PageFrame
|
||||
title="电能成本看板"
|
||||
subtitle="围绕充电量、费用、日趋势和车辆归属展示电能支出结构,辅助识别费用波动。"
|
||||
title="电能经营看板"
|
||||
subtitle="充电量、费用、日趋势与车辆归属"
|
||||
icon={CalendarDays}
|
||||
eyebrow="ELECTRIC BI"
|
||||
meta="时间单位清晰标注 · 支持日/总览切换"
|
||||
meta="按日 · 总览"
|
||||
>
|
||||
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
@@ -34,28 +34,32 @@ export default function ElectricOverview() {
|
||||
const trendData = data.trend;
|
||||
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
|
||||
const trendMonthLabel = trendData[0]?.date.slice(0, 7);
|
||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
|
||||
? `${trendMonthLabel} 每日充电`
|
||||
: '本月每日充电';
|
||||
const now = new Date();
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
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 = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 0;
|
||||
const monthPrice = displayedMonthKwh > 0 ? displayedMonthFee / displayedMonthKwh : 0;
|
||||
const dataAsOf = data.latestChargeTime ?? '暂无充电记录';
|
||||
|
||||
return (
|
||||
<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">
|
||||
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
||||
<div className="rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[11px] font-bold text-slate-400">
|
||||
充电账本最新记录:{dataAsOf} · {dataIsCurrentMonth ? '本月数据' : `${trendMonthLabel ?? '暂无'} 数据`}
|
||||
</div>
|
||||
{/* 横向 mini KPI 头 */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} />
|
||||
<MetricTile icon={CalendarClock} label="本月充电费" value={fmtYuan(k.monthFee)} helper={fmtKwh(k.monthKwh)} tone="emerald" />
|
||||
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
|
||||
<MetricTile icon={BatteryCharging} label="今日充电" value={k.todayKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={`${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
|
||||
<MetricTile icon={CalendarClock} label={dataIsCurrentMonth ? '本月充电费' : '数据最新月费用'} value={fmtYuan(displayedMonthFee)} helper={fmtKwh(displayedMonthKwh)} tone="emerald" />
|
||||
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`${dataIsCurrentMonth ? '本月' : '最新月'} ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
@@ -71,8 +75,8 @@ export default function ElectricOverview() {
|
||||
</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 ? (k.monthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
|
||||
<div className="mt-1 text-[11px] font-bold text-slate-500">本月费用 / 累计费用</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>
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ export default function EtcModule() {
|
||||
return (
|
||||
<PageFrame
|
||||
title="ETC 通行费看板"
|
||||
subtitle="规划按车、按月、按线路拆分通行费,让车辆运营成本口径逐步完整。"
|
||||
subtitle="通行明细、账单与回款状态"
|
||||
icon={Receipt}
|
||||
eyebrow="ETC BI"
|
||||
meta="数据对接中 · 页面能力预留"
|
||||
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,368 +1,304 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchHydrogenDaily } from './api';
|
||||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { fetchHydrogenDaily, fetchHydrogenDailyDetail, type HydrogenVerifyScope } from './api';
|
||||
import type { CustomerType, DateQuickPick, HydrogenDailyDetailResponse, HydrogenDailyRow } from './types';
|
||||
import {
|
||||
buildHydrogenDailyTrend,
|
||||
filterHydrogenRowsByStation,
|
||||
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 }> = [
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
{ id: 'thisMonth', label: '本月' },
|
||||
{ id: 'last15', label: '近 15 天' },
|
||||
];
|
||||
|
||||
type RangeMode = DateQuickPick | 'custom';
|
||||
|
||||
function fmtYmd(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
export interface HydrogenDailyProps {
|
||||
scope?: HydrogenDailyBoardScope;
|
||||
onRangeTextChange?: (rangeText: string) => void;
|
||||
}
|
||||
|
||||
function addDays(d: Date, days: number): Date {
|
||||
const next = new Date(d);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
interface DailyDatasets {
|
||||
lingniu: HydrogenDailyRow[] | null;
|
||||
external: HydrogenDailyRow[] | null;
|
||||
}
|
||||
|
||||
function getQuickRange(pick: DateQuickPick): { start: string; end: string } {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (pick === 'thisWeek') {
|
||||
const day = today.getDay() || 7;
|
||||
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
|
||||
}
|
||||
if (pick === 'thisMonth') {
|
||||
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
|
||||
}
|
||||
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
|
||||
export interface DailyDetailState {
|
||||
loading: boolean;
|
||||
data: HydrogenDailyDetailResponse | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function normalizeRange(start: string, end: string): { start: string; end: string } {
|
||||
return start <= end ? { start, end } : { start: end, end: start };
|
||||
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() {
|
||||
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 [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
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 [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]);
|
||||
const effectiveRange = useMemo(
|
||||
() => normalizeRange(dateRange.start, dateRange.end),
|
||||
[dateRange.start, dateRange.end],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onRangeTextChange?.(`${effectiveRange.start} 至 ${effectiveRange.end}`);
|
||||
}, [effectiveRange.start, effectiveRange.end, onRangeTextChange]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const query = pick === 'custom'
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||
: { range: pick };
|
||||
fetchHydrogenDaily(query, customer)
|
||||
.then(r => { if (!cancelled) setRows(r); })
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
|
||||
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; };
|
||||
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
|
||||
}, [pick, effectiveRange.start, effectiveRange.end, refreshKey]);
|
||||
|
||||
// 柱图:按日期升序,用于"从左到右时间流"
|
||||
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
|
||||
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
|
||||
const activeDays = (rows ?? []).filter(r => r.totalKg > 0).length;
|
||||
const stationCount = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
(rows ?? []).forEach(r => r.stations.forEach(s => names.add(s.name)));
|
||||
return names.size;
|
||||
}, [rows]);
|
||||
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
|
||||
const scopeLabel = pick === 'custom'
|
||||
? '自定义区间'
|
||||
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
|
||||
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||
const peakDay = trendData.reduce<HydrogenDailyRow | null>((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null);
|
||||
const lowDay = trendData
|
||||
.filter(item => item.totalKg > 0)
|
||||
.reduce<HydrogenDailyRow | null>((low, item) => (!low || item.totalKg < low.totalKg ? item : low), null);
|
||||
const zeroDays = (rows ?? []).filter(r => r.totalKg === 0).length;
|
||||
const allRows = useMemo(
|
||||
() => mergeHydrogenDailyRows(datasets.lingniu, datasets.external),
|
||||
[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 => {
|
||||
const next = new Set(prev);
|
||||
next.has(date) ? next.delete(date) : next.add(date);
|
||||
useEffect(() => {
|
||||
if (scope !== 'station') return;
|
||||
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;
|
||||
});
|
||||
|
||||
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 }));
|
||||
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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SurfaceCard className="p-2 md:p-3">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{QUICK_PICK_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => applyQuickPick(opt.id)}
|
||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||
pick === opt.id
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPick('custom')}
|
||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||
pick === 'custom'
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.start}
|
||||
onChange={e => updateDateRange('start', e.target.value)}
|
||||
onInput={e => updateDateRange('start', e.currentTarget.value)}
|
||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||
<DailyRangeControls
|
||||
pick={pick}
|
||||
dateRange={dateRange}
|
||||
customer={vehicleScope === 'all' ? 'all' : vehicleScope === 'lingniu' ? 'lingniu' : 'external'}
|
||||
stations={scope === 'station' ? stationOptions : undefined}
|
||||
selectedStationId={scope === 'station' ? selectedStationId : undefined}
|
||||
vehicleScope={vehicleScope}
|
||||
updatedAt={updatedAt}
|
||||
loading={loading}
|
||||
onQuickPick={(p: DateQuickPick) => {
|
||||
setPick(p);
|
||||
setDateRange(getQuickRange(p));
|
||||
}}
|
||||
onCustomPick={() => setPick('custom')}
|
||||
onDateRangeChange={(field, value) => {
|
||||
setPick('custom');
|
||||
setDateRange((prev) => ({ ...prev, [field]: value }));
|
||||
}}
|
||||
onCustomerChange={(cust: CustomerType) => {
|
||||
setVehicleScope(cust === 'all' ? 'all' : cust === 'lingniu' ? 'lingniu' : 'external');
|
||||
}}
|
||||
onStationChange={scope === 'station' ? setSelectedStationId : undefined}
|
||||
onVehicleScopeChange={setVehicleScope}
|
||||
onRefresh={() => setRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<span className="block text-[10px] font-black text-slate-400">结束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.end}
|
||||
onChange={e => updateDateRange('end', e.target.value)}
|
||||
onInput={e => updateDateRange('end', e.currentTarget.value)}
|
||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||
{(['lingniu', 'external'] as const).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCustomer(c)}
|
||||
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
|
||||
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Truck size={14} />
|
||||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
|
||||
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
|
||||
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
|
||||
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
|
||||
</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 && (
|
||||
<SurfaceCard>
|
||||
<div className="flex items-center justify-between px-4 pt-4 mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">时间单位:日 · 单位 Kg</span>
|
||||
</div>
|
||||
<div className="mx-4 mb-2 grid grid-cols-3 gap-2 rounded-xl bg-slate-50 p-2">
|
||||
<div>
|
||||
<div className="text-[10px] font-black text-slate-400">峰值日</div>
|
||||
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
|
||||
{peakDay ? `${peakDay.date.slice(5)} · ${peakDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black text-slate-400">低谷日</div>
|
||||
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
|
||||
{lowDay ? `${lowDay.date.slice(5)} · ${lowDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black text-slate-400">零数据日</div>
|
||||
<div className={`mt-0.5 text-[11px] font-black ${zeroDays > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
|
||||
{zeroDays} 天
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[180px] min-w-0 px-2 pb-2">
|
||||
<ResponsiveContainer width="100%" height={180} minWidth={0}>
|
||||
<BarChart data={trendData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
minTickGap={8}
|
||||
/>
|
||||
<YAxis
|
||||
width={42}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 9, fill: '#94a3b8' }}
|
||||
tickFormatter={(v: number) => v >= 1000 ? `${Math.round(v / 1000)}k` : `${Math.round(v)}`}
|
||||
/>
|
||||
<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)' }}
|
||||
/>
|
||||
{avgKg > 0 && (
|
||||
<ReferenceLine
|
||||
y={avgKg}
|
||||
stroke="#f59e0b"
|
||||
strokeDasharray="4 4"
|
||||
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
</SurfaceCard>
|
||||
)}
|
||||
|
||||
{/* 表格(外部车辆 + 全 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 ? (
|
||||
<div className="p-3"><ErrorState message={error} /></div>
|
||||
) : rows === null ? (
|
||||
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></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}`}>
|
||||
<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">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle size={14} className="text-amber-600" />
|
||||
<span>最新数据刷新失败,正展示上一版缓存内容:{error}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggle(r.date)}
|
||||
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"
|
||||
type="button"
|
||||
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">
|
||||
<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>
|
||||
<RefreshCw size={12} /> 重试
|
||||
</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>
|
||||
) : 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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,9 @@
|
||||
import { LayoutDashboard, CalendarDays } from 'lucide-react';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
||||
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: HydrogenSubTab; label: string; icon: typeof CalendarDays }[];
|
||||
|
||||
const SUB_IDS: readonly HydrogenSubTab[] = ['daily', 'overview'];
|
||||
import { EnergyBiBoardApp } from './hydrogen-bi-v2/PrototypeBoard';
|
||||
|
||||
/**
|
||||
* New standalone Hydrogen BI surface. The legacy feature files remain for a
|
||||
* controlled rollback, but the live hydrogen route now uses only v2.
|
||||
*/
|
||||
export default function HydrogenModule() {
|
||||
const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS);
|
||||
return (
|
||||
<PageFrame
|
||||
title="氢能经营看板"
|
||||
subtitle="按时间、车辆归属、加氢站和区域统一展示加氢量、费用、收入与异常波动。"
|
||||
icon={CalendarDays}
|
||||
eyebrow="ENERGY BI"
|
||||
meta="数据单位清晰标注 · 支持日/总览切换"
|
||||
>
|
||||
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||
<AnimatePresence mode="wait">
|
||||
<FadeIn key={sub}>
|
||||
<HydrogenView sub={sub} />
|
||||
</FadeIn>
|
||||
</AnimatePresence>
|
||||
</PageFrame>
|
||||
);
|
||||
return <EnergyBiBoardApp />;
|
||||
}
|
||||
|
||||
@@ -1,714 +1,282 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
||||
} from 'recharts';
|
||||
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw, Gauge, AlertTriangle, Building2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
fetchHydrogenOverview,
|
||||
fetchHydrogenOverviewDetail,
|
||||
type HydrogenOverviewResponse,
|
||||
type HydrogenVehicleScope,
|
||||
type HydrogenVerifyScope,
|
||||
} 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 = [
|
||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
||||
'#94a3b8',
|
||||
];
|
||||
|
||||
interface YAxisTickProps {
|
||||
x?: number;
|
||||
y?: number;
|
||||
index?: number;
|
||||
payload?: { value: string };
|
||||
export interface HydrogenOverviewProps {
|
||||
scope?: OverviewScope;
|
||||
selectedStationId?: number | null;
|
||||
onScopeChange?: (scope: HydrogenBoardScope) => void;
|
||||
onSubChange?: (sub: 'daily' | 'overview' | 'settlement') => void;
|
||||
onSelectStation?: (stationId: number | null) => void;
|
||||
onOpenDaily?: () => void;
|
||||
onRangeTextChange?: (rangeText: string) => void;
|
||||
}
|
||||
|
||||
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
|
||||
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
||||
{index + 1}
|
||||
</text>
|
||||
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
||||
{payload?.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
const DEFAULT_AVAILABLE_YEARS = [2026, 2025, 2024, 2023, 2022, 2021, 2020];
|
||||
|
||||
interface DrillSelection {
|
||||
stationId?: number | null;
|
||||
customerId?: number | null;
|
||||
customerName?: string | null;
|
||||
plateNo?: string | null;
|
||||
region?: string | null;
|
||||
month?: string | null;
|
||||
date?: string | null;
|
||||
}
|
||||
|
||||
// ---------- 数字格式化 ----------
|
||||
function fmtKg(kg: number): { value: string; unit: string } {
|
||||
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
|
||||
return { value: kg.toFixed(2), unit: 'Kg' };
|
||||
}
|
||||
function fmtYuan(yuan: number): { value: string; unit: string } {
|
||||
const abs = Math.abs(yuan);
|
||||
if (abs >= 100_000_000) return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
|
||||
if (abs >= 10_000) {
|
||||
const w = yuan / 10_000;
|
||||
return { value: w.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), unit: '万元' };
|
||||
}
|
||||
return { value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), unit: '元' };
|
||||
}
|
||||
|
||||
// ---------- KPI 卡 ----------
|
||||
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() {
|
||||
export default function HydrogenOverview({
|
||||
scope = 'global',
|
||||
selectedStationId = null,
|
||||
onScopeChange,
|
||||
onSubChange,
|
||||
onSelectStation,
|
||||
onOpenDaily,
|
||||
onRangeTextChange,
|
||||
}: HydrogenOverviewProps) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [activeYear, setActiveYear] = useState<number>(() => {
|
||||
return DEFAULT_AVAILABLE_YEARS.includes(currentYear) ? currentYear : 2026;
|
||||
});
|
||||
const [vehicleScope, setVehicleScope] = useState<HydrogenVehicleScope>('all');
|
||||
const [verifyScope, setVerifyScope] = useState<HydrogenVerifyScope>('all');
|
||||
const [internalStationId, setInternalStationId] = useState<number | null>(selectedStationId);
|
||||
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [year, setYear] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
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 seq = ++refreshSeq.current;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const d = await fetchHydrogenOverview(selectedYear ?? undefined, force);
|
||||
if (seq !== refreshSeq.current) return; // outdated
|
||||
setData(d);
|
||||
const [drillTree, setDrillTree] = useState<{
|
||||
title: string;
|
||||
selection: DrillSelection;
|
||||
} | null>(null);
|
||||
|
||||
const effectiveStationId = scope === 'station' ? (internalStationId ?? selectedStationId) : null;
|
||||
|
||||
const load = useCallback(async (force = false) => {
|
||||
if (force) setRefreshing(true);
|
||||
else setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await fetchHydrogenOverview({
|
||||
year: activeYear,
|
||||
vehicleScope,
|
||||
verifyScope,
|
||||
stationId: effectiveStationId,
|
||||
force,
|
||||
});
|
||||
setData(result);
|
||||
setLastRefreshAt(Date.now());
|
||||
} catch (e) {
|
||||
if (seq !== refreshSeq.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
const rangeEnd = result.latestLedgerTime?.slice(0, 10) ?? (activeYear === currentYear ? '至今' : `${activeYear}-12-31`);
|
||||
onRangeTextChange?.(`${activeYear}-01-01 至 ${rangeEnd}`);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
} 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(() => {
|
||||
const t = setInterval(() => { void load(year, false); }, 60_000);
|
||||
return () => clearInterval(t);
|
||||
}, [year, load]);
|
||||
void load(false);
|
||||
}, [load]);
|
||||
|
||||
if (error && !data) {
|
||||
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
||||
const handleDrillRequest = (request: OverviewDrillRequest) => {
|
||||
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 />;
|
||||
}
|
||||
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 monthAvgKg = monthly.length > 0 ? monthly.reduce((sum, m) => sum + m.kg, 0) / monthly.length : 0;
|
||||
const bestMonth = monthly.reduce<typeof monthly[number] | null>((best, item) => (!best || item.kg > best.kg ? item : best), null);
|
||||
const latestMonth = monthly[monthly.length - 1];
|
||||
const prevMonth = monthly[monthly.length - 2];
|
||||
const monthMomentum = latestMonth && prevMonth && prevMonth.kg > 0 ? (latestMonth.kg - prevMonth.kg) / prevMonth.kg * 100 : null;
|
||||
const top5Share = top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, k.yearKg) * 100;
|
||||
const profitYield = k.yearRevenue > 0 ? k.yearProfit / k.yearRevenue * 100 : 0;
|
||||
const stationAvgKg = stations.length > 0 ? k.yearKg / stations.length : 0;
|
||||
|
||||
// 月度收支组合数据(推算"年内每月"图)
|
||||
const monthlyDual = monthly.map(m => ({
|
||||
...m,
|
||||
monthLabel: m.month.slice(5).replace(/^0/, '') + '月',
|
||||
}));
|
||||
|
||||
if (error && !data) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 relative">
|
||||
{/* 顶部说明条 + 年份切换 + 刷新按钮 */}
|
||||
<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">
|
||||
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRefreshTime(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 (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-6 text-sm text-red-700">
|
||||
<div className="font-bold">氢能总览数据加载失败</div>
|
||||
<p className="mt-1 text-xs text-red-600">{error}</p>
|
||||
<button
|
||||
key={y}
|
||||
onClick={() => setYear(y)}
|
||||
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
|
||||
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => void load(true)}
|
||||
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"
|
||||
>
|
||||
{y}
|
||||
重新加载
|
||||
</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 卡 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||||
<KpiCard
|
||||
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>
|
||||
const selectedStationName = data?.stations.find((s) => s.id === effectiveStationId)?.name ?? null;
|
||||
const yearProfitFmt = data ? formatYuan(data.kpi.yearProfit) : { value: '0', unit: '元' };
|
||||
const yearRevenueFmt = data ? formatYuan(data.kpi.yearRevenue) : { value: '0', unit: '元' };
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 md:gap-3">
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">月度动能</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">
|
||||
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
|
||||
</div>
|
||||
</div>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
|
||||
<Gauge size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
|
||||
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
|
||||
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">站点集中度</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">Top5 {top5Share.toFixed(1)}%</div>
|
||||
</div>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-cyan-50 text-cyan-600 ring-1 ring-cyan-100">
|
||||
<Building2 size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
共 {stations.length} 站 · 单站年均 {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit}
|
||||
{top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">收支健康度</div>
|
||||
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{profitYield.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<span className={`flex h-9 w-9 items-center justify-center rounded-xl ring-1 ${profitYield >= 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}>
|
||||
<AlertTriangle size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
时享获利 {yearProfitFmt.value}{yearProfitFmt.unit} · 客户收入 {yearRevenueFmt.value}{yearRevenueFmt.unit}
|
||||
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<OverviewHeader
|
||||
activeYear={activeYear}
|
||||
availableYears={DEFAULT_AVAILABLE_YEARS}
|
||||
vehicleScope={vehicleScope}
|
||||
verifyScope={verifyScope}
|
||||
latestLedgerTime={data?.latestLedgerTime ?? null}
|
||||
lastRefreshAt={lastRefreshAt}
|
||||
refreshing={refreshing}
|
||||
onSelectYear={(y) => setActiveYear(y)}
|
||||
onVehicleScopeChange={(vs) => setVehicleScope(vs)}
|
||||
onVerifyScopeChange={setVerifyScope}
|
||||
onRefresh={() => void load(true)}
|
||||
/>
|
||||
|
||||
{/* 月度趋势:年内每月加氢量 */}
|
||||
{monthly.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">{activeYear} 年月度加氢量</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<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}
|
||||
{data && derived ? (
|
||||
<section className="ehb-host" aria-label="经营总览">
|
||||
<KpiSection
|
||||
kpi={data.kpi}
|
||||
scope={scope}
|
||||
scopeLabel={selectedStationName}
|
||||
onDrillRequest={handleDrillRequest}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 月度收支对比 */}
|
||||
{monthly.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">{activeYear} 年月度收支对比</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
||||
</div>
|
||||
<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
|
||||
verticalAlign="top"
|
||||
height={20}
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v, name) => {
|
||||
const f = fmtYuan(Number(v ?? 0));
|
||||
return [`¥${f.value} ${f.unit}`, name];
|
||||
<InsightCards
|
||||
monthAvgKg={derived.monthAvgKg}
|
||||
bestMonth={derived.bestMonth}
|
||||
latestMonth={derived.latestMonth}
|
||||
monthMomentum={derived.monthMomentum}
|
||||
top5Share={derived.top5Share}
|
||||
customerGrossMarginPct={derived.customerGrossMarginPct}
|
||||
stationAvgKg={derived.stationAvgKg}
|
||||
stationCount={data.stations.length}
|
||||
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 }}
|
||||
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
|
||||
onDrillRequest={handleDrillRequest}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<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 + 区域占比 */}
|
||||
<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 formatRefreshTime(ts: number): string {
|
||||
const exactTime = new Date(ts).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
return `${formatRelative(ts)} · ${exactTime.replace(/\//g, '-')}`;
|
||||
}
|
||||
|
||||
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>
|
||||
<RefreshOverlay refreshing={refreshing} hasData={Boolean(data)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,544 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
const result = await fetchHydrogenStationBoard({
|
||||
startDate: dateRange.start,
|
||||
endDate: dateRange.end,
|
||||
stationId: selectedStationId,
|
||||
force,
|
||||
});
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (reason) {
|
||||
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 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 {
|
||||
sub: HydrogenSubTab;
|
||||
onSubChange?: (sub: HydrogenSubTab) => void;
|
||||
onScopeChange?: (scope: HydrogenBoardScope) => void;
|
||||
onDailyRangeTextChange?: (rangeText: string) => void;
|
||||
onOverviewRangeTextChange?: (rangeText: string) => void;
|
||||
}
|
||||
|
||||
export default function HydrogenView({ sub }: Props) {
|
||||
return sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily />;
|
||||
export default function HydrogenView({
|
||||
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,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',
|
||||
);
|
||||
});
|
||||
+146
-3
@@ -4,6 +4,10 @@ import type {
|
||||
HydrogenCustomerRow, HydrogenStationFull,
|
||||
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
|
||||
CustomerType, DateQuickPick,
|
||||
HydrogenStationBoardResponse,
|
||||
HydrogenDailyDetailResponse,
|
||||
HydrogenOverviewDetailGroupBy,
|
||||
HydrogenOverviewDetailResponse,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api/energy';
|
||||
@@ -17,20 +21,74 @@ export interface HydrogenOverviewResponse {
|
||||
stations: HydrogenStationFull[];
|
||||
availableYears: 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();
|
||||
if (year) params.set('year', String(year));
|
||||
if (force) params.set('force', '1');
|
||||
if (query.year) params.set('year', String(query.year));
|
||||
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();
|
||||
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
|
||||
}
|
||||
|
||||
export interface HydrogenOverviewDetailQuery {
|
||||
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[]> {
|
||||
@@ -38,12 +96,40 @@ export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: 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()}`);
|
||||
}
|
||||
|
||||
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 {
|
||||
kpi: ElectricKpi;
|
||||
trend: ElectricDailyRow[];
|
||||
latestChargeTime: string | null;
|
||||
}
|
||||
|
||||
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
||||
@@ -57,3 +143,60 @@ export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDail
|
||||
if (query.endDate) q.set('endDate', query.endDate);
|
||||
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,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
import { fetchJson } from '../../../auth/api-client';
|
||||
import type {
|
||||
H2BiDailyResponse,
|
||||
H2BiDailyTreeQuery,
|
||||
H2BiDailyTreeResponse,
|
||||
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 = {}) {
|
||||
const qs = queryString(query as Record<string, unknown>);
|
||||
return fetchJson<T>(`${BASE}/${path}${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function fetchH2BiDailyTree(date: string, query: H2BiDailyTreeQuery) {
|
||||
return request<H2BiDailyTreeResponse>('daily-tree', { date, ...query });
|
||||
}
|
||||
|
||||
export function fetchH2BiDrill(query: H2BiDrillQuery) {
|
||||
return request<H2BiDrillResponse>('drill', query);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
fetchH2BiDaily,
|
||||
fetchH2BiDailyTree,
|
||||
fetchH2BiDrill,
|
||||
fetchH2BiMeta,
|
||||
fetchH2BiOverview,
|
||||
} from "./api";
|
||||
import type { H2BiDailyTreeResponse, H2BiDrillQuery, H2BiQuery } from "./types";
|
||||
|
||||
/**
|
||||
* The only permitted bridge between the imported prototype DOM and live data.
|
||||
* It deliberately maps data, never presentation: the original component keeps
|
||||
* its markup, class names, ordering and interaction hierarchy unchanged.
|
||||
*/
|
||||
export async function loadPrototypeOverview(query: H2BiQuery) {
|
||||
const [meta, overview] = await Promise.all([
|
||||
fetchH2BiMeta(),
|
||||
fetchH2BiOverview(query),
|
||||
]);
|
||||
return {
|
||||
years: meta.years.map((item) => item.value),
|
||||
range: overview.range,
|
||||
watermark: overview.watermark,
|
||||
kpi: overview.kpis,
|
||||
monthlyQuantity: overview.monthly.map((item) => ({
|
||||
month: item.month,
|
||||
ownKg: item.lingniuKg,
|
||||
extKg: item.externalKg,
|
||||
totalKg: item.totalKg,
|
||||
})),
|
||||
monthlyRevenue: overview.monthly.map((item) => ({
|
||||
month: item.month,
|
||||
customerAmount: item.customerRevenue,
|
||||
// 收入仅来自客户承担订单;成本按账本 settlement_type 分成客户、
|
||||
// 我司、其他三段,三段之和严格等于当月总成本。
|
||||
cost: item.customerCost,
|
||||
customerCost: item.customerCost,
|
||||
companyCost: item.companyCost,
|
||||
otherCost: item.otherCost,
|
||||
})),
|
||||
topStations: overview.topStations.map((item, index) => ({
|
||||
rank: index + 1,
|
||||
...item,
|
||||
ownKg: item.lingniuKg,
|
||||
extKg: item.externalKg,
|
||||
})),
|
||||
regions: overview.regions,
|
||||
stations: overview.stations,
|
||||
customers: overview.customers,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadPrototypeDaily(query: H2BiQuery) {
|
||||
return fetchH2BiDaily(query);
|
||||
}
|
||||
|
||||
export async function loadPrototypeDailyTree(
|
||||
date: string,
|
||||
query: Pick<H2BiQuery, "stationId" | "vehicleScope" | "verifyScope">,
|
||||
): Promise<H2BiDailyTreeResponse> {
|
||||
return fetchH2BiDailyTree(date, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the prototype's station -> customer -> vehicle -> record hierarchy,
|
||||
* while delegating every aggregate and leaf value to the live drill endpoint.
|
||||
*/
|
||||
export async function loadPrototypeDrill(query: H2BiDrillQuery) {
|
||||
return fetchH2BiDrill(query);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export function downloadExcelAoa(
|
||||
rows: Array<Array<string | number | boolean | null | undefined>>,
|
||||
fileName: string,
|
||||
sheetName: string,
|
||||
) {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const sheet = XLSX.utils.aoa_to_sheet(rows);
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, sheetName.slice(0, 31));
|
||||
XLSX.writeFile(workbook, fileName);
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Download, RefreshCw, Truck } from "lucide-react";
|
||||
import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiDrill } from "./api";
|
||||
import { downloadExcelAoa } from "./prototype-download";
|
||||
import type {
|
||||
H2BiDailyResponse,
|
||||
H2BiDailyTreeResponse,
|
||||
H2BiDrillResponse,
|
||||
H2BiQuery,
|
||||
H2BiVehicleScope,
|
||||
} from "./types";
|
||||
|
||||
const format = (value: number, digits = 2) =>
|
||||
value.toLocaleString("zh-CN", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
const toScope = (scope: "all" | "own" | "external"): H2BiVehicleScope =>
|
||||
scope === "own" ? "lingniu" : scope;
|
||||
const sourceLabel = (source: unknown) => {
|
||||
const value = String(source || "").toLowerCase();
|
||||
if (value === "lingniu") return "羚牛上报";
|
||||
if (value === "api") return "API接入";
|
||||
if (value === "station") return "站点上报";
|
||||
return String(source || "未知来源");
|
||||
};
|
||||
/** Keep raw API statuses out of the UI. Missing/unknown statuses are unverified. */
|
||||
const verifyLabel = (status: unknown) => {
|
||||
const value = String(status ?? "").trim().toLowerCase();
|
||||
return value === "verified" || value === "pass" || value === "已验证"
|
||||
? "已验证"
|
||||
: "未验证";
|
||||
};
|
||||
|
||||
export function PrototypeRealDailyView({
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
fleetScope,
|
||||
onFleetScopeChange,
|
||||
verifyScope,
|
||||
onRefresh,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
onStartDateChange: (value: string) => void;
|
||||
onEndDateChange: (value: string) => void;
|
||||
fleetScope: "all" | "own" | "external";
|
||||
onFleetScopeChange: (value: "all" | "own" | "external") => void;
|
||||
verifyScope: "all" | "verified";
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const query = useMemo<H2BiQuery>(
|
||||
() => ({
|
||||
year: Number(startDate.slice(0, 4)),
|
||||
startDate,
|
||||
endDate,
|
||||
vehicleScope: toScope(fleetScope),
|
||||
verifyScope,
|
||||
}),
|
||||
[endDate, fleetScope, startDate, verifyScope],
|
||||
);
|
||||
const [daily, setDaily] = useState<H2BiDailyResponse | null>(null);
|
||||
const [trees, setTrees] = useState<Record<string, H2BiDailyTreeResponse>>({});
|
||||
const [expandedDate, setExpandedDate] = useState<string | null>(null);
|
||||
const [expandedStation, setExpandedStation] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [expandedCustomer, setExpandedCustomer] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [expandedStationLists, setExpandedStationLists] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [expandedCustomerLists, setExpandedCustomerLists] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [expandedRecordLists, setExpandedRecordLists] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [customerRecords, setCustomerRecords] = useState<
|
||||
Record<string, H2BiDrillResponse>
|
||||
>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [highlightedDate, setHighlightedDate] = useState<string | null>(null);
|
||||
const dateRowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setError(null);
|
||||
setTrees({});
|
||||
setExpandedDate(null);
|
||||
setExpandedStation({});
|
||||
setExpandedCustomer({});
|
||||
setExpandedStationLists({});
|
||||
setExpandedCustomerLists({});
|
||||
setExpandedRecordLists({});
|
||||
setCustomerRecords({});
|
||||
void fetchH2BiDaily(query)
|
||||
.then((result) => alive && setDaily(result))
|
||||
.catch(
|
||||
(reason: unknown) =>
|
||||
alive &&
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "按日数据加载失败",
|
||||
),
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [query]);
|
||||
const ensureDateTree = (date: string) => {
|
||||
if (trees[date]) return;
|
||||
void fetchH2BiDailyTree(date, {
|
||||
vehicleScope: query.vehicleScope,
|
||||
verifyScope,
|
||||
}).then((tree) => setTrees((items) => ({ ...items, [date]: tree })));
|
||||
};
|
||||
const openDate = (date: string, scrollIntoDate = false) => {
|
||||
setExpandedDate(date);
|
||||
ensureDateTree(date);
|
||||
if (!scrollIntoDate) return;
|
||||
setHighlightedDate(date);
|
||||
window.setTimeout(() => {
|
||||
dateRowRefs.current[date]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}, 40);
|
||||
window.setTimeout(() => {
|
||||
setHighlightedDate((current) => (current === date ? null : current));
|
||||
}, 2200);
|
||||
};
|
||||
const toggleDate = (date: string) => {
|
||||
if (expandedDate === date) {
|
||||
setExpandedDate(null);
|
||||
return;
|
||||
}
|
||||
openDate(date);
|
||||
};
|
||||
const toggleCustomer = (
|
||||
date: string,
|
||||
stationId: string | number,
|
||||
customerId: number,
|
||||
) => {
|
||||
const key = `${date}:${stationId}:${customerId}`;
|
||||
setExpandedCustomer((items) => ({ ...items, [key]: !items[key] }));
|
||||
if (customerRecords[key]) return;
|
||||
void fetchH2BiDrill({
|
||||
...query,
|
||||
date,
|
||||
stationId,
|
||||
customerId,
|
||||
groupBy: "record",
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
}).then((result) =>
|
||||
setCustomerRecords((items) => ({ ...items, [key]: result })),
|
||||
);
|
||||
};
|
||||
const exportRows = () => {
|
||||
const rows: Array<Array<string | number>> = [
|
||||
["日期", "加氢站", "客户", "加氢量(Kg)", "成本(元)", "流水笔数"],
|
||||
];
|
||||
Object.values(trees).forEach((tree) =>
|
||||
tree.stations.forEach((station) =>
|
||||
station.customers.forEach((customer) =>
|
||||
rows.push([
|
||||
tree.date,
|
||||
station.name,
|
||||
customer.name,
|
||||
customer.kg,
|
||||
customer.cost,
|
||||
customer.recordCount,
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
downloadExcelAoa(
|
||||
rows,
|
||||
`每日加氢数据明细_${startDate}_${endDate}.xlsx`,
|
||||
"每日加氢明细",
|
||||
);
|
||||
};
|
||||
const trend = daily?.trend ?? [];
|
||||
const maxKg = Math.max(...trend.map((row) => row.kg), 1);
|
||||
const averageKg = daily?.kpis.averageDailyKg ?? 0;
|
||||
const peak = trend.reduce(
|
||||
(best, row) => (row.kg > best.kg ? row : best),
|
||||
trend[0],
|
||||
);
|
||||
const trough = trend
|
||||
.filter((row) => row.kg > 0)
|
||||
.reduce(
|
||||
(best, row) => (!best || row.kg < best.kg ? row : best),
|
||||
undefined as (typeof trend)[number] | undefined,
|
||||
);
|
||||
return (
|
||||
<div className="ehb-daily-container">
|
||||
<section className="ehb-daily-filter-card">
|
||||
<div className="ehb-daily-filter-row">
|
||||
<div className="ehb-daily-filter-group">
|
||||
<div className="ehb-pill-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-pill-btn"
|
||||
onClick={() => {
|
||||
const today = endDate;
|
||||
onStartDateChange(today.slice(0, 8) + "01");
|
||||
}}
|
||||
>
|
||||
本月
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-pill-btn"
|
||||
onClick={() => {
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const begin = new Date(end);
|
||||
begin.setDate(end.getDate() - 14);
|
||||
onStartDateChange(begin.toISOString().slice(0, 10));
|
||||
}}
|
||||
>
|
||||
近15天
|
||||
</button>
|
||||
<button type="button" className="ehb-pill-btn is-active">
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="ehb-modal-select"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(event) => onStartDateChange(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="ehb-modal-select"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(event) => onEndDateChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ehb-daily-filter-group">
|
||||
<div className="ehb-fleet-segmented">
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-fleet-btn ${fleetScope === "all" ? "is-active" : ""}`}
|
||||
onClick={() => onFleetScopeChange("all")}
|
||||
>
|
||||
全部车辆
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-fleet-btn ${fleetScope === "own" ? "is-active" : ""}`}
|
||||
onClick={() => onFleetScopeChange("own")}
|
||||
>
|
||||
<Truck size={14} />
|
||||
仅羚牛车辆
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-fleet-btn ${fleetScope === "external" ? "is-active" : ""}`}
|
||||
onClick={() => onFleetScopeChange("external")}
|
||||
>
|
||||
<Truck size={14} />
|
||||
仅外部车辆
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-btn ehb-btn--ghost"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{error ? <div className="ehb-empty">{error}</div> : null}
|
||||
<section className="ehb-daily-kpi-grid">
|
||||
<div className="ehb-daily-kpi-card">
|
||||
<div className="ehb-daily-kpi-title">区间加氢量</div>
|
||||
<div className="ehb-daily-kpi-val">
|
||||
{format(daily?.kpis.totalKg ?? 0)} <small>Kg</small>
|
||||
</div>
|
||||
<div className="ehb-daily-kpi-sub">
|
||||
{startDate} 至 {endDate}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-daily-kpi-card">
|
||||
<div className="ehb-daily-kpi-title">区间成本</div>
|
||||
<div className="ehb-daily-kpi-val">
|
||||
¥{format(daily?.kpis.totalCost ?? 0)}
|
||||
</div>
|
||||
<div className="ehb-daily-kpi-sub">真实成本台账汇总</div>
|
||||
</div>
|
||||
<div className="ehb-daily-kpi-card">
|
||||
<div className="ehb-daily-kpi-title">有效天数</div>
|
||||
<div className="ehb-daily-kpi-val">{daily?.kpis.activeDays ?? 0}</div>
|
||||
<div className="ehb-daily-kpi-sub">
|
||||
日均 {format(daily?.kpis.averageDailyKg ?? 0)} Kg
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-daily-kpi-card">
|
||||
<div className="ehb-daily-kpi-title">涉及加氢站</div>
|
||||
<div className="ehb-daily-kpi-val">
|
||||
{daily?.kpis.stationCount ?? 0} <small>站</small>
|
||||
</div>
|
||||
<div className="ehb-daily-kpi-sub">按明细站点去重</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="ehb-daily-chart-section">
|
||||
<div className="ehb-daily-chart-head">
|
||||
<div className="ehb-daily-chart-title">
|
||||
每日加氢量{" "}
|
||||
<span className="ehb-title-sub">
|
||||
(点击柱体下锚定位到对应日期明细)
|
||||
</span>
|
||||
</div>
|
||||
<div className="ehb-daily-chart-meta-group">
|
||||
<div className="ehb-daily-chart-legend">
|
||||
<span className="ehb-legend-item">
|
||||
<span className="ehb-legend-dot is-own" />
|
||||
内部客户
|
||||
</span>
|
||||
<span className="ehb-legend-item">
|
||||
<span className="ehb-legend-dot is-ext" />
|
||||
外部客户
|
||||
</span>
|
||||
</div>
|
||||
<span className="ehb-daily-chart-meta">时间单位:日 · 单位 Kg</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-daily-summary-pills">
|
||||
<div className="ehb-daily-pill-item">
|
||||
<span>峰值日</span>
|
||||
<strong>{peak ? `${peak.date} ${format(peak.kg)} Kg` : "—"}</strong>
|
||||
</div>
|
||||
<div className="ehb-daily-pill-item">
|
||||
<span>低谷日</span>
|
||||
<strong>
|
||||
{trough ? `${trough.date} ${format(trough.kg)} Kg` : "—"}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="ehb-daily-pill-item">
|
||||
<span>零数日</span>
|
||||
<strong>{trend.filter((row) => row.kg === 0).length} 天</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-daily-bar-container">
|
||||
<div
|
||||
className="ehb-daily-avg-line"
|
||||
style={{
|
||||
bottom: `${Math.min(92, Math.round((averageKg / maxKg) * 100))}%`,
|
||||
}}
|
||||
>
|
||||
<span className="ehb-daily-avg-label">
|
||||
均值 {format(averageKg)} Kg
|
||||
</span>
|
||||
</div>
|
||||
{trend.map((row) => {
|
||||
const total = row.kg || 1;
|
||||
const ownRatio = (row.lingniuKg / total) * 100;
|
||||
const extRatio = (row.externalKg / total) * 100;
|
||||
const active = expandedDate === row.date;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={row.date}
|
||||
className="ehb-daily-bar-col"
|
||||
onClick={() => openDate(row.date, true)}
|
||||
title={`${row.date} 加氢总量 ${format(row.kg)} Kg;点击展开并定位当日明细`}
|
||||
aria-label={`展开并定位${row.date}当日加氢明细`}
|
||||
>
|
||||
<div
|
||||
className="ehb-daily-bar-val"
|
||||
style={{
|
||||
color: active ? "#0284c7" : undefined,
|
||||
fontWeight: active ? 700 : undefined,
|
||||
}}
|
||||
>
|
||||
{Math.round(row.kg)}
|
||||
</div>
|
||||
<div
|
||||
className={`ehb-daily-bar-fill is-stacked ${active ? "is-active" : ""}`}
|
||||
style={{ height: `${Math.max(0, (row.kg / maxKg) * 100)}%` }}
|
||||
>
|
||||
{row.externalKg > 0 ? (
|
||||
<div
|
||||
className="ehb-bar-segment is-ext"
|
||||
style={{ height: `${extRatio}%` }}
|
||||
/>
|
||||
) : null}
|
||||
{row.lingniuKg > 0 ? (
|
||||
<div
|
||||
className="ehb-bar-segment is-own"
|
||||
style={{ height: `${ownRatio}%` }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="ehb-daily-bar-label"
|
||||
style={{
|
||||
color: active ? "#0284c7" : undefined,
|
||||
fontWeight: active ? 700 : undefined,
|
||||
}}
|
||||
>
|
||||
{row.date.slice(5)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<section className="ehb-daily-table-card">
|
||||
<div className="ehb-daily-table-head">
|
||||
<div className="ehb-daily-table-title">
|
||||
每日加氢数据明细{" "}
|
||||
<span className="ehb-title-sub">
|
||||
(可多层下钻:按日 → 加氢站 → 客户 → 车辆及数据源)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-btn ehb-btn--outline ehb-export-btn"
|
||||
onClick={exportRows}
|
||||
>
|
||||
<Download size={14} />
|
||||
导出 Excel
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="ehb-h5-scroll-hint ehb-daily-table-scroll-hint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
‹ 左右滑动查看完整指标列 ›
|
||||
</div>
|
||||
<div className="ehb-table-wrap">
|
||||
<table className="ehb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期 / 加氢站 / 客户 / 车辆明细</th>
|
||||
<th>单价(元/Kg)</th>
|
||||
<th>加氢量(Kg)</th>
|
||||
<th>成本金额(元) / 环比</th>
|
||||
<th>预充值余额 / 数据来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style={{ background: "#f8fafc", fontWeight: 700 }}>
|
||||
<td>合计</td>
|
||||
<td />
|
||||
<td>{format(daily?.kpis.totalKg ?? 0)}</td>
|
||||
<td>¥{format(daily?.kpis.totalCost ?? 0)}</td>
|
||||
<td>暂无来源</td>
|
||||
</tr>
|
||||
{(daily?.days ?? []).map((day) => {
|
||||
const tree = trees[day.date];
|
||||
const open = expandedDate === day.date;
|
||||
return (
|
||||
<Fragment key={day.date}>
|
||||
<tr
|
||||
ref={(node) => {
|
||||
dateRowRefs.current[day.date] = node;
|
||||
}}
|
||||
id={`daily-row-${day.date}`}
|
||||
className={`ehb-daily-date-row${highlightedDate === day.date ? " is-highlighted" : ""}`}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: open ? "#f0f9ff" : undefined,
|
||||
}}
|
||||
onClick={() => toggleDate(day.date)}
|
||||
>
|
||||
<td>
|
||||
<span className="ehb-daily-tree-toggle is-date">
|
||||
{open ? "▼" : "►"}
|
||||
</span>
|
||||
{day.date}{" "}
|
||||
<span className="ehb-title-sub">
|
||||
({day.stationCount ?? 0} 个加氢站)
|
||||
</span>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>{format(day.kg)}</td>
|
||||
<td>
|
||||
¥{format(day.cost)} /{" "}
|
||||
{format((day as { chainPct?: number }).chainPct ?? 0)}%
|
||||
</td>
|
||||
<td>暂无来源</td>
|
||||
</tr>
|
||||
{open &&
|
||||
tree?.stations
|
||||
.slice(0, expandedStationLists[day.date] ? undefined : 10)
|
||||
.map((station) => {
|
||||
const stationKey = `${day.date}:${station.id}`;
|
||||
const stationOpen = !!expandedStation[stationKey];
|
||||
return (
|
||||
<Fragment key={stationKey}>
|
||||
<tr
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: "#f8fafc",
|
||||
}}
|
||||
onClick={() =>
|
||||
setExpandedStation((items) => ({
|
||||
...items,
|
||||
[stationKey]: !items[stationKey],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<td className="ehb-tree-cell-l1">
|
||||
<span className="ehb-daily-tree-toggle is-station">
|
||||
{stationOpen ? "▼" : "►"}
|
||||
</span>
|
||||
<span className="ehb-daily-tree-branch">└</span>
|
||||
加氢站:{station.name}
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>{format(station.kg)}</td>
|
||||
<td>¥{format(station.cost)}</td>
|
||||
<td>暂无来源</td>
|
||||
</tr>
|
||||
{stationOpen &&
|
||||
station.customers
|
||||
.slice(
|
||||
0,
|
||||
expandedCustomerLists[stationKey]
|
||||
? undefined
|
||||
: 10,
|
||||
)
|
||||
.map((customer) => {
|
||||
const customerKey = `${stationKey}:${customer.id}`;
|
||||
const customerOpen =
|
||||
!!expandedCustomer[customerKey];
|
||||
const allRecords =
|
||||
customerRecords[customerKey]?.records ?? [];
|
||||
const records = allRecords.slice(
|
||||
0,
|
||||
expandedRecordLists[customerKey]
|
||||
? undefined
|
||||
: 20,
|
||||
);
|
||||
return (
|
||||
<Fragment key={customerKey}>
|
||||
<tr
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() =>
|
||||
toggleCustomer(
|
||||
day.date,
|
||||
station.id,
|
||||
customer.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<td className="ehb-tree-cell-l2">
|
||||
<span className="ehb-daily-tree-toggle is-customer">
|
||||
{customerOpen ? "▼" : "►"}
|
||||
</span>
|
||||
<span className="ehb-daily-tree-branch">└─</span>
|
||||
客户:{customer.name}{" "}
|
||||
<span className="ehb-title-sub">
|
||||
({customer.recordCount} 笔)
|
||||
</span>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>{format(customer.kg)}</td>
|
||||
<td>¥{format(customer.cost)}</td>
|
||||
<td>点击查看真实流水</td>
|
||||
</tr>
|
||||
{customerOpen &&
|
||||
records.map((record) => (
|
||||
<tr key={String(record.id)}>
|
||||
<td className="ehb-tree-cell-l3">
|
||||
<span className="ehb-daily-tree-branch">└──</span>
|
||||
{String(record.time || "—").slice(11, 16)}{" "}
|
||||
<strong>
|
||||
{String(record.plateNo || "无车牌")}
|
||||
</strong>{" "}
|
||||
<span
|
||||
className={`ehb-daily-record-tag ${record.vehicleScope === "lingniu" ? "is-own" : "is-external"}`}
|
||||
>
|
||||
{record.vehicleScope === "lingniu"
|
||||
? "羚牛车辆"
|
||||
: "外部车辆"}
|
||||
</span>
|
||||
<span
|
||||
className="ehb-daily-record-tag is-source"
|
||||
title={String(record.source || "未知来源")}
|
||||
>
|
||||
{sourceLabel(record.source)}
|
||||
</span>
|
||||
<span
|
||||
className={`ehb-daily-record-tag ${verifyLabel(record.verifyStatus) === "已验证" ? "is-verified" : "is-unverified"}`}
|
||||
title={verifyLabel(record.verifyStatus)}
|
||||
>
|
||||
{verifyLabel(record.verifyStatus)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{format(
|
||||
Number(record.unitPrice ?? 0),
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{format(Number(record.kg ?? 0))}
|
||||
</td>
|
||||
<td>
|
||||
¥{format(Number(record.cost ?? 0))}
|
||||
</td>
|
||||
<td>暂无预充值余额</td>
|
||||
</tr>
|
||||
))}
|
||||
{customerOpen && allRecords.length > 20 ? (
|
||||
<tr className="ehb-daily-tree-more-row">
|
||||
<td colSpan={5}>
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-daily-tree-more-btn"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setExpandedRecordLists((items) => ({
|
||||
...items,
|
||||
[customerKey]: !items[customerKey],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{expandedRecordLists[customerKey]
|
||||
? "收起车辆明细"
|
||||
: `更多车辆明细(还有 ${allRecords.length - 20} 笔)`}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{stationOpen && station.customers.length > 10 ? (
|
||||
<tr className="ehb-daily-tree-more-row">
|
||||
<td colSpan={5}>
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-daily-tree-more-btn"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setExpandedCustomerLists((items) => ({
|
||||
...items,
|
||||
[stationKey]: !items[stationKey],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{expandedCustomerLists[stationKey]
|
||||
? "收起客户"
|
||||
: `更多客户(还有 ${station.customers.length - 10} 个)`}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{open && tree && tree.stations.length > 10 ? (
|
||||
<tr className="ehb-daily-tree-more-row">
|
||||
<td colSpan={5}>
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-daily-tree-more-btn"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setExpandedStationLists((items) => ({
|
||||
...items,
|
||||
[day.date]: !items[day.date],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{expandedStationLists[day.date]
|
||||
? "收起加氢站"
|
||||
: `更多加氢站(还有 ${tree.stations.length - 10} 个)`}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronLeft, Download, Search, Truck, X } from "lucide-react";
|
||||
import { fetchH2BiDrill, fetchH2BiMeta } from "./api";
|
||||
import { downloadExcelAoa } from "./prototype-download";
|
||||
import type {
|
||||
H2BiDrillGroupBy,
|
||||
H2BiDrillGroupRow,
|
||||
H2BiDrillResponse,
|
||||
H2BiAmountScope,
|
||||
H2BiMetaResponse,
|
||||
H2BiQuery,
|
||||
H2BiVehicleScope,
|
||||
} from "./types";
|
||||
|
||||
type DrillKind = "kpi" | "customer" | "station" | "records";
|
||||
|
||||
export interface PrototypeDrillModalProps {
|
||||
kind: DrillKind;
|
||||
label: string;
|
||||
query: H2BiQuery;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type DrillRootDimension = "station" | "customer";
|
||||
|
||||
type DrillState = Pick<
|
||||
H2BiQuery,
|
||||
| "stationId"
|
||||
| "customerId"
|
||||
| "customerName"
|
||||
| "plateNo"
|
||||
| "date"
|
||||
| "month"
|
||||
| "region"
|
||||
| "regionGranularity"
|
||||
> & {
|
||||
level: H2BiDrillGroupBy;
|
||||
rootDimension: DrillRootDimension;
|
||||
amountScope: H2BiAmountScope;
|
||||
stationName?: string;
|
||||
};
|
||||
|
||||
const formatNumber = (value: unknown, digits = 2) =>
|
||||
Number(value ?? 0).toLocaleString("zh-CN", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
const fleetScope = (value: "all" | "own" | "external"): H2BiVehicleScope =>
|
||||
value === "own" ? "lingniu" : value;
|
||||
const verifyLabel = (status: unknown) => {
|
||||
const value = String(status ?? "").trim().toLowerCase();
|
||||
return value === "verified" || value === "pass" || value === "已验证"
|
||||
? "已验证"
|
||||
: "未验证";
|
||||
};
|
||||
|
||||
const sourceLabel = (source: unknown) => {
|
||||
const value = String(source ?? "").trim().toLowerCase();
|
||||
if (value === "api") return "API接入";
|
||||
if (value === "station" || value === "station_report") return "站点上报";
|
||||
if (value === "lingniu" || value === "lingniu_report") return "羚牛上报";
|
||||
return String(source ?? "真实账本");
|
||||
};
|
||||
|
||||
const sourceClass = (source: unknown) => {
|
||||
const value = String(source ?? "").trim().toLowerCase();
|
||||
if (value === "api") return "ehb-tag--source-api";
|
||||
if (value === "station" || value === "station_report") return "ehb-tag--source-station";
|
||||
return "ehb-tag--source-lingniu";
|
||||
};
|
||||
|
||||
/** 原生 select 会出现双箭头且不支持长列表检索;统一为可搜索选择器。 */
|
||||
function DrillSearchSelect({
|
||||
ariaLabel,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
allLabel,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
allLabel: string;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
if (open) document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setKeyword("");
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}, [open]);
|
||||
|
||||
const selectedLabel =
|
||||
options.find((option) => option.value === value)?.label ?? allLabel;
|
||||
const matches = useMemo(() => {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return query
|
||||
? options.filter(
|
||||
(option) =>
|
||||
option.label.toLowerCase().includes(query) ||
|
||||
option.value.toLowerCase().includes(query),
|
||||
)
|
||||
: options;
|
||||
}, [keyword, options]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ehb-bi-search-select ${disabled ? "is-disabled" : ""}`}
|
||||
ref={rootRef}
|
||||
style={{ width: 180 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-bi-search-select__trigger ${open ? "is-open" : ""} ${value ? "has-value" : ""}`}
|
||||
onClick={() => !disabled && setOpen((shown) => !shown)}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
title={selectedLabel}
|
||||
>
|
||||
<span className="ehb-bi-search-select__label">{selectedLabel}</span>
|
||||
<ChevronDown size={13} className="ehb-bi-search-select__chevron" />
|
||||
</button>
|
||||
{open && !disabled ? (
|
||||
<div className="ehb-bi-search-select__dropdown">
|
||||
<label className="ehb-bi-search-select__search">
|
||||
<Search size={13} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={`搜索${ariaLabel}`}
|
||||
/>
|
||||
</label>
|
||||
<div className="ehb-bi-search-select__list">
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-bi-search-select__item ${value ? "" : "is-selected"}`}
|
||||
onClick={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{allLabel}
|
||||
</button>
|
||||
{matches.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`ehb-bi-search-select__item ${value === option.value ? "is-selected" : ""}`}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
title={option.label}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{matches.length === 0 ? (
|
||||
<div className="ehb-bi-search-select__empty">无匹配项</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function initialState(kind: DrillKind, label: string): DrillState {
|
||||
// Canonical labels use “2026年4月”. Accept the former “2026年2026-04”
|
||||
// form too so a legacy label can never silently drop the month filter.
|
||||
const monthParts =
|
||||
label.match(/(\d{4})年(\d{1,2})月/) ?? label.match(/(\d{4})-(\d{1,2})/);
|
||||
const regionMatch = label.match(/区域(市|省):(.+)/);
|
||||
const stationIdMatch = label.match(/stationId=(\d+)/);
|
||||
const cleanLabel = label.replace(/\|stationId=\d+$/, "");
|
||||
// 对客金额、对客成本和利润都只看客户承担订单;利润减法使用完全
|
||||
// 相同订单集。成本图的另外两段则按账本 settlement_type 定向下钻。
|
||||
const amountScope: H2BiAmountScope = /我司承担成本/.test(cleanLabel)
|
||||
? "company"
|
||||
: /其他成本/.test(cleanLabel)
|
||||
? "other"
|
||||
: /加氢利润|对客金额|对客收入|客户收入|客户承担成本|对客成本/.test(cleanLabel)
|
||||
? "customer"
|
||||
: "all";
|
||||
if (kind === "customer" || kind === "station")
|
||||
return { level: "date", rootDimension: "station", amountScope };
|
||||
if (kind === "records")
|
||||
return { level: "record", rootDimension: "station", amountScope };
|
||||
return {
|
||||
level: stationIdMatch ? "customer" : "station",
|
||||
rootDimension: "station",
|
||||
amountScope,
|
||||
stationId: stationIdMatch ? stationIdMatch[1] : undefined,
|
||||
month: monthParts
|
||||
? `${monthParts[1]}-${monthParts[2].padStart(2, "0")}`
|
||||
: undefined,
|
||||
region: regionMatch ? regionMatch[2] : undefined,
|
||||
regionGranularity: regionMatch
|
||||
? regionMatch[1] === "省"
|
||||
? "province"
|
||||
: "city"
|
||||
: undefined,
|
||||
// Keep this assignment solely to make the parsed label explicit in DevTools;
|
||||
// it is not sent as a business filter.
|
||||
customerName: cleanLabel.startsWith("客户:")
|
||||
? cleanLabel.slice(3)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function titleFor(level: H2BiDrillGroupBy) {
|
||||
return (
|
||||
{
|
||||
station: "加氢站",
|
||||
customer: "客户",
|
||||
date: "日期",
|
||||
vehicle: "车辆",
|
||||
record: "加氢记录",
|
||||
} as const
|
||||
)[level];
|
||||
}
|
||||
|
||||
function nextState(current: DrillState, row: H2BiDrillGroupRow): DrillState {
|
||||
if (current.level === "station") {
|
||||
if (current.rootDimension === "customer" && current.customerId !== undefined)
|
||||
return { ...current, level: "vehicle", stationId: row.id, stationName: row.name };
|
||||
return { ...current, level: "customer", stationId: row.id, stationName: row.name };
|
||||
}
|
||||
if (current.level === "customer") {
|
||||
if (current.rootDimension === "customer" && current.customerId === undefined)
|
||||
return {
|
||||
...current,
|
||||
level: "station",
|
||||
customerId: Number(row.id),
|
||||
customerName: row.name,
|
||||
};
|
||||
return {
|
||||
...current,
|
||||
level: "vehicle",
|
||||
customerId: Number(row.id),
|
||||
customerName: row.name,
|
||||
};
|
||||
}
|
||||
if (current.level === "date")
|
||||
return { ...current, level: "vehicle", date: row.id };
|
||||
if (current.level === "vehicle")
|
||||
return { ...current, level: "record", plateNo: row.name };
|
||||
return current;
|
||||
}
|
||||
|
||||
function useDrill(query: H2BiQuery, state: DrillState) {
|
||||
const [data, setData] = useState<H2BiDrillResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const key = JSON.stringify({ ...query, ...state });
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchH2BiDrill({
|
||||
...query,
|
||||
...state,
|
||||
groupBy: state.level,
|
||||
amountScope: state.amountScope,
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
})
|
||||
.then((result) => alive && setData(result))
|
||||
.catch(
|
||||
(reason: unknown) =>
|
||||
alive &&
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "下钻数据加载失败",
|
||||
),
|
||||
)
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [key]);
|
||||
return { data, error, loading };
|
||||
}
|
||||
|
||||
function GroupTable({
|
||||
state,
|
||||
data,
|
||||
onOpen,
|
||||
}: {
|
||||
state: DrillState;
|
||||
data: H2BiDrillResponse;
|
||||
onOpen: (row: H2BiDrillGroupRow) => void;
|
||||
}) {
|
||||
const customerBearingScope = data.amountScope === "customer";
|
||||
const costLabel =
|
||||
data.amountScope === "company"
|
||||
? "我司承担成本金额"
|
||||
: data.amountScope === "other"
|
||||
? "其他成本金额"
|
||||
: customerBearingScope
|
||||
? "对客成本金额"
|
||||
: "成本金额";
|
||||
const isProfit = state.amountScope === "customer";
|
||||
if (state.level === "record") {
|
||||
return (
|
||||
<table className="ehb-modal-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>加氢站 / 客户 / 车辆与凭证链路</th>
|
||||
<th>类型 / 归属</th>
|
||||
<th>数据来源及凭证号</th>
|
||||
<th>核对状态</th>
|
||||
<th style={{ textAlign: "right" }}>加氢笔数</th>
|
||||
<th style={{ textAlign: "right" }}>加氢总量 (Kg)</th>
|
||||
<th style={{ textAlign: "right" }}>{costLabel} (元)</th>
|
||||
<th style={{ textAlign: "right" }}>对客金额 (元)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.stationName ? (
|
||||
<tr className="ehb-tree-row-station">
|
||||
<td className="ehb-tree-cell-l1"><div className="ehb-tree-node-title"><span aria-hidden>▾</span><strong>{state.stationName}</strong></div></td>
|
||||
<td><span className="ehb-tree-node-sub">加氢站</span></td><td><span className="ehb-tree-node-sub">真实账本流水</span></td><td>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{state.customerName ? (
|
||||
<tr className="ehb-tree-row-customer">
|
||||
<td className="ehb-tree-cell-l2"><div className="ehb-tree-node-title"><span aria-hidden>▾</span><strong>└─ 客户:{state.customerName}</strong></div></td>
|
||||
<td><span className="ehb-tree-node-sub">客户</span></td><td><span className="ehb-tree-node-sub">真实账本流水</span></td><td>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{state.plateNo ? (
|
||||
<tr>
|
||||
<td className="ehb-tree-cell-l3"><div className="ehb-tree-node-title"><span aria-hidden>▾</span><strong>└─ {state.plateNo}</strong></div></td>
|
||||
<td><span className="ehb-tag ehb-tag--own-fleet">车辆</span></td><td><span className="ehb-tree-node-sub">真实账本流水</span></td><td>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{data.records.map((record, index) => (
|
||||
<tr key={`${String(record.id)}-${index}`}>
|
||||
<td className="ehb-tree-cell-l4">
|
||||
<div className="ehb-tree-ord-block">
|
||||
<strong>
|
||||
订单编号 {String(record.orderNo || record.id || "—")}
|
||||
</strong>
|
||||
<span className="ehb-tree-ord-time">
|
||||
{String(record.time || "—")} · {String(record.stationName || "未关联站点")} · {String(record.customerName || "未关联客户")}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`ehb-tag ${record.vehicleScope === "lingniu" ? "ehb-tag--own-fleet" : "ehb-tag--ext-fleet"}`}
|
||||
>
|
||||
{record.vehicleScope === "lingniu" ? "羚牛车辆" : "外部车辆"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`ehb-tag ${sourceClass(record.source)}`}>
|
||||
{sourceLabel(record.source)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`ehb-tag ${verifyLabel(record.verifyStatus) === "已验证" ? "ehb-tag--verify-ok" : "ehb-tag--verify-warn"}`}
|
||||
>
|
||||
{verifyLabel(record.verifyStatus)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
1 笔
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>{formatNumber(record.kg)}</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
¥{formatNumber(record.cost)}
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
¥{formatNumber(record.revenue)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<table className="ehb-modal-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>加氢站 / 客户 / 车辆与凭证链路</th>
|
||||
<th>类型 / 归属</th>
|
||||
<th>数据来源及凭证号</th>
|
||||
<th>核对状态</th>
|
||||
<th style={{ textAlign: "right" }}>加氢笔数</th>
|
||||
<th style={{ textAlign: "right" }}>加氢总量 (Kg)</th>
|
||||
<th style={{ textAlign: "right" }}>{costLabel} (元)</th>
|
||||
<th style={{ textAlign: "right" }}>对客金额 (元)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.stationName ? (
|
||||
<tr className="ehb-tree-row-station">
|
||||
<td className="ehb-tree-cell-l1">
|
||||
<div className="ehb-tree-node-title"><span aria-hidden>▾</span><strong>{state.stationName}</strong></div>
|
||||
</td>
|
||||
<td><span className="ehb-tree-node-sub">加氢站</span></td>
|
||||
<td><span className="ehb-tree-node-sub">真实账本流水</span></td>
|
||||
<td>—</td>
|
||||
<td style={{ textAlign: "right" }}>—</td>
|
||||
<td style={{ textAlign: "right" }}>—</td>
|
||||
<td style={{ textAlign: "right" }}>—</td>
|
||||
<td style={{ textAlign: "right" }}>—</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{state.customerName && state.level !== "customer" ? (
|
||||
<tr className="ehb-tree-row-customer">
|
||||
<td className="ehb-tree-cell-l2">
|
||||
<div className="ehb-tree-node-title"><span aria-hidden>▾</span><strong>└─ 客户:{state.customerName}</strong></div>
|
||||
</td>
|
||||
<td><span className="ehb-tree-node-sub">客户</span></td>
|
||||
<td><span className="ehb-tree-node-sub">真实账本流水</span></td>
|
||||
<td>—</td>
|
||||
<td style={{ textAlign: "right" }}>—</td>
|
||||
<td style={{ textAlign: "right" }}>—</td>
|
||||
<td style={{ textAlign: "right" }}>—</td>
|
||||
<td style={{ textAlign: "right" }}>—</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{data.groups.map((row, index) => (
|
||||
<tr
|
||||
key={`${row.id}-${index}`}
|
||||
onClick={() => onOpen(row)}
|
||||
style={{ cursor: "pointer" }}
|
||||
title={`点击继续查看${titleFor(nextState(state, row).level)}`}
|
||||
>
|
||||
<td
|
||||
className={
|
||||
state.level === "station"
|
||||
? "ehb-tree-cell-l1"
|
||||
: state.level === "customer"
|
||||
? "ehb-tree-cell-l2"
|
||||
: state.level === "vehicle"
|
||||
? "ehb-tree-cell-l3"
|
||||
: "ehb-tree-cell-l1"
|
||||
}
|
||||
>
|
||||
<div className="ehb-tree-node-title">
|
||||
<span aria-hidden>{state.level === "station" ? "▸" : "└─"}</span>
|
||||
<strong>
|
||||
{state.level === "customer" ? "客户:" : ""}
|
||||
{row.name}
|
||||
</strong>
|
||||
{row.province ? <span className="ehb-tree-node-sub">{row.province}</span> : null}
|
||||
<span className="ehb-kpi-drill-hint">钻取 ›</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{state.level === "vehicle" ? (
|
||||
<span
|
||||
className={`ehb-tag ${row.lingniuKg > 0 ? "ehb-tag--own-fleet" : "ehb-tag--ext-fleet"}`}
|
||||
>
|
||||
{row.lingniuKg > 0 ? "羚牛车辆" : "外部车辆"}
|
||||
</span>
|
||||
) : state.level === "station" ? (
|
||||
<span className="ehb-tree-node-sub">
|
||||
覆盖 {formatNumber(row.customerCount, 0)} 家客户
|
||||
</span>
|
||||
) : (
|
||||
<span className="ehb-tree-node-sub">聚合</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="ehb-tree-node-sub">
|
||||
{state.level === "vehicle" ? "真实账本流水" : "全量自动归集"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="ehb-tree-node-sub">
|
||||
{state.level === "station" ? "汇总" : "点击展开"}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>{formatNumber(row.recordCount, 0)} 笔</td>
|
||||
<td style={{ textAlign: "right", fontWeight: 700 }}>
|
||||
{formatNumber(row.kg)}
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
¥{formatNumber(row.cost)}
|
||||
</td>
|
||||
<td style={{ textAlign: "right", color: isProfit ? "#10b981" : undefined }}>
|
||||
¥{formatNumber(row.revenue)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/** Preserves the prototype overlay/card/meta/table anatomy, but every row is live ledger data. */
|
||||
export function PrototypeDrillModal({
|
||||
kind,
|
||||
label,
|
||||
query,
|
||||
onClose,
|
||||
}: PrototypeDrillModalProps) {
|
||||
const [state, setState] = useState<DrillState>(() => ({
|
||||
...initialState(kind, label),
|
||||
...(kind === "customer" ? { customerName: label } : {}),
|
||||
}));
|
||||
const [history, setHistory] = useState<DrillState[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [meta, setMeta] = useState<H2BiMetaResponse | null>(null);
|
||||
const [fleetCategoryFilter, setFleetCategoryFilter] = useState<
|
||||
"all" | "own" | "external"
|
||||
>(() =>
|
||||
query.vehicleScope === "lingniu"
|
||||
? "own"
|
||||
: query.vehicleScope === "external"
|
||||
? "external"
|
||||
: "all",
|
||||
);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void fetchH2BiMeta().then((result) => alive && setMeta(result)).catch(() => {
|
||||
// The drill itself remains usable even when the optional select metadata is unavailable.
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
const liveQuery = useMemo(
|
||||
() => ({ ...query, vehicleScope: fleetScope(fleetCategoryFilter) }),
|
||||
[fleetCategoryFilter, query],
|
||||
);
|
||||
const live = useDrill(liveQuery, state);
|
||||
const data = useMemo(() => {
|
||||
if (!live.data || !search.trim()) return live.data;
|
||||
const term = search.trim().toLowerCase();
|
||||
return {
|
||||
...live.data,
|
||||
groups: live.data.groups.filter((row) =>
|
||||
JSON.stringify(row).toLowerCase().includes(term),
|
||||
),
|
||||
records: live.data.records.filter((row) =>
|
||||
JSON.stringify(row).toLowerCase().includes(term),
|
||||
),
|
||||
};
|
||||
}, [live.data, search]);
|
||||
const cleanLabel = label.replace(/\|stationId=\d+$/, "");
|
||||
const customerBearingScope = live.data?.amountScope === "customer";
|
||||
const profit =
|
||||
Number(live.data?.summary.revenue ?? 0) -
|
||||
Number(live.data?.summary.cost ?? 0);
|
||||
const coverageStationCount = Number(
|
||||
live.data?.summary.stationCount ??
|
||||
(state.stationId
|
||||
? 1
|
||||
: state.level === "station"
|
||||
? live.data?.groups.length ?? 0
|
||||
: 0),
|
||||
);
|
||||
const traceability = Number(live.data?.summary.recordCount ?? 0)
|
||||
? (Number(live.data?.summary.traceableRecordCount ?? 0) /
|
||||
Number(live.data?.summary.recordCount ?? 1)) *
|
||||
100
|
||||
: 0;
|
||||
const open = (row: H2BiDrillGroupRow) => {
|
||||
setHistory((items) => [...items, state]);
|
||||
setState(nextState(state, row));
|
||||
};
|
||||
const stationOptions = meta?.stations ?? [];
|
||||
const customerOptions =
|
||||
state.level === "customer"
|
||||
? live.data?.groups.map((row) => ({ id: row.id, name: row.name })) ?? []
|
||||
: state.customerId && state.customerName
|
||||
? [{ id: String(state.customerId), name: state.customerName }]
|
||||
: [];
|
||||
const vehicleOptions =
|
||||
state.level === "vehicle"
|
||||
? live.data?.groups.map((row) => ({ id: row.id, name: row.name })) ?? []
|
||||
: state.plateNo
|
||||
? [{ id: state.plateNo, name: state.plateNo }]
|
||||
: [];
|
||||
const resetToRoot = (overrides: Partial<DrillState> = {}) => {
|
||||
setHistory([]);
|
||||
setSearch("");
|
||||
setState({ ...initialState(kind, label), ...overrides });
|
||||
};
|
||||
const changeStation = (value: string) => {
|
||||
if (!value) {
|
||||
resetToRoot({ rootDimension: state.rootDimension, level: state.rootDimension });
|
||||
return;
|
||||
}
|
||||
resetToRoot({
|
||||
stationId: value,
|
||||
stationName: stationOptions.find((station) => String(station.id) === value)?.name,
|
||||
level: "customer",
|
||||
rootDimension: "station",
|
||||
});
|
||||
};
|
||||
const changeCustomer = (value: string) => {
|
||||
if (!value) {
|
||||
if (state.stationId) setState((current) => ({ ...current, level: "customer", customerId: undefined, plateNo: undefined }));
|
||||
return;
|
||||
}
|
||||
setHistory([]);
|
||||
setSearch("");
|
||||
setState((current) => ({
|
||||
...current,
|
||||
customerId: Number(value),
|
||||
customerName: customerOptions.find((customer) => customer.id === value)?.name,
|
||||
plateNo: undefined,
|
||||
level: "vehicle",
|
||||
}));
|
||||
};
|
||||
const changeVehicle = (value: string) => {
|
||||
if (!value) {
|
||||
setState((current) => ({ ...current, level: "vehicle", plateNo: undefined }));
|
||||
return;
|
||||
}
|
||||
setHistory([]);
|
||||
setSearch("");
|
||||
setState((current) => ({ ...current, plateNo: value, level: "record" }));
|
||||
};
|
||||
const exportCurrent = () => {
|
||||
if (!data) return;
|
||||
const rows: Array<Array<string | number>> = [
|
||||
[
|
||||
"加氢站 / 客户 / 车辆与凭证链路",
|
||||
"加氢量(Kg)",
|
||||
"成本金额(元)",
|
||||
"对客金额(元)",
|
||||
],
|
||||
];
|
||||
if (state.level === "record") {
|
||||
data.records.forEach((record) =>
|
||||
rows.push([
|
||||
`${String(record.stationName || "未关联站点")} / ${String(record.customerName || "未关联客户")} / ${String(record.plateNo || "无车牌")} / ${String(record.orderNo || record.id || "—")}`,
|
||||
Number(record.kg ?? 0),
|
||||
Number(record.cost ?? 0),
|
||||
Number(record.revenue ?? 0),
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
data.groups.forEach((row) =>
|
||||
rows.push([row.name, row.kg, row.cost, row.revenue]),
|
||||
);
|
||||
}
|
||||
downloadExcelAoa(rows, `${cleanLabel}_真实账本穿透.xlsx`, "真实账本穿透");
|
||||
};
|
||||
const switchRootDimension = (rootDimension: DrillRootDimension) => {
|
||||
if (kind !== "kpi" || state.rootDimension === rootDimension) return;
|
||||
setHistory([]);
|
||||
setSearch("");
|
||||
setState({
|
||||
...initialState(kind, label),
|
||||
rootDimension,
|
||||
level: rootDimension,
|
||||
});
|
||||
};
|
||||
const back = () => {
|
||||
const previous = history.at(-1);
|
||||
if (!previous) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setHistory((items) => items.slice(0, -1));
|
||||
setState(previous);
|
||||
};
|
||||
const scope =
|
||||
state.date ||
|
||||
state.month ||
|
||||
query.date ||
|
||||
query.month ||
|
||||
(query.startDate && query.endDate
|
||||
? `${query.startDate} 至 ${query.endDate}`
|
||||
: `${query.year} 年`);
|
||||
const displayTitle = /\d{4}年/.test(cleanLabel)
|
||||
? `${cleanLabel}明细`
|
||||
: `「${query.year}」${cleanLabel}明细`;
|
||||
return (
|
||||
<div
|
||||
className="ehb-modal-overlay"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className="ehb-modal-card"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="ehb-modal-head">
|
||||
<div className="ehb-modal-head__title-group">
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-modal-back-btn"
|
||||
onClick={back}
|
||||
title="返回"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
<span>{history.length ? "返回上级" : "返回"}</span>
|
||||
</button>
|
||||
<div>
|
||||
<div className="ehb-modal-head__title">{displayTitle}</div>
|
||||
<div className="ehb-modal-head__sub">真实账本下钻 · {scope}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-modal-head__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-modal-close-btn"
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-modal-body">
|
||||
<div className="ehb-modal-meta-bar">
|
||||
<div className="ehb-modal-meta-item">
|
||||
<span className="ehb-modal-meta-label">数据归集总量</span>
|
||||
<span className="ehb-modal-meta-val">
|
||||
<span style={{ color: "#0284c7" }}>
|
||||
{formatNumber(Number(live.data?.summary.kg ?? 0) / 1000)} T
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="ehb-modal-meta-item">
|
||||
<span className="ehb-modal-meta-label">数据总金额</span>
|
||||
<span className="ehb-modal-meta-val">
|
||||
<span style={{ color: "#10b981" }}>
|
||||
¥{formatNumber(Number(live.data?.summary.cost ?? 0) / 10000)} 万元
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="ehb-modal-meta-item">
|
||||
<span className="ehb-modal-meta-label">覆盖加氢站数</span>
|
||||
<span className="ehb-modal-meta-val">
|
||||
{coverageStationCount} 站
|
||||
</span>
|
||||
</div>
|
||||
<div className="ehb-modal-meta-item">
|
||||
<span className="ehb-modal-meta-label">数据源可追溯率</span>
|
||||
<span className="ehb-modal-meta-val">
|
||||
<span style={{ color: "#f59e0b" }}>
|
||||
{formatNumber(traceability, 0)}%(含账本来源字段)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{cleanLabel === "加氢利润" && customerBearingScope ? (
|
||||
<div className="ehb-modal-hint-text" style={{ marginBottom: 10 }}>
|
||||
利润口径:客户承担订单的对客总价 ¥
|
||||
{formatNumber(live.data?.summary.revenue)} − 成本总价 ¥
|
||||
{formatNumber(live.data?.summary.cost)} = ¥{formatNumber(profit)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="ehb-modal-filter-row">
|
||||
<div className="ehb-modal-filter-group">
|
||||
<DrillSearchSelect
|
||||
ariaLabel="筛选加氢站"
|
||||
value={state.stationId ? String(state.stationId) : ""}
|
||||
onChange={changeStation}
|
||||
options={stationOptions.map((station) => ({
|
||||
value: String(station.id),
|
||||
label: station.name,
|
||||
}))}
|
||||
allLabel="全部加氢站"
|
||||
placeholder="搜索加氢站"
|
||||
/>
|
||||
<DrillSearchSelect
|
||||
ariaLabel="筛选客户"
|
||||
value={state.customerId ? String(state.customerId) : ""}
|
||||
onChange={changeCustomer}
|
||||
options={customerOptions.map((customer) => ({
|
||||
value: String(customer.id),
|
||||
label: customer.name,
|
||||
}))}
|
||||
allLabel="全部客户"
|
||||
placeholder="搜索客户"
|
||||
disabled={!state.stationId}
|
||||
/>
|
||||
<DrillSearchSelect
|
||||
ariaLabel="筛选车辆"
|
||||
value={state.plateNo ?? ""}
|
||||
onChange={changeVehicle}
|
||||
options={vehicleOptions.map((vehicle) => ({
|
||||
value: vehicle.name,
|
||||
label: vehicle.name,
|
||||
}))}
|
||||
allLabel="全部车辆"
|
||||
placeholder="搜索车牌"
|
||||
disabled={!state.customerId}
|
||||
/>
|
||||
<div className="ehb-fleet-segmented" role="radiogroup" aria-label="车辆归属">
|
||||
{([
|
||||
["all", "全部车辆"],
|
||||
["own", "仅羚牛车辆"],
|
||||
["external", "仅外部车辆"],
|
||||
] as const).map(([value, text]) => (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={fleetCategoryFilter === value}
|
||||
className={`ehb-fleet-btn ${fleetCategoryFilter === value ? "is-active" : ""}`}
|
||||
onClick={() => setFleetCategoryFilter(value)}
|
||||
key={value}
|
||||
>
|
||||
{value !== "all" ? <Truck size={14} aria-hidden /> : null}
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="ehb-btn ehb-btn--outline ehb-export-btn" onClick={exportCurrent}>
|
||||
<Download size={14} aria-hidden />
|
||||
导出 Excel 穿透账单
|
||||
</button>
|
||||
{kind === "kpi" && history.length === 0 ? (
|
||||
<div className="ehb-pill-tabs" role="group" aria-label="下钻首层维度">
|
||||
<button type="button" className={`ehb-pill-btn ${state.rootDimension === "station" ? "is-active" : ""}`} onClick={() => switchRootDimension("station")}>加氢站 → 客户</button>
|
||||
<button type="button" className={`ehb-pill-btn ${state.rootDimension === "customer" ? "is-active" : ""}`} onClick={() => switchRootDimension("customer")}>客户 → 加氢站</button>
|
||||
</div>
|
||||
) : null}
|
||||
<span className="ehb-modal-hint-text">提示:点击表格行可四级层层展开【加氢站 → 客户 → 车牌 → 单笔订单与核对明细】</span>
|
||||
</div>
|
||||
<label className="ehb-modal-search-input">
|
||||
<Search size={14} />
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索当前下钻结果" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="ehb-h5-scroll-hint">‹ 左右滑动查看完整指标列 ›</div>
|
||||
<div className="ehb-modal-table-wrap is-v-scroll">
|
||||
{live.loading ? (
|
||||
<div className="ehb-empty">正在加载真实下钻数据…</div>
|
||||
) : live.error ? (
|
||||
<div className="ehb-empty">{live.error}</div>
|
||||
) : data &&
|
||||
(state.level === "record"
|
||||
? data.records.length
|
||||
: data.groups.length) > 0 ? (
|
||||
<GroupTable state={state} data={data} onOpen={open} />
|
||||
) : (
|
||||
<div className="ehb-empty">当前筛选范围暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function prototypeFleetScope(value: "all" | "own" | "external") {
|
||||
return fleetScope(value);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useState } from 'react';
|
||||
import './styles/energy-bi-board.css';
|
||||
import { isOssGateSessionAuthed } from '../../common/oss-access-gate';
|
||||
|
||||
export const ENERGY_BI_PASSWORD = 'lingniu';
|
||||
export const ENERGY_BI_AUTH_KEY = 'energy-h2-bi-board-auth-v1';
|
||||
|
||||
export function isEnergyBiAuthed(): boolean {
|
||||
return isOssGateSessionAuthed(ENERGY_BI_AUTH_KEY);
|
||||
}
|
||||
|
||||
export function setEnergyBiAuthed(ok: boolean): void {
|
||||
try {
|
||||
if (ok) sessionStorage.setItem(ENERGY_BI_AUTH_KEY, '1');
|
||||
else sessionStorage.removeItem(ENERGY_BI_AUTH_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
interface EnergyBiAccessGateProps {
|
||||
onOk: () => void;
|
||||
}
|
||||
|
||||
/** 轻门禁:口令 lingniu · 本会话记住(与汇报舱 / 作战室同口径) */
|
||||
export const EnergyBiAccessGate: React.FC<EnergyBiAccessGateProps> = ({ onOk }) => {
|
||||
const [pwd, setPwd] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (pwd.trim() === ENERGY_BI_PASSWORD) {
|
||||
setEnergyBiAuthed(true);
|
||||
setErr('');
|
||||
onOk();
|
||||
return;
|
||||
}
|
||||
setErr('口令不对,请重试');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ehb-gate">
|
||||
<form className="ehb-gate-card" onSubmit={submit}>
|
||||
<p className="ehb-gate-kicker">ONEOS · 能源 BI</p>
|
||||
<h1 className="ehb-gate-title">氢能经营看板</h1>
|
||||
<p className="ehb-gate-sub">我司成本 · 按日 / 总览 · 单站日报</p>
|
||||
<label className="ehb-gate-label" htmlFor="ehb-pwd">
|
||||
访问口令
|
||||
</label>
|
||||
<input
|
||||
id="ehb-pwd"
|
||||
className="ehb-gate-input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
value={pwd}
|
||||
onChange={(e) => {
|
||||
setPwd(e.target.value);
|
||||
if (err) setErr('');
|
||||
}}
|
||||
placeholder="请输入口令"
|
||||
/>
|
||||
<p className="ehb-gate-error" role="alert">
|
||||
{err}
|
||||
</p>
|
||||
<button type="submit" className="ehb-gate-btn">
|
||||
进入看板
|
||||
</button>
|
||||
<p className="ehb-gate-foot">内部文件 · 请勿外传</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"directory": {
|
||||
"title": "能源氢费经营看板",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "产品需求说明(PRD)",
|
||||
"type": "markdown",
|
||||
"path": ".spec/requirements-prd.md",
|
||||
"description": "口令 lingniu · 顶栏全局/单站 · 无关联入口卡 · 默认全部车辆·KPI/图表/钻取跟随筛选 · 无车牌归外部车辆 · 外部主数据见 energy-h2-external-* · 分流 own→氢费明细 external→仅 BI"
|
||||
},
|
||||
{
|
||||
"id": "board-app",
|
||||
"title": "氢能经营看板",
|
||||
"type": "route",
|
||||
"path": "/prototypes/energy-h2-bi-board",
|
||||
"description": "顶栏全局/单站;单站=日报起止查询·行内加氢量占比·按量降序"
|
||||
},
|
||||
{
|
||||
"id": "energy-board-plan",
|
||||
"title": "能源 BI 看板方案",
|
||||
"type": "markdown",
|
||||
"path": "../../resources/prd/energy-board-plan-20260806.md"
|
||||
},
|
||||
{
|
||||
"id": "host-ref",
|
||||
"title": "宿主 overview 视觉参考",
|
||||
"type": "markdown",
|
||||
"path": "../../resources/prd/energy-bi-host-ref/content.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import type {
|
||||
CostDim,
|
||||
FleetScope,
|
||||
H2OrderRow,
|
||||
LeaseKind,
|
||||
OpsKind,
|
||||
} from '../types';
|
||||
import { COST_DIM_LABEL, LEASE_KIND_LABEL, OPS_KIND_LABEL } from '../types';
|
||||
|
||||
export function filterOrders(
|
||||
rows: H2OrderRow[],
|
||||
year: number,
|
||||
verifyScope: 'all' | 'verified',
|
||||
fleetScope: FleetScope,
|
||||
): H2OrderRow[] {
|
||||
return rows.filter((r) => {
|
||||
if (!r.occurredAt.startsWith(String(year))) return false;
|
||||
if (verifyScope === 'verified' && r.verifyStatus !== 'verified') return false;
|
||||
if (fleetScope === 'own' && r.fleet !== 'own') return false;
|
||||
if (fleetScope === 'external' && r.fleet !== 'external') return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function sumAmount(rows: H2OrderRow[]): number {
|
||||
return rows.reduce((s, r) => s + r.amount, 0);
|
||||
}
|
||||
|
||||
export function sumKg(rows: H2OrderRow[]): number {
|
||||
return rows.reduce((s, r) => s + r.quantityKg, 0);
|
||||
}
|
||||
|
||||
export function companyCostRows(rows: H2OrderRow[]): H2OrderRow[] {
|
||||
return rows.filter((r) => r.borneBy === 'company');
|
||||
}
|
||||
|
||||
export function dimAmount(rows: H2OrderRow[], dim: CostDim): number {
|
||||
return sumAmount(companyCostRows(rows).filter((r) => r.costDim === dim));
|
||||
}
|
||||
|
||||
export function leaseSubAmount(rows: H2OrderRow[], kind: LeaseKind): number {
|
||||
return sumAmount(
|
||||
companyCostRows(rows).filter((r) => r.costDim === 'lease' && r.leaseKind === kind),
|
||||
);
|
||||
}
|
||||
|
||||
export function opsSubAmount(rows: H2OrderRow[], kind: OpsKind): number {
|
||||
return sumAmount(
|
||||
companyCostRows(rows).filter((r) => r.costDim === 'ops' && r.opsKind === kind),
|
||||
);
|
||||
}
|
||||
|
||||
export interface DimCard {
|
||||
key: CostDim;
|
||||
label: string;
|
||||
amount: number;
|
||||
subs: { key: string; label: string; amount: number }[];
|
||||
}
|
||||
|
||||
export function costDimCards(rows: H2OrderRow[]): DimCard[] {
|
||||
return [
|
||||
{
|
||||
key: 'lease',
|
||||
label: COST_DIM_LABEL.lease,
|
||||
amount: dimAmount(rows, 'lease'),
|
||||
subs: [
|
||||
{ key: 'company_borne', label: LEASE_KIND_LABEL.company_borne, amount: leaseSubAmount(rows, 'company_borne') },
|
||||
{ key: 'package_h2', label: LEASE_KIND_LABEL.package_h2, amount: leaseSubAmount(rows, 'package_h2') },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'logistics',
|
||||
label: COST_DIM_LABEL.logistics,
|
||||
amount: dimAmount(rows, 'logistics'),
|
||||
subs: [],
|
||||
},
|
||||
{
|
||||
key: 'ops',
|
||||
label: COST_DIM_LABEL.ops,
|
||||
amount: dimAmount(rows, 'ops'),
|
||||
subs: [
|
||||
{ key: 'abnormal', label: OPS_KIND_LABEL.abnormal, amount: opsSubAmount(rows, 'abnormal') },
|
||||
{ key: 'transfer', label: OPS_KIND_LABEL.transfer, amount: opsSubAmount(rows, 'transfer') },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function pendingAmount(rows: H2OrderRow[]): number {
|
||||
return dimAmount(rows, 'pending');
|
||||
}
|
||||
|
||||
export function unverified(rows: H2OrderRow[]): { amount: number; count: number } {
|
||||
const list = rows.filter((r) => r.verifyStatus === 'unverified');
|
||||
return { amount: sumAmount(list), count: list.length };
|
||||
}
|
||||
|
||||
export function formatYuan(n: number): string {
|
||||
return `¥${n.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`;
|
||||
}
|
||||
|
||||
export function formatKg(n: number): string {
|
||||
return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} kg`;
|
||||
}
|
||||
|
||||
export function costDimLabel(row: H2OrderRow): string {
|
||||
if (row.costDim === 'lease' && row.leaseKind) {
|
||||
return `${COST_DIM_LABEL.lease} · ${LEASE_KIND_LABEL[row.leaseKind]}`;
|
||||
}
|
||||
if (row.costDim === 'ops' && row.opsKind) {
|
||||
return `${COST_DIM_LABEL.ops} · ${OPS_KIND_LABEL[row.opsKind]}`;
|
||||
}
|
||||
return COST_DIM_LABEL[row.costDim];
|
||||
}
|
||||
|
||||
export type DimFilter =
|
||||
| { dim: CostDim; sub?: string }
|
||||
| null;
|
||||
|
||||
export function applyDimFilter(rows: H2OrderRow[], filter: DimFilter): H2OrderRow[] {
|
||||
if (!filter) return rows;
|
||||
return rows.filter((r) => {
|
||||
if (r.borneBy !== 'company') return false;
|
||||
if (r.costDim !== filter.dim) return false;
|
||||
if (!filter.sub) return true;
|
||||
if (filter.dim === 'lease') return r.leaseKind === filter.sub;
|
||||
if (filter.dim === 'ops') return r.opsKind === filter.sub;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** 统计/明细共用:维度筛后的我司成本行;无维度筛则全部我司行 */
|
||||
export function companyRowsForStats(rows: H2OrderRow[], filter: DimFilter): H2OrderRow[] {
|
||||
if (!filter) return companyCostRows(rows);
|
||||
return applyDimFilter(rows, filter);
|
||||
}
|
||||
|
||||
export interface StationMonthRow {
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
month: string;
|
||||
amount: number;
|
||||
quantityKg: number;
|
||||
unverifiedAmount: number;
|
||||
}
|
||||
|
||||
export function stationMonthAgg(rows: H2OrderRow[]): StationMonthRow[] {
|
||||
const map = new Map<string, H2OrderRow[]>();
|
||||
rows.forEach((r) => {
|
||||
const month = r.occurredAt.slice(0, 7);
|
||||
const key = `${r.stationId}|${month}`;
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(r);
|
||||
map.set(key, list);
|
||||
});
|
||||
return Array.from(map.entries())
|
||||
.map(([key, list]) => {
|
||||
const [stationId, month] = key.split('|');
|
||||
return {
|
||||
stationId,
|
||||
stationName: list[0].stationName,
|
||||
month,
|
||||
amount: sumAmount(list),
|
||||
quantityKg: sumKg(list),
|
||||
unverifiedAmount: sumAmount(list.filter((x) => x.verifyStatus === 'unverified')),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
}
|
||||
|
||||
export interface CustomerAttrRow {
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
borneLabel: string;
|
||||
quantityKg: number;
|
||||
companyCost: number;
|
||||
unverifiedAmount: number;
|
||||
}
|
||||
|
||||
export function customerAttrAgg(rows: H2OrderRow[]): CustomerAttrRow[] {
|
||||
const map = new Map<string, H2OrderRow[]>();
|
||||
rows.forEach((r) => {
|
||||
const list = map.get(r.customerId) ?? [];
|
||||
list.push(r);
|
||||
map.set(r.customerId, list);
|
||||
});
|
||||
return Array.from(map.entries())
|
||||
.map(([customerId, list]) => {
|
||||
const company = list.filter((x) => x.borneBy === 'company');
|
||||
const customer = list.filter((x) => x.borneBy === 'customer');
|
||||
let borneLabel = '混合';
|
||||
if (company.length && !customer.length) borneLabel = '我司';
|
||||
else if (customer.length && !company.length) borneLabel = '客户';
|
||||
return {
|
||||
customerId,
|
||||
customerName: list[0].customerName,
|
||||
borneLabel,
|
||||
quantityKg: sumKg(list),
|
||||
companyCost: sumAmount(company),
|
||||
unverifiedAmount: sumAmount(list.filter((x) => x.verifyStatus === 'unverified')),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.companyCost - a.companyCost || b.quantityKg - a.quantityKg);
|
||||
}
|
||||
|
||||
export const SOURCE_LABEL: Record<H2OrderRow['source'], string> = {
|
||||
api: 'API',
|
||||
manual: '补录',
|
||||
fence: '围栏',
|
||||
};
|
||||
|
||||
/** 总览 KPI:在宿主示意量级上按当前筛选(年/车辆/核对)等比缩放,保证卡片跟随顶栏筛选 */
|
||||
export function computeHostKpi(
|
||||
filtered: H2OrderRow[],
|
||||
year: number,
|
||||
allOrders: H2OrderRow[],
|
||||
base: {
|
||||
totalKgT: number;
|
||||
companyKgT: number;
|
||||
customerKgT: number;
|
||||
totalFeeWan: number;
|
||||
companyFeeWan: number;
|
||||
customerFeeWan: number;
|
||||
profitWan: number;
|
||||
incomeWan: number;
|
||||
costWan: number;
|
||||
monthKgT: number;
|
||||
monthFeeWan: number;
|
||||
monthYearPct: number;
|
||||
dayKg: number;
|
||||
dayFee: number;
|
||||
dayMonthPct: number;
|
||||
},
|
||||
) {
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
const baseline = filterOrders(allOrders, year, 'all', 'all');
|
||||
const baseKg = sumKg(baseline) || 1;
|
||||
const fKg = sumKg(filtered);
|
||||
const ratio = fKg / baseKg;
|
||||
|
||||
const companyKg = sumKg(filtered.filter((r) => r.borneBy === 'company'));
|
||||
const customerKg = sumKg(filtered.filter((r) => r.borneBy === 'customer'));
|
||||
const split = companyKg + customerKg || 1;
|
||||
const companyShare = companyKg / split;
|
||||
const customerShare = customerKg / split;
|
||||
|
||||
const monthRows = filtered.filter((r) => r.occurredAt.startsWith(`${year}-08`));
|
||||
const dayRows = filtered.filter((r) => r.occurredAt.startsWith(`${year}-08-08`));
|
||||
const monthKg = sumKg(monthRows);
|
||||
const dayKgVal = sumKg(dayRows);
|
||||
const monthAmt = sumAmount(monthRows);
|
||||
const dayAmt = sumAmount(dayRows);
|
||||
const yearKg = fKg || 1;
|
||||
const monthKgShare = monthKg / yearKg;
|
||||
const dayMonthShare = monthKg > 0 ? dayKgVal / monthKg : 0;
|
||||
|
||||
const totalKgT = round2(base.totalKgT * ratio);
|
||||
const totalFeeWan = round2(base.totalFeeWan * ratio);
|
||||
const incomeWan = round2(base.incomeWan * ratio);
|
||||
const costWan = round2(base.costWan * ratio);
|
||||
const profitWan = round2(base.profitWan * ratio);
|
||||
const monthKgT = round2(totalKgT * monthKgShare);
|
||||
const monthFeeWan = round2(totalFeeWan * monthKgShare);
|
||||
|
||||
return {
|
||||
totalKgT,
|
||||
companyKgT: round2(totalKgT * companyShare),
|
||||
customerKgT: round2(totalKgT * customerShare),
|
||||
totalFeeWan,
|
||||
companyFeeWan: round2(totalFeeWan * companyShare),
|
||||
customerFeeWan: round2(totalFeeWan * customerShare),
|
||||
profitWan,
|
||||
incomeWan,
|
||||
costWan,
|
||||
monthKgT,
|
||||
monthFeeWan,
|
||||
monthYearPct: round2(monthKgShare * 100),
|
||||
dayKg: round2(dayKgVal > 0 ? dayKgVal : base.dayKg * ratio * Math.max(dayMonthShare, 0.01)),
|
||||
dayFee: Math.round(dayAmt > 0 ? dayAmt : base.dayFee * ratio * Math.max(dayMonthShare, 0.01)),
|
||||
dayMonthPct: round2((dayMonthShare || base.dayMonthPct / 100) * 100),
|
||||
profitRatePct: incomeWan > 0 ? round2((profitWan / incomeWan) * 100) : 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { H2OrderRow, StationPrepaid } from '../types';
|
||||
|
||||
/** 辅助生成 200 条逼真高质量订单明细假数据 */
|
||||
function generate200MockOrders(): H2OrderRow[] {
|
||||
const stations = [
|
||||
{ id: 'st-ln', name: '佛山南海羚牛加氢站' },
|
||||
{ id: 'st-dp', name: '东鹏大道甲醇制氢一体站' },
|
||||
{ id: 'st-jx', name: '嘉兴中石化滨海加氢站' },
|
||||
{ id: 'st-jj', name: '嘉兴嘉锦加氢站' },
|
||||
{ id: 'st-cd', name: '成都中石化天府机场高速北站加氢站' },
|
||||
{ id: 'st-gz', name: '广州黄埔高新区氢能示范加氢站' },
|
||||
{ id: 'st-sh', name: '上海安亭加氢站' },
|
||||
];
|
||||
|
||||
const ownPlates = [
|
||||
'粤A99887', '粤B12001', '浙A52088', '浙F77881', '浙A88888F',
|
||||
'浙A66666', '浙F11223', '浙F33445', '川A77889', '川B99001',
|
||||
'沪A33219', '粤B88102', '浙F99812', '浙F66521', '粤A11029',
|
||||
];
|
||||
|
||||
const extPlates = [
|
||||
'粤A77661', '浙F33211', '川A55432', '沪B98765', '粤B66554',
|
||||
'浙A22334', '粤A99102', '川B88761', '无车牌(散车)',
|
||||
];
|
||||
|
||||
const customers = [
|
||||
{ id: 'c-ln', name: '羚牛自营', type: 'internal', deptId: 'd-ops', deptName: '运维中心' },
|
||||
{ id: 'c-bao', name: '包氢专线项目', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' },
|
||||
{ id: 'c-lease-a', name: '嘉兴智奇供应链', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' },
|
||||
{ id: 'c-log', name: '嘉兴益顺冷链', type: 'internal', deptId: 'd-log', deptName: '物流中心' },
|
||||
{ id: 'c-log2', name: '四川群彬物流', type: 'internal', deptId: 'd-log', deptName: '物流中心' },
|
||||
{ id: 'c-lease-b', name: '无锡铭康物流', type: 'internal', deptId: 'd-lease', deptName: '租赁业务二部' },
|
||||
{ id: 'c-ops', name: '运维调拨车辆', type: 'internal', deptId: 'd-ops', deptName: '运维中心' },
|
||||
{ id: 'c-pend', name: '待归属样本', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' },
|
||||
{ id: 'c-ext-a', name: '广东氢动力', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
|
||||
{ id: 'c-ext-b', name: '广东清运专线', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
|
||||
{ id: 'c-ext-c', name: '东展供应链', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
|
||||
{ id: 'c-ext-d', name: '顺丰冷运专线', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
|
||||
{ id: 'c-ext-e', name: '极兔速递冷链', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
|
||||
];
|
||||
|
||||
const list: H2OrderRow[] = [];
|
||||
|
||||
// 生成 200 条记录
|
||||
for (let i = 1; i <= 200; i++) {
|
||||
const padId = String(i).padStart(3, '0');
|
||||
|
||||
// 年份分配: 1~145 (2026年), 146~185 (2025年), 186~200 (2024年)
|
||||
let year = 2026;
|
||||
let month = Math.floor((i % 8)) + 1; // 1~8月
|
||||
if (i > 145 && i <= 185) {
|
||||
year = 2025;
|
||||
month = Math.floor((i % 12)) + 1;
|
||||
} else if (i > 185) {
|
||||
year = 2024;
|
||||
month = Math.floor((i % 12)) + 1;
|
||||
}
|
||||
|
||||
const day = (i * 7 % 28) + 1;
|
||||
const hour = (i * 3 % 14) + 7;
|
||||
const minute = (i * 11 % 50) + 5;
|
||||
|
||||
const mm = month < 10 ? `0${month}` : `${month}`;
|
||||
const dd = day < 10 ? `0${day}` : `${day}`;
|
||||
const hh = hour < 10 ? `0${hour}` : `${hour}`;
|
||||
const min = minute < 10 ? `0${minute}` : `${minute}`;
|
||||
|
||||
const occurredAt = `${year}-${mm}-${dd} ${hh}:${min}`;
|
||||
const station = stations[i % stations.length];
|
||||
const customer = customers[i % customers.length];
|
||||
|
||||
const isOwn = customer.type === 'internal';
|
||||
const fleet = isOwn ? 'own' : 'external';
|
||||
const plateNo = isOwn
|
||||
? ownPlates[i % ownPlates.length]
|
||||
: extPlates[i % extPlates.length];
|
||||
|
||||
// 单价 & 加氢量
|
||||
const unitPrice = [28, 30, 32, 35][i % 4];
|
||||
const quantityKg = Math.round((18 + (i * 3.7 % 85)) * 100) / 100;
|
||||
const amount = Math.round(quantityKg * unitPrice);
|
||||
|
||||
// 成本维度与费用承担
|
||||
let borneBy: 'company' | 'customer' = 'company';
|
||||
let costDim: 'lease' | 'logistics' | 'ops' | 'pending' = 'lease';
|
||||
let leaseKind: 'company_borne' | 'package_h2' | undefined = undefined;
|
||||
let opsKind: 'abnormal' | 'transfer' | undefined = undefined;
|
||||
|
||||
if (customer.id === 'c-ext-a' || customer.id === 'c-ext-d') {
|
||||
borneBy = 'customer';
|
||||
}
|
||||
|
||||
const dimType = i % 5;
|
||||
if (dimType === 0) {
|
||||
costDim = 'lease';
|
||||
leaseKind = 'company_borne';
|
||||
} else if (dimType === 1) {
|
||||
costDim = 'lease';
|
||||
leaseKind = 'package_h2';
|
||||
} else if (dimType === 2) {
|
||||
costDim = 'logistics';
|
||||
} else if (dimType === 3) {
|
||||
costDim = 'ops';
|
||||
opsKind = i % 2 === 0 ? 'abnormal' : 'transfer';
|
||||
} else {
|
||||
costDim = 'pending';
|
||||
}
|
||||
|
||||
// 核对状态与数据来源
|
||||
const verifyStatus = isOwn ? (i % 4 === 0 ? 'unverified' : 'verified') : 'unverified';
|
||||
const source = isOwn
|
||||
? (i % 3 === 0 ? 'manual' : i % 3 === 1 ? 'fence' : 'api')
|
||||
: (i % 2 === 0 ? 'api' : 'manual');
|
||||
|
||||
list.push({
|
||||
id: `HO-${String(year).slice(2)}${mm}-${padId}`,
|
||||
occurredAt,
|
||||
stationId: station.id,
|
||||
stationName: station.name,
|
||||
plateNo,
|
||||
customerId: customer.id,
|
||||
customerName: customer.name,
|
||||
deptId: customer.deptId,
|
||||
deptName: customer.deptName,
|
||||
amount,
|
||||
quantityKg,
|
||||
unitPrice,
|
||||
borneBy,
|
||||
costDim,
|
||||
leaseKind,
|
||||
opsKind,
|
||||
verifyStatus,
|
||||
source,
|
||||
fleet,
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 假数:200 条订单明细(三维度成本拆分) */
|
||||
export const MOCK_ORDERS: H2OrderRow[] = generate200MockOrders();
|
||||
|
||||
export const MOCK_PREPAID: StationPrepaid[] = [
|
||||
{
|
||||
stationId: 'st-jx',
|
||||
stationName: '嘉兴中石化滨海加氢站',
|
||||
openingBalance: 120000,
|
||||
openingAnchorLabel: '2025 年末财务期末',
|
||||
recharge: 80000,
|
||||
consume: 95000,
|
||||
},
|
||||
{
|
||||
stationId: 'st-jj',
|
||||
stationName: '嘉兴嘉锦加氢站',
|
||||
openingBalance: null,
|
||||
openingAnchorLabel: null,
|
||||
recharge: 40000,
|
||||
consume: 28000,
|
||||
},
|
||||
];
|
||||
|
||||
/** 宿主总览 KPI(与 zip content 同量级示意,只读壳) */
|
||||
export const HOST_KPI = {
|
||||
totalKgT: 697.16,
|
||||
companyKgT: 468.28,
|
||||
customerKgT: 228.88,
|
||||
totalFeeWan: 2093.71,
|
||||
companyFeeWan: 1362.85,
|
||||
customerFeeWan: 730.87,
|
||||
profitWan: 13.01,
|
||||
incomeWan: 743.88,
|
||||
costWan: 2093.71,
|
||||
monthKgT: 16.67,
|
||||
monthFeeWan: 50.36,
|
||||
monthYearPct: 2.4,
|
||||
dayKg: 181.78,
|
||||
dayFee: 6533,
|
||||
dayMonthPct: 1.1,
|
||||
};
|
||||
|
||||
export const DEFAULT_YEAR = 2026;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @name 能源氢费经营看板
|
||||
* @description 嵌入 bi-next #hydrogen/overview · 我司成本三维度(非 OneOS V2) · 口令 lingniu
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import {
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
} from '@axhub/annotation';
|
||||
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
||||
import { EnergyBiAccessGate, isEnergyBiAuthed } from './EnergyBiAccessGate';
|
||||
import { EnergyBiBoardApp } from './EnergyBiBoardApp';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
|
||||
function AuthedEnergyBiBoard() {
|
||||
const [ok, setOk] = useState(() => isEnergyBiAuthed());
|
||||
if (!ok) return <EnergyBiAccessGate onOk={() => setOk(true)} />;
|
||||
return <EnergyBiBoardApp />;
|
||||
}
|
||||
|
||||
export default function EnergyH2BiBoardEntry() {
|
||||
useEffect(() => {
|
||||
clearHostPrototypeRouteInfo();
|
||||
}, []);
|
||||
|
||||
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||
() => ({ title: '能源氢费经营看板' }),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PrototypeAnnotationHost
|
||||
source={annotationSourceDocument as unknown as AnnotationSourceDocument}
|
||||
options={annotationOptions}
|
||||
>
|
||||
<AuthedEnergyBiBoard />
|
||||
</PrototypeAnnotationHost>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const container = document.getElementById('root');
|
||||
if (container && !container.dataset.energyH2BiBoardMounted) {
|
||||
container.dataset.energyH2BiBoardMounted = '1';
|
||||
const root = createRoot(container);
|
||||
root.render(<EnergyH2BiBoardEntry />);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": 1,
|
||||
"format": "axhub-published-source",
|
||||
"sourceRoot": "source",
|
||||
"entry": "index.tsx",
|
||||
"files": [
|
||||
{
|
||||
"path": "annotation-source.json",
|
||||
"kind": "source"
|
||||
},
|
||||
{
|
||||
"path": "data/aggregates.ts",
|
||||
"kind": "source"
|
||||
},
|
||||
{
|
||||
"path": "data/mockBoard.ts",
|
||||
"kind": "source"
|
||||
},
|
||||
{
|
||||
"path": "data/mockDaily.ts",
|
||||
"kind": "source"
|
||||
},
|
||||
{
|
||||
"path": "EnergyBiAccessGate.tsx",
|
||||
"kind": "source"
|
||||
},
|
||||
{
|
||||
"path": "EnergyBiBoardApp.tsx",
|
||||
"kind": "source"
|
||||
},
|
||||
{
|
||||
"path": "index.tsx",
|
||||
"kind": "entry"
|
||||
},
|
||||
{
|
||||
"path": "styles/energy-bi-board.css",
|
||||
"kind": "source"
|
||||
},
|
||||
{
|
||||
"path": "types.ts",
|
||||
"kind": "source"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
/** 能源 BI · 氢能总览嵌入功能 · 类型(宿主 bi-next #hydrogen/overview,非 OneOS V2) */
|
||||
|
||||
/** 我司成本三维度(本尊 2026-08-07) */
|
||||
export type CostDim = 'lease' | 'logistics' | 'ops' | 'pending';
|
||||
|
||||
/** 租赁成本二级 */
|
||||
export type LeaseKind = 'company_borne' | 'package_h2';
|
||||
|
||||
/** 运维成本二级 */
|
||||
export type OpsKind = 'abnormal' | 'transfer';
|
||||
|
||||
export type VerifyStatus = 'verified' | 'unverified';
|
||||
export type BorneBy = 'company' | 'customer';
|
||||
export type FleetScope = 'own' | 'external' | 'all';
|
||||
/** 按日 | 总览(站日报 / 现结登记已拆独立模块) */
|
||||
export type HostView = 'daily' | 'overview';
|
||||
|
||||
export interface H2OrderRow {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
plateNo: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
deptId: string;
|
||||
deptName: string;
|
||||
amount: number;
|
||||
quantityKg: number;
|
||||
unitPrice: number;
|
||||
borneBy: BorneBy;
|
||||
costDim: CostDim;
|
||||
leaseKind?: LeaseKind;
|
||||
opsKind?: OpsKind;
|
||||
verifyStatus: VerifyStatus;
|
||||
source: 'api' | 'manual' | 'fence';
|
||||
fleet: 'own' | 'external';
|
||||
}
|
||||
|
||||
export interface StationPrepaid {
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
openingBalance: number | null;
|
||||
openingAnchorLabel: string | null;
|
||||
recharge: number;
|
||||
consume: number;
|
||||
}
|
||||
|
||||
export const COST_DIM_LABEL: Record<CostDim, string> = {
|
||||
lease: '租赁成本',
|
||||
logistics: '物流成本',
|
||||
ops: '运维成本',
|
||||
pending: '待归属',
|
||||
};
|
||||
|
||||
export const LEASE_KIND_LABEL: Record<LeaseKind, string> = {
|
||||
company_borne: '我司承担',
|
||||
package_h2: '包氢项目',
|
||||
};
|
||||
|
||||
export const OPS_KIND_LABEL: Record<OpsKind, string> = {
|
||||
abnormal: '异动',
|
||||
transfer: '调拨',
|
||||
};
|
||||
@@ -0,0 +1,905 @@
|
||||
/* Exact station-overview primitives carried from the supplied prototype. */
|
||||
.ehb-shell--station-daily,
|
||||
.sd-embedded {
|
||||
--sd-ink: #0f172a;
|
||||
--sd-muted: #64748b;
|
||||
--sd-tertiary: #94a3b8;
|
||||
--sd-line: rgba(15, 23, 42, 0.08);
|
||||
--sd-cyan: #2563eb;
|
||||
--sd-cyan-soft: #eff6ff;
|
||||
--sd-surface: #fff;
|
||||
--sd-font:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC",
|
||||
"Microsoft YaHei", "Noto Sans SC", sans-serif;
|
||||
--sd-mono:
|
||||
"JetBrains Mono", "Cascadia Mono", Consolas, "SF Mono", ui-monospace,
|
||||
monospace;
|
||||
color: #1e293b;
|
||||
font-family: var(--sd-font);
|
||||
}
|
||||
.sd-embedded {
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.sd-topbar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.sd-topbar--embedded {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.sd-topbar__updated {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--sd-mono);
|
||||
}
|
||||
.sd-topbar__tools {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sd-date {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.sd-date--right .sd-date__popover {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
.sd-date__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
height: 30px;
|
||||
padding: 0 10px 0 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--sd-ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
.sd-date__trigger:hover,
|
||||
.sd-date__trigger.is-open {
|
||||
border-color: #2563eb;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 2px #2563eb1f;
|
||||
}
|
||||
|
||||
.sd-date__trigger:focus-visible,
|
||||
.sd-btn:focus-visible,
|
||||
.sd-hero-kpi--click:focus-visible,
|
||||
.sd-share-seg:focus-visible,
|
||||
.sd-share-legend__btn:focus-visible,
|
||||
.sd-station-row:focus-visible,
|
||||
.sd-board-mode > button[role="tab"]:focus-visible,
|
||||
.sd-board-station-select:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.72);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.sd-date__label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--sd-muted);
|
||||
}
|
||||
.sd-date__value {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: var(--sd-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0f172a;
|
||||
}
|
||||
.sd-date__icon {
|
||||
color: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sd-date__popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
z-index: 40;
|
||||
width: 300px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
0 18px 40px -18px #0f172a47,
|
||||
0 8px 16px -10px #0284c72e;
|
||||
}
|
||||
.sd-date__header { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; }
|
||||
.sd-date__title { font-size:14px; font-weight:800; color:var(--sd-ink); letter-spacing:-.02em; }
|
||||
.sd-date__nav { display:inline-flex; align-items:center; justify-content:center; width:30px; height:30px; border:none; border-radius:8px; background:#f1f5f9; color:#475569; cursor:pointer; }
|
||||
.sd-date__week { display:grid; grid-template-columns:repeat(7,1fr); margin-bottom:6px; text-align:center; font-size:11px; font-weight:700; color:#94a3b8; }
|
||||
.sd-date__grid { display:grid; grid-template-columns:repeat(7,1fr); gap:3px; }
|
||||
.sd-date__day { display:inline-flex; align-items:center; justify-content:center; height:32px; border:none; border-radius:8px; background:transparent; color:#334155; font-size:13px; font-weight:600; font-variant-numeric:tabular-nums; cursor:pointer; }
|
||||
.sd-date__day.is-empty { cursor:default; pointer-events:none; }.sd-date__day.is-selected { background:var(--sd-cyan); color:#fff; font-weight:800; box-shadow:0 6px 14px -6px #0284c78c; }.sd-date__day.is-in-range:not(.is-selected) { background:#dbeafe; color:#1e40af; border-radius:0; }
|
||||
.sd-date__range-tabs { display:grid; grid-template-columns:1fr 1fr; gap:6px; margin-bottom:10px; }.sd-date__range-tabs button { min-height:32px; border:1px solid #e2e8f0; border-radius:8px; background:#fff; color:#64748b; font-size:11px; font-weight:600; font-family:var(--sd-mono); cursor:pointer; padding:4px 6px; }.sd-date__range-tabs button.is-on { border-color:#2563eb; color:#1d4ed8; background:#eff6ff; box-shadow:0 0 0 2px #2563eb1a; }
|
||||
.sd-date__today { border:none; background:transparent; color:var(--sd-cyan); font-size:12px; font-weight:700; cursor:pointer; padding:4px 6px; border-radius:6px; }.sd-date__apply { margin-left:auto; }
|
||||
.sd-date__shortcuts {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.sd-date__shortcuts button {
|
||||
flex: 1;
|
||||
height: 28px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 7px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sd-date__shortcuts button:hover {
|
||||
border-color: #93c5fd;
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
}
|
||||
.sd-date__range-fields {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.sd-date__range-fields label {
|
||||
display: grid;
|
||||
grid-template-columns: 62px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sd-date__range-fields input {
|
||||
height: 30px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 7px;
|
||||
padding: 0 7px;
|
||||
color: #0f172a;
|
||||
background: #fff;
|
||||
font: 600 12px var(--sd-mono);
|
||||
}
|
||||
.sd-date__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #eef2f7;
|
||||
}
|
||||
.sd-date__footer--range {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #64748b;
|
||||
font: 500 11px var(--sd-mono);
|
||||
}
|
||||
|
||||
/* Station detail: kept in the same DOM/class hierarchy as the supplied prototype. */
|
||||
.sd-detail-top { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; flex-wrap:wrap; margin-bottom:16px; }
|
||||
.sd-detail-top__lead { display:flex; align-items:flex-start; gap:12px; }
|
||||
.sd-detail-top__title { margin:0; font-size:16px; font-weight:700; color:var(--sd-ink); letter-spacing:-.01em; }
|
||||
.sd-detail-top__meta { margin:4px 0 0; font-size:12px; color:var(--sd-muted); font-family:var(--sd-mono); font-variant-numeric:tabular-nums; }
|
||||
.sd-detail-top__updated { margin:4px 0 0; font-size:12px; font-weight:500; color:#94a3b8; font-variant-numeric:tabular-nums; font-family:var(--sd-mono); }
|
||||
.sd-detail-top__tools { display:flex; align-items:flex-end; gap:8px; flex-wrap:wrap; }
|
||||
.sd-hero-kpis--detail { margin-bottom:16px; }
|
||||
.sd-unit { margin-left:2px; font-size:11px; font-weight:500; color:var(--sd-muted); font-family:var(--sd-font); }
|
||||
.sd-panel { background:#fff; border:1px solid var(--sd-line); border-radius:10px; padding:12px 14px; box-shadow:none; min-width:0; }
|
||||
.sd-panel--block { width:100%; margin-top:16px; }
|
||||
.sd-panel__title { margin:0; font-size:14px; font-weight:700; color:var(--sd-ink); letter-spacing:-.01em; }
|
||||
.sd-panel__head-row { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:8px; }
|
||||
.sd-table-scroll { overflow-x:auto; overflow-y:visible; margin-top:8px; max-width:100%; -webkit-overflow-scrolling:touch; }
|
||||
.sd-table-scroll--matrix { border:1px solid #e2e8f0; border-radius:8px; background:#fff; }
|
||||
.sd-bi-table { width:100%; border-collapse:separate; border-spacing:0; text-align:left; font-size:13px; min-width:0; }
|
||||
.sd-bi-table th { font-size:12px; font-weight:500; color:var(--sd-muted); padding:8px 12px; border-bottom:1px solid var(--sd-line); white-space:nowrap; background:#f8fafc; text-align:left; }
|
||||
.sd-bi-table td { font-size:13px; font-weight:400; color:#334155; padding:8px 12px; border-bottom:1px solid var(--sd-line); white-space:nowrap; vertical-align:middle; }
|
||||
.sd-bi-table tr:last-child td { border-bottom:none; }
|
||||
.sd-bi-table td.is-num,.sd-bi-table th.is-num { text-align:right; font-variant-numeric:tabular-nums; font-family:var(--sd-mono); font-size:12px; }
|
||||
.sd-bi-table tr:hover td { background:#f8fafc; }
|
||||
.sd-bi-table tr.is-total td { font-weight:700; background:#f8fafc; color:#0f172a; }
|
||||
.sd-table-more { padding:8px !important; text-align:center; background:#f8fafc; }
|
||||
.sd-table-more button { border:1px dashed #bfdbfe; border-radius:5px; padding:4px 12px; background:#fff; color:#0284c7; font-size:12px; font-weight:700; cursor:pointer; }
|
||||
.sd-table-more button:hover { background:#eff6ff; }
|
||||
.sd-bi-table .is-mono { font-family:var(--sd-mono); font-size:12px; color:#475569; }
|
||||
.sd-bi-table--fill { width:100%; min-width:100%; }
|
||||
.sd-bi-table--matrix { width:max(100%,1080px); min-width:100%; table-layout:auto; }
|
||||
.sd-bi-table--matrix th:first-child,.sd-bi-table--matrix td:first-child { position:sticky; left:0; z-index:2; background:#fff; min-width:168px; max-width:220px; white-space:normal; word-break:break-word; box-shadow:4px 0 8px -6px #0f172a2e; }
|
||||
.sd-bi-table--matrix thead th:first-child { z-index:3; background:#f8fafc; }
|
||||
.sd-bi-table--matrix tr.is-total td:first-child,.sd-bi-table--matrix tr:hover td:first-child { background:#f8fafc; }
|
||||
.sd-bi-table--matrix th.is-num,.sd-bi-table--matrix td.is-num { min-width:72px; padding-left:6px; padding-right:8px; text-align:right; }
|
||||
.sd-bi-table--matrix.is-amount th.is-num,.sd-bi-table--matrix.is-amount td.is-num { min-width:92px; font-size:12px; }
|
||||
.is-stock-up { color:#ef4444 !important; font-weight:700; }
|
||||
.is-stock-down { color:#10b981 !important; font-weight:700; }
|
||||
.is-stock-flat { color:#64748b; }
|
||||
.sd-delta { display:inline-block; margin-left:3px; font-size:10px; line-height:1; vertical-align:middle; }
|
||||
.sd-trend { position:relative; margin-top:8px; }
|
||||
.sd-trend-legend-chip { display:inline-flex; align-items:center; gap:6px; font-size:12px; font-weight:500; color:#64748b; max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.sd-trend-legend-dot { width:8px; height:8px; border-radius:2px; background:#2563eb; flex:0 0 auto; display:inline-block; }
|
||||
.sd-trend--fill { display:flex; align-items:flex-end; gap:6px; width:100%; min-height:220px; padding:12px 4px 4px; box-sizing:border-box; }
|
||||
.sd-trend__col { flex:1 1 0; min-width:0; display:flex; flex-direction:column; align-items:center; gap:6px; }
|
||||
.sd-trend__val { font-size:11px; color:#64748b; font-variant-numeric:tabular-nums; font-family:var(--sd-mono); }
|
||||
.sd-trend__bar-wrap { width:100%; height:140px; display:flex; align-items:flex-end; justify-content:center; }
|
||||
.sd-trend__bar { width:min(42px,70%); border-radius:4px 4px 2px 2px; background:linear-gradient(180deg,#60a5fa,#2563eb); }
|
||||
.sd-trend__date { font-size:10px; color:#94a3b8; font-family:var(--sd-mono); font-variant-numeric:tabular-nums; white-space:nowrap; letter-spacing:-.02em; }
|
||||
.sd-trend__col.is-hover .sd-trend__bar { filter:brightness(1.08); outline:2px solid rgba(37,99,235,.35); }
|
||||
.sd-trend-tip { position:absolute; top:8px; right:12px; z-index:5; min-width:220px; max-width:320px; padding:10px 12px; border-radius:8px; border:1px solid #e2e8f0; background:#fffffff5; box-shadow:0 8px 20px #0f172a1f; pointer-events:none; }
|
||||
.sd-trend-tip__date { font-size:12px; font-weight:700; color:#0f172a; font-family:var(--sd-mono); margin-bottom:6px; }
|
||||
.sd-trend-tip__row { display:flex; align-items:center; gap:6px; font-size:12px; color:#334155; }
|
||||
.sd-trend-tip__name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.sd-trend-tip__row strong { font-family:var(--sd-mono); font-variant-numeric:tabular-nums; color:#0f172a; white-space:nowrap; }
|
||||
.sd-trend-tip__sub { margin-top:4px; font-size:11px; color:#94a3b8; }
|
||||
.sd-msel { position:relative; flex:0 0 auto; }
|
||||
.sd-msel__trigger { display:inline-flex; align-items:center; gap:8px; height:30px; max-width:280px; padding:0 10px; border:1px solid #cbd5e1; border-radius:8px; background:#fff; cursor:pointer; color:#0f172a; }
|
||||
.sd-msel.is-open .sd-msel__trigger,.sd-msel__trigger:hover { border-color:#2563eb; box-shadow:0 0 0 2px #2563eb1f; }
|
||||
.sd-msel__label { font-size:12px; font-weight:500; color:#64748b; flex:0 0 auto; }
|
||||
.sd-msel__value { font-size:12px; font-weight:600; color:#0f172a; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:160px; }
|
||||
.sd-msel__chev { color:#94a3b8; flex:0 0 auto; }
|
||||
.sd-msel__panel { position:absolute; top:calc(100% + 6px); right:0; z-index:40; width:min(320px,80vw); max-height:320px; display:flex; flex-direction:column; background:#fff; border:1px solid #e2e8f0; border-radius:10px; box-shadow:0 12px 28px #0f172a1f; overflow:hidden; }
|
||||
.sd-msel__search { padding:10px 10px 6px; }
|
||||
.sd-msel__search input { width:100%; height:32px; border:1px solid #e2e8f0; border-radius:8px; padding:0 10px; font-size:12px; box-sizing:border-box; }
|
||||
.sd-msel__actions { display:flex; gap:8px; padding:0 10px 8px; }
|
||||
.sd-msel__actions button { border:none; background:#f1f5f9; color:#334155; font-size:12px; font-weight:600; height:26px; padding:0 10px; border-radius:999px; cursor:pointer; }
|
||||
.sd-msel__list { list-style:none; margin:0; padding:0 0 8px; overflow:auto; flex:1; }
|
||||
.sd-msel__opt { width:100%; display:flex; align-items:flex-start; gap:8px; padding:8px 12px; border:none; background:transparent; cursor:pointer; text-align:left; }
|
||||
.sd-msel__opt:hover { background:#f8fafc; }.sd-msel__opt.is-on { background:#eff6ff; }
|
||||
.sd-msel__check { width:16px; height:16px; border-radius:4px; border:1px solid #cbd5e1; display:inline-flex; align-items:center; justify-content:center; flex:0 0 auto; margin-top:1px; color:#fff; background:#fff; }
|
||||
.sd-msel__opt.is-on .sd-msel__check { background:#2563eb; border-color:#2563eb; }
|
||||
.sd-msel__name { font-size:12px; color:#334155; line-height:1.35; }.sd-msel__empty { padding:16px; text-align:center; color:#94a3b8; font-size:12px; }
|
||||
.sd-dual { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:12px; margin-top:16px; }
|
||||
.sd-dual--cash { grid-template-columns:minmax(0,1fr) minmax(0,1fr); }
|
||||
.sd-panel--grow { min-width:0; }
|
||||
.sd-table-scroll--cash-lines { max-height:420px; overflow:auto; }
|
||||
.sd-bi-table--ledger { width:100%; min-width:520px; }
|
||||
.sd-bi-table--ledger td:first-child,.sd-bi-table--ledger th:first-child { white-space:normal; word-break:break-word; max-width:160px; }
|
||||
.sd-more-btn { display:inline-flex; align-items:center; justify-content:center; gap:4px; width:100%; margin-top:10px; height:32px; border:1px dashed #cbd5e1; border-radius:8px; background:#f8fafc; color:#475569; font-size:12px; font-weight:600; cursor:pointer; }
|
||||
.sd-more-btn:hover { border-color:#93c5fd; color:#2563eb; background:#eff6ff; }
|
||||
.sd-pending-row td { height:110px; color:#94a3b8; text-align:center; font-size:12px; background:#fff; }
|
||||
@media (max-width:767px) { .sd-detail-top__tools{width:100%;}.sd-detail-top__tools .sd-date{flex:1;}.sd-detail-top__tools .sd-date__trigger{width:100%;justify-content:space-between;}.sd-panel__head-row{flex-direction:column;align-items:stretch;}.sd-msel__trigger{max-width:none;width:100%;}.sd-msel__panel{left:0;right:0;width:auto;}.sd-trend__date{font-size:9px;} }
|
||||
@media (max-width:1024px) { .sd-dual--cash { grid-template-columns:minmax(0,1fr); } }
|
||||
.sd-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
padding: 0 11px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--sd-line);
|
||||
background: #fff;
|
||||
color: #1e293b;
|
||||
}
|
||||
.sd-btn--ghost {
|
||||
background: #fff;
|
||||
border-color: var(--sd-line);
|
||||
color: var(--sd-muted);
|
||||
}
|
||||
.sd-btn--ghost:hover {
|
||||
border-color: #2563eb59;
|
||||
color: #2563eb;
|
||||
}
|
||||
.sd-hero-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sd-hero-kpi {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border: 1px solid var(--sd-line);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.sd-hero-kpi--click {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sd-hero-kpi--click:hover,
|
||||
.sd-station-row:hover {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 0 0 2px #2563eb14;
|
||||
}
|
||||
.sd-hero-kpi__label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--sd-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.sd-hero-kpi__value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
font-family: var(--sd-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
.sd-hero-kpi__sub {
|
||||
margin-top: auto;
|
||||
font-size: 11px;
|
||||
color: var(--sd-muted);
|
||||
font-family: var(--sd-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.sd-share-panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--sd-line);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sd-share-panel__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sd-share-panel__title,
|
||||
.sd-section-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
.sd-share-panel__title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.sd-share-panel__range {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--sd-muted);
|
||||
font-family: var(--sd-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sd-share-panel__total {
|
||||
font-size: 11px;
|
||||
color: var(--sd-muted);
|
||||
font-family: var(--sd-mono);
|
||||
}
|
||||
.sd-share-panel__total strong {
|
||||
color: var(--sd-ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sd-share-track {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
min-height: 44px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sd-share-seg {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 48px;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
}
|
||||
.sd-share-seg--0 {
|
||||
background: #2563eb;
|
||||
}
|
||||
.sd-share-seg--1 {
|
||||
background: #0ea5e9;
|
||||
}
|
||||
.sd-share-seg--2 {
|
||||
background: #6366f1;
|
||||
}
|
||||
.sd-share-seg--3 {
|
||||
background: #8b5cf6;
|
||||
}
|
||||
.sd-share-seg--4 {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.sd-share-seg__pct {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--sd-mono);
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.sd-share-seg__name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
opacity: 0.92;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.sd-share-legend {
|
||||
list-style: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 0;
|
||||
row-gap: 6px;
|
||||
}
|
||||
.sd-share-legend > li:nth-child(odd) {
|
||||
padding-right: 18px;
|
||||
border-right: 1px solid var(--sd-line);
|
||||
}
|
||||
.sd-share-legend > li:nth-child(even) {
|
||||
padding-left: 18px;
|
||||
}
|
||||
.sd-share-legend__btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.sd-share-legend__swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.sd-share-legend__rank {
|
||||
min-width: 12px;
|
||||
color: var(--sd-muted);
|
||||
font-family: var(--sd-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sd-share-legend__name {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: min(22vw, 240px);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.sd-share-legend__val {
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--sd-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sd-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sd-board-mode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sd-board-mode > button[role="tab"] {
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sd-board-mode > button[role="tab"].is-on {
|
||||
border-color: #2563eb;
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
}
|
||||
.sd-board-station-select {
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 7px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
color: #0f172a;
|
||||
background: #fff;
|
||||
max-width: 180px;
|
||||
}
|
||||
.sd-station-single-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.sd-station-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.sd-station-group {
|
||||
min-width: 0;
|
||||
}
|
||||
.sd-station-group__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.sd-station-group__head h3 {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sd-station-group__head span {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sd-station-group__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 52px;
|
||||
margin: 0;
|
||||
padding: 0 14px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sd-station-row {
|
||||
width: 100%;
|
||||
border: 1px solid var(--sd-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.sd-station-row__main {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
auto minmax(120px, 1.2fr) minmax(100px, 0.8fr) minmax(280px, 2fr)
|
||||
auto;
|
||||
gap: 12px 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.sd-station-card__icon {
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
.sd-station-card__name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--sd-ink);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.sd-station-card__region {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--sd-muted);
|
||||
}
|
||||
.sd-spark {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 48px;
|
||||
padding: 4px 2px 0;
|
||||
}
|
||||
.sd-spark--empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
border: 1px dashed #e2e8f0;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sd-spark--empty:after {
|
||||
content: "本区间无加氢量";
|
||||
}
|
||||
.sd-spark__col {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.sd-spark__bar {
|
||||
width: 100%;
|
||||
border-radius: 3px 3px 1px 1px;
|
||||
background: linear-gradient(180deg, #60a5fa, #2563eb);
|
||||
min-height: 3px;
|
||||
}
|
||||
.sd-station-row__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.sd-station-row__metrics > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.sd-station-card__m-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--sd-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.sd-station-row__metrics strong {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-family: var(--sd-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0f172a;
|
||||
}
|
||||
.sd-station-row__metrics strong span {
|
||||
margin-left: 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--sd-muted);
|
||||
font-family: var(--sd-font);
|
||||
}
|
||||
.sd-station-card__share {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--sd-mono);
|
||||
}
|
||||
.sd-station-card__go {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--sd-cyan);
|
||||
background: var(--sd-cyan-soft);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.sd-hero-kpis {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.sd-station-row__main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sd-station-row__main > .sd-spark {
|
||||
flex: 1 1 140px;
|
||||
}
|
||||
.sd-station-row__metrics {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.sd-hero-kpis {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sd-hero-kpi__value {
|
||||
font-size: 18px;
|
||||
}
|
||||
.sd-station-row__metrics {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.sd-share-legend {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 触屏窄屏:保留 2×2 指标密度,并将时间与站点操作变成明确的纵向分组。 */
|
||||
@media (max-width: 560px) {
|
||||
.sd-topbar,
|
||||
.sd-topbar--embedded {
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sd-topbar__updated {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sd-topbar__tools {
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sd-date {
|
||||
min-width: 0;
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
|
||||
.sd-date__trigger {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
height: 40px;
|
||||
justify-content: flex-start;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.sd-date__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sd-date__value {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sd-date__popover,
|
||||
.sd-date--right .sd-date__popover {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
/* Shell 的移动端底部导航为固定层,日期弹层必须避开它。 */
|
||||
bottom: calc(72px + env(safe-area-inset-bottom));
|
||||
left: 12px;
|
||||
top: auto;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
max-height: calc(100dvh - 84px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sd-date__shortcuts button,
|
||||
.sd-date__range-fields input,
|
||||
.sd-btn {
|
||||
min-height: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.sd-hero-kpis {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sd-hero-kpi {
|
||||
min-height: 88px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.sd-hero-kpi__label,
|
||||
.sd-hero-kpi__sub {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sd-hero-kpi__value {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.sd-share-panel {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sd-share-panel__head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sd-share-panel__total {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.sd-section-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sd-board-mode {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sd-board-mode > button[role="tab"] {
|
||||
flex: 0 0 auto;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.sd-board-station-select {
|
||||
min-height: 40px;
|
||||
max-width: 100%;
|
||||
flex: 1 1 140px;
|
||||
}
|
||||
|
||||
.sd-station-row {
|
||||
min-height: 56px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sd-station-card__name {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.sd-date__trigger:active,
|
||||
.sd-btn:active,
|
||||
.sd-hero-kpi--click:active,
|
||||
.sd-share-seg:active,
|
||||
.sd-share-legend__btn:active,
|
||||
.sd-station-row:active,
|
||||
.sd-board-mode > button[role="tab"]:active {
|
||||
transform: scale(0.985);
|
||||
filter: brightness(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sd-date__trigger,
|
||||
.sd-btn,
|
||||
.sd-hero-kpi--click,
|
||||
.sd-share-seg,
|
||||
.sd-share-legend__btn,
|
||||
.sd-station-row,
|
||||
.sd-board-mode > button[role="tab"] {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 359px) {
|
||||
.sd-hero-kpis {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
export type H2BiScope = "global" | "station";
|
||||
export type H2BiView = "overview" | "daily";
|
||||
export type H2BiVehicleScope = "all" | "lingniu" | "external";
|
||||
export type H2BiVerifyScope = "all" | "verified";
|
||||
/** 账本 settlement_type 的承担口径;下钻、KPI 与趋势图必须使用同一口径。 */
|
||||
export type H2BiAmountScope = "all" | "customer" | "company" | "other";
|
||||
export type H2BiRegionGranularity = "province" | "city";
|
||||
|
||||
export interface H2BiQuery {
|
||||
year: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
date?: string;
|
||||
month?: string;
|
||||
stationId?: string | number | null;
|
||||
customerId?: number | null;
|
||||
customerName?: string | null;
|
||||
plateNo?: string | null;
|
||||
region?: string | null;
|
||||
regionGranularity?: H2BiRegionGranularity;
|
||||
vehicleScope: H2BiVehicleScope;
|
||||
verifyScope: H2BiVerifyScope;
|
||||
}
|
||||
|
||||
export interface H2BiRange {
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
}
|
||||
|
||||
export interface H2BiWatermark {
|
||||
ledgerAt: string | null;
|
||||
paymentAt: string | null;
|
||||
}
|
||||
|
||||
export interface H2BiStationOption {
|
||||
id: string | number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface H2BiMetaResponse {
|
||||
years: Array<{ value: number; startDate: string; endDate: string }>;
|
||||
stations: H2BiStationOption[];
|
||||
watermark: H2BiWatermark;
|
||||
}
|
||||
|
||||
export interface H2BiKpis {
|
||||
totalKg: number;
|
||||
totalCost: number;
|
||||
/** 加氢量:账本 settlement_type=1(客户承担)。 */
|
||||
customerBearingKg: number;
|
||||
/** 加氢量:账本 settlement_type=2(我司承担)。 */
|
||||
companyBearingKg: number;
|
||||
/** 加氢量:其他承担方式(含客户自行结算、未知)。 */
|
||||
otherBearingKg: number;
|
||||
/** 对客金额:账本 settlement_type=1 的 fee_total。 */
|
||||
customerRevenue: number;
|
||||
/** 成本金额:账本 settlement_type=1 的 cost_total。 */
|
||||
customerCost: number;
|
||||
/** 成本金额:账本 settlement_type=2 的 cost_total。 */
|
||||
companyCost: number;
|
||||
/** 成本金额:其他承担方式的 cost_total。 */
|
||||
otherCost: number;
|
||||
totalRevenue: number;
|
||||
customerGrossProfit: number;
|
||||
monthKg: number;
|
||||
monthCost: number;
|
||||
todayKg: number;
|
||||
todayCost: number;
|
||||
monthShareOfRange: number;
|
||||
todayShareOfMonth: number;
|
||||
recordCount: number;
|
||||
stationCount: number;
|
||||
}
|
||||
|
||||
export interface H2BiMonthlyPoint {
|
||||
month: string;
|
||||
totalKg: number;
|
||||
lingniuKg: number;
|
||||
externalKg: number;
|
||||
cost: number;
|
||||
customerCost: number;
|
||||
companyCost: number;
|
||||
otherCost: number;
|
||||
revenue: number;
|
||||
customerRevenue: number;
|
||||
customerGrossProfit: number;
|
||||
}
|
||||
|
||||
export interface H2BiRegionRow {
|
||||
region: string;
|
||||
kg: number;
|
||||
share: number;
|
||||
}
|
||||
|
||||
export interface H2BiStationRow {
|
||||
rank?: number;
|
||||
id: string | number | null;
|
||||
name: string;
|
||||
province: string | null;
|
||||
city: string | null;
|
||||
kg: number;
|
||||
lingniuKg: number;
|
||||
externalKg: number;
|
||||
cost: number;
|
||||
revenue: number;
|
||||
/** 对客金额:账本 settlement_type=1 的 fee_total。 */
|
||||
customerRevenue: number;
|
||||
customerCost: number;
|
||||
companyCost: number;
|
||||
otherCost: number;
|
||||
recordCount: number;
|
||||
customerCount: number;
|
||||
share: number;
|
||||
}
|
||||
|
||||
export interface H2BiCustomerRow {
|
||||
rank?: number;
|
||||
id: number | null;
|
||||
name: string;
|
||||
kg: number;
|
||||
/** 加氢订单中账本 settlement_type=1(客户承担)的量。 */
|
||||
customerBearingKg: number;
|
||||
/** 加氢订单中账本 settlement_type=2(我司承担)的量。 */
|
||||
companyBearingKg: number;
|
||||
/** 其他承担方式(含客户自行结算、未知)的量。 */
|
||||
otherBearingKg: number;
|
||||
bearer: "company" | "customer" | "both" | "other";
|
||||
cost: number;
|
||||
revenue: number;
|
||||
customerRevenue: number;
|
||||
customerCost: number;
|
||||
companyCost: number;
|
||||
otherCost: number;
|
||||
recordCount: number;
|
||||
}
|
||||
|
||||
export interface H2BiOverviewResponse {
|
||||
range: H2BiRange;
|
||||
watermark: H2BiWatermark;
|
||||
filters: Partial<H2BiQuery>;
|
||||
kpis: H2BiKpis;
|
||||
monthly: H2BiMonthlyPoint[];
|
||||
topStations: H2BiStationRow[];
|
||||
regions: H2BiRegionRow[];
|
||||
stations: H2BiStationRow[];
|
||||
customers: H2BiCustomerRow[];
|
||||
}
|
||||
|
||||
export interface H2BiDailyPoint {
|
||||
date: string;
|
||||
kg: number;
|
||||
lingniuKg: number;
|
||||
externalKg: number;
|
||||
cost: number;
|
||||
recordCount: number;
|
||||
}
|
||||
|
||||
export interface H2BiDailyRow extends H2BiDailyPoint {
|
||||
stationCount?: number;
|
||||
}
|
||||
|
||||
export interface H2BiDailyKpis {
|
||||
totalKg: number;
|
||||
totalCost: number;
|
||||
averageDailyKg: number;
|
||||
stationCount: number;
|
||||
activeDays: number;
|
||||
}
|
||||
|
||||
export interface H2BiDailyResponse {
|
||||
range: H2BiRange;
|
||||
watermark: H2BiWatermark;
|
||||
filters: Partial<H2BiQuery>;
|
||||
kpis: H2BiDailyKpis;
|
||||
trend: H2BiDailyPoint[];
|
||||
days: H2BiDailyRow[];
|
||||
}
|
||||
|
||||
export type H2BiDrillMetric =
|
||||
| "totalKg"
|
||||
| "totalCost"
|
||||
| "totalRevenue"
|
||||
| "customerGrossProfit"
|
||||
| "monthKg"
|
||||
| "todayKg"
|
||||
| "station"
|
||||
| "customer"
|
||||
| "day";
|
||||
export type H2BiDrillGroupBy =
|
||||
"station" | "customer" | "date" | "vehicle" | "record";
|
||||
|
||||
export interface H2BiDrillQuery extends H2BiQuery {
|
||||
groupBy: H2BiDrillGroupBy;
|
||||
amountScope?: H2BiAmountScope;
|
||||
customerId?: number | null;
|
||||
plateNo?: string | null;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface H2BiDrillSummary {
|
||||
label?: string;
|
||||
value?: number;
|
||||
count?: number;
|
||||
[key: string]: string | number | null | undefined;
|
||||
}
|
||||
|
||||
export type H2BiDrillRecord = Record<
|
||||
string,
|
||||
string | number | boolean | null | undefined
|
||||
>;
|
||||
|
||||
export interface H2BiDrillGroupRow {
|
||||
[key: string]: string | number | boolean | null | undefined;
|
||||
id: string;
|
||||
name: string;
|
||||
province: string | null;
|
||||
city: string | null;
|
||||
recordCount: number;
|
||||
stationCount: number;
|
||||
customerCount: number;
|
||||
kg: number;
|
||||
cost: number;
|
||||
revenue: number;
|
||||
lingniuKg: number;
|
||||
externalKg: number;
|
||||
}
|
||||
|
||||
export interface H2BiDrillResponse {
|
||||
groupBy: H2BiDrillGroupBy;
|
||||
amountScope: H2BiAmountScope;
|
||||
filters: Partial<H2BiQuery>;
|
||||
summary: H2BiDrillSummary;
|
||||
groups: H2BiDrillGroupRow[];
|
||||
records: H2BiDrillRecord[];
|
||||
page: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface H2BiDailyTreeCustomer {
|
||||
id: number;
|
||||
name: string;
|
||||
kg: number;
|
||||
cost: number;
|
||||
recordCount: number;
|
||||
}
|
||||
|
||||
export interface H2BiDailyTreeStation {
|
||||
id: string | number;
|
||||
name: string;
|
||||
kg: number;
|
||||
cost: number;
|
||||
recordCount: number;
|
||||
customers: H2BiDailyTreeCustomer[];
|
||||
}
|
||||
|
||||
export interface H2BiDailyTreeResponse {
|
||||
date: string;
|
||||
stations: H2BiDailyTreeStation[];
|
||||
}
|
||||
|
||||
export type H2BiDailyTreeQuery = Omit<
|
||||
Partial<H2BiQuery>,
|
||||
"date" | "startDate" | "endDate" | "month"
|
||||
>;
|
||||
@@ -0,0 +1,375 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronRight, Download, RefreshCw } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import TrendBadge from '../../TrendBadge';
|
||||
import type { HydrogenDailyDetailCustomer, HydrogenDailyDetailStation, HydrogenDailyRow } from '../../types';
|
||||
import type { DailyDetailState } from '../../HydrogenDaily';
|
||||
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
|
||||
|
||||
type DailySortKey = 'date' | 'price' | 'kg' | 'fee' | 'chainPct';
|
||||
|
||||
interface DailyDetailTableProps {
|
||||
rows: HydrogenDailyRow[];
|
||||
totalKg: number;
|
||||
totalFee: number;
|
||||
expanded: Set<string>;
|
||||
highlightedDate?: string | null;
|
||||
details: Record<string, DailyDetailState>;
|
||||
onToggle: (date: string) => void;
|
||||
onRetryDetail: (date: string) => void;
|
||||
}
|
||||
|
||||
export function DailyDetailTable({
|
||||
rows,
|
||||
totalKg,
|
||||
totalFee,
|
||||
expanded,
|
||||
highlightedDate,
|
||||
details,
|
||||
onToggle,
|
||||
onRetryDetail,
|
||||
}: DailyDetailTableProps) {
|
||||
const [sortKey, setSortKey] = useState<DailySortKey>('date');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const sortedRows = useMemo(() => sortBy(rows, sortKey, sortDirection, (row, key) => {
|
||||
if (key === 'price') return row.totalKg > 0 ? row.totalFee / row.totalKg : 0;
|
||||
return row[key === 'kg' ? 'totalKg' : key === 'fee' ? 'totalFee' : key];
|
||||
}), [rows, sortDirection, sortKey]);
|
||||
const changeSort = (nextKey: DailySortKey) => {
|
||||
const next = toggleSort(sortKey, sortDirection, nextKey);
|
||||
setSortKey(next.key);
|
||||
setSortDirection(next.direction);
|
||||
};
|
||||
return (
|
||||
<section className="overflow-hidden rounded-[14px] border border-slate-200/70 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-slate-100 px-4 py-3">
|
||||
<h2 className="text-[14px] font-bold text-slate-800">
|
||||
每日加氢数据明细
|
||||
<span className="ml-1 text-[11px] font-normal text-slate-400">(日期 → 加氢站 → 客户 → 车辆/来源)</span>
|
||||
</h2>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="hidden text-[11px] font-medium text-slate-400 sm:inline">{rows.length} 天</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportDailyRows(rows, details)}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 text-[11px] font-semibold text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
<Download size={13} />导出 Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[780px]">
|
||||
<div className="grid grid-cols-[minmax(250px,1fr)_110px_120px_110px_100px] gap-3 bg-slate-50 px-3 py-2 text-[11px] font-medium text-slate-500">
|
||||
<span><SortableColumnHeader label="日期 / 加氢站" sortKey="date" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></span>
|
||||
<span className="text-right"><SortableColumnHeader label="单价 (元/Kg)" sortKey="price" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></span>
|
||||
<span className="text-right"><SortableColumnHeader label="加氢量 (Kg)" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></span>
|
||||
<span className="text-right"><SortableColumnHeader label="成本 / 环比" sortKey="fee" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></span>
|
||||
<span className="text-right">站点余额</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[minmax(250px,1fr)_110px_120px_110px_100px] gap-3 bg-blue-50/60 px-3 py-2 text-[12px] font-semibold text-blue-700">
|
||||
<span>合计</span>
|
||||
<span />
|
||||
<span className="text-right font-mono tabular-nums">{formatNumber(totalKg, 2)}</span>
|
||||
<span className="text-right font-mono tabular-nums">¥{formatNumber(totalFee, 0)}</span>
|
||||
<span className="text-right text-[10px] font-medium text-slate-400">以源表为准</span>
|
||||
</div>
|
||||
{sortedRows.map(row => {
|
||||
const open = expanded.has(row.date);
|
||||
const highlighted = highlightedDate === row.date;
|
||||
const abnormal = Math.abs(row.chainPct) >= 0.3;
|
||||
const background = highlighted
|
||||
? 'bg-sky-50 ring-1 ring-inset ring-sky-200'
|
||||
: abnormal
|
||||
? row.chainPct > 0 ? 'bg-emerald-50/35' : 'bg-red-50/35'
|
||||
: '';
|
||||
return (
|
||||
<div key={row.date} id={`hydrogen-daily-row-${row.date}`} className={`border-t border-slate-100 ${background}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(row.date)}
|
||||
className="grid w-full grid-cols-[minmax(250px,1fr)_110px_120px_110px_100px] gap-3 px-3 py-2.5 text-left transition-colors hover:bg-slate-50/60"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-[12px] font-semibold text-slate-700">
|
||||
<ChevronRight size={14} className={`text-sky-600 transition-transform ${open ? 'rotate-90' : ''}`} />
|
||||
<span className="font-mono tabular-nums">{row.date}</span>
|
||||
<span className="text-[10px] font-normal text-slate-400">({row.stations.length} 个加氢站)</span>
|
||||
</span>
|
||||
<span className="text-right text-[12px] text-slate-300">—</span>
|
||||
<span className="text-right font-mono text-[12px] font-semibold tabular-nums text-slate-800">{formatNumber(row.totalKg, 2)}</span>
|
||||
<span className="text-right"><TrendBadge value={row.chainPct} /></span>
|
||||
<span className="text-right text-[10px] text-slate-400">展开查看</span>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
<StationRows
|
||||
date={row.date}
|
||||
fallbackStations={row.stations}
|
||||
detail={details[row.date]}
|
||||
onRetry={() => onRetryDetail(row.date)}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface StationRowsProps {
|
||||
date: string;
|
||||
fallbackStations: HydrogenDailyRow['stations'];
|
||||
detail?: DailyDetailState;
|
||||
onRetry: () => void;
|
||||
sortKey: DailySortKey;
|
||||
sortDirection: SortDirection;
|
||||
}
|
||||
|
||||
function StationRows({ date, fallbackStations, detail, onRetry, sortKey, sortDirection }: StationRowsProps) {
|
||||
const [openStations, setOpenStations] = useState<Set<string>>(new Set());
|
||||
const [openCustomers, setOpenCustomers] = useState<Set<string>>(new Set());
|
||||
const stations = detail?.data?.stations;
|
||||
const sortedStations = useMemo(() => sortBy(stations ?? [], sortKey, sortDirection, (station, key) => dailyDetailValue(station, key)), [sortDirection, sortKey, stations]);
|
||||
|
||||
const toggleStation = (station: HydrogenDailyDetailStation) => {
|
||||
const key = `${date}:${station.id}`;
|
||||
setOpenStations(previous => toggleSet(previous, key));
|
||||
};
|
||||
|
||||
const toggleCustomer = (station: HydrogenDailyDetailStation, customer: HydrogenDailyDetailCustomer) => {
|
||||
const key = `${date}:${station.id}:${customer.id}:${customer.name}`;
|
||||
setOpenCustomers(previous => toggleSet(previous, key));
|
||||
};
|
||||
|
||||
return (
|
||||
<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/60"
|
||||
>
|
||||
{detail?.loading ? <DetailStatus label="正在读取客户和车辆明细" /> : null}
|
||||
{detail?.error ? (
|
||||
<div className="flex items-center justify-between px-9 py-3 text-[11px] text-rose-600">
|
||||
<span>明细读取失败,请重试。</span>
|
||||
<button type="button" onClick={onRetry} className="inline-flex items-center gap-1 font-semibold"><RefreshCw size={12} />重试</button>
|
||||
</div>
|
||||
) : null}
|
||||
{!detail ? <DetailStatus label={`正在准备 ${fallbackStations.length} 个站点明细`} /> : null}
|
||||
{stations?.length === 0 ? (
|
||||
<div className="px-9 py-3 text-[11px] text-slate-400">当日无站点明细</div>
|
||||
) : sortedStations.map((station, index) => {
|
||||
const stationKey = `${date}:${station.id}`;
|
||||
const stationOpen = openStations.has(stationKey);
|
||||
return (
|
||||
<div key={`${station.id}-${station.name}-${index}`} className="border-t border-slate-100 first:border-t-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleStation(station)}
|
||||
className="grid w-full grid-cols-[minmax(250px,1fr)_110px_120px_110px_100px] items-center gap-3 px-3 py-2 pl-9 text-left hover:bg-slate-100/80"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<ChevronRight size={13} className={`shrink-0 text-sky-600 transition-transform ${stationOpen ? 'rotate-90' : ''}`} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-medium text-slate-700" title={station.name}>{station.name}</div>
|
||||
<div className="mt-0.5 text-[10px] text-slate-400">{station.customers.length} 个客户 · {formatStationType(station.stationType)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-right font-mono text-[12px] font-semibold tabular-nums text-slate-600">{station.kg > 0 ? formatNumber(station.fee / station.kg, 2) : '—'}</span>
|
||||
<span className="text-right font-mono text-[12px] font-semibold tabular-nums text-slate-800">{formatNumber(station.kg, 2)}</span>
|
||||
<span className="text-right font-mono text-[11px] font-semibold tabular-nums text-emerald-700">¥{formatNumber(station.fee, 0)}</span>
|
||||
<span
|
||||
className={`text-right font-mono text-[10px] tabular-nums ${station.balance === null ? 'text-slate-400' : 'font-semibold text-slate-700'}`}
|
||||
title={station.balanceEffectiveTime ? `余额记录时间:${station.balanceEffectiveTime}` : '数据源暂无该站点余额记录'}
|
||||
>
|
||||
{station.balance === null ? '暂无记录' : `¥${formatNumber(station.balance, 2)}`}
|
||||
</span>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{stationOpen ? (
|
||||
<CustomerRows
|
||||
date={date}
|
||||
station={station}
|
||||
openCustomers={openCustomers}
|
||||
onToggle={customer => toggleCustomer(station, customer)}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerRows({
|
||||
date,
|
||||
station,
|
||||
openCustomers,
|
||||
onToggle,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
}: {
|
||||
date: string;
|
||||
station: HydrogenDailyDetailStation;
|
||||
openCustomers: Set<string>;
|
||||
onToggle: (customer: HydrogenDailyDetailCustomer) => void;
|
||||
sortKey: DailySortKey;
|
||||
sortDirection: SortDirection;
|
||||
}) {
|
||||
const sortedCustomers = useMemo(() => sortBy(station.customers, sortKey, sortDirection, (customer, key) => dailyDetailValue(customer, key)), [sortDirection, sortKey, station.customers]);
|
||||
return (
|
||||
<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-white"
|
||||
>
|
||||
{sortedCustomers.map(customer => {
|
||||
const customerKey = `${date}:${station.id}:${customer.id}:${customer.name}`;
|
||||
const customerOpen = openCustomers.has(customerKey);
|
||||
return (
|
||||
<div key={customerKey} className="border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(customer)}
|
||||
className="grid w-full grid-cols-[minmax(250px,1fr)_110px_120px_110px_100px] items-center gap-3 px-3 py-2 pl-14 text-left hover:bg-sky-50/50"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<ChevronRight size={12} className={`shrink-0 text-slate-400 transition-transform ${customerOpen ? 'rotate-90' : ''}`} />
|
||||
<span className="truncate text-[11px] font-medium text-slate-700" title={customer.name}>{customer.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{customer.vehicles.length} 条</span>
|
||||
</span>
|
||||
<span className="text-right text-[11px] text-slate-400">客户</span>
|
||||
<span className="text-right font-mono text-[11px] tabular-nums text-slate-700">{formatNumber(customer.kg, 2)}</span>
|
||||
<span className="text-right font-mono text-[11px] tabular-nums text-emerald-700">¥{formatNumber(customer.fee, 0)}</span>
|
||||
<span className="text-right text-[11px] text-slate-300">—</span>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{customerOpen ? <VehicleRows customer={customer} sortKey={sortKey} sortDirection={sortDirection} /> : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function VehicleRows({ customer, sortKey, sortDirection }: { customer: HydrogenDailyDetailCustomer; sortKey: DailySortKey; sortDirection: SortDirection }) {
|
||||
const sortedVehicles = useMemo(() => sortBy(customer.vehicles, sortKey, sortDirection, (vehicle, key) => dailyDetailValue(vehicle, key)), [customer.vehicles, sortDirection, sortKey]);
|
||||
return (
|
||||
<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/80 px-3 py-1.5 pl-[74px]"
|
||||
>
|
||||
{sortedVehicles.map(vehicle => (
|
||||
<div key={vehicle.id} className="grid grid-cols-[minmax(176px,1fr)_110px_120px_110px_100px] items-center gap-3 border-t border-slate-100 py-1.5 first:border-t-0">
|
||||
<div className="flex min-w-0 items-center gap-2 text-[10px]">
|
||||
<span className="font-mono tabular-nums text-slate-400">{vehicle.time}</span>
|
||||
<span className="truncate font-semibold text-slate-700">{vehicle.plateNo}</span>
|
||||
<span className={`rounded px-1 py-0.5 font-medium ${vehicle.vehicleScope === 'lingniu' ? 'bg-blue-50 text-blue-600' : 'bg-amber-50 text-amber-700'}`}>
|
||||
{vehicle.vehicleScope === 'lingniu' ? '羚牛' : '外部'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 justify-end gap-1 text-[9px]">
|
||||
<span className="max-w-[72px] truncate rounded bg-white px-1 py-0.5 text-slate-500" title={vehicle.source}>{vehicle.source}</span>
|
||||
<span className="rounded bg-white px-1 py-0.5 text-slate-500">{formatVerifyStatus(vehicle.verifyStatus)}</span>
|
||||
</div>
|
||||
<span className="text-right font-mono text-[10px] font-semibold tabular-nums text-slate-700">{formatNumber(vehicle.kg, 3)}</span>
|
||||
<span className="text-right font-mono text-[10px] tabular-nums text-emerald-700">¥{formatNumber(vehicle.fee, 2)}</span>
|
||||
<span className="text-right text-[10px] text-slate-300">—</span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function dailyDetailValue(
|
||||
row: HydrogenDailyDetailStation | HydrogenDailyDetailCustomer | HydrogenDailyDetailCustomer['vehicles'][number],
|
||||
key: DailySortKey,
|
||||
) {
|
||||
if (key === 'date') return 'time' in row ? `${row.time} ${row.plateNo}` : 'name' in row ? row.name : '';
|
||||
if (key === 'price') return row.kg > 0 ? row.fee / row.kg : 0;
|
||||
if (key === 'chainPct') return row.kg;
|
||||
return row[key];
|
||||
}
|
||||
|
||||
function DetailStatus({ label }: { label: string }) {
|
||||
return <div className="px-9 py-3 text-[11px] text-slate-400">{label}</div>;
|
||||
}
|
||||
|
||||
function toggleSet(previous: Set<string>, key: string) {
|
||||
const next = new Set(previous);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
return next;
|
||||
}
|
||||
|
||||
function formatVerifyStatus(value: string) {
|
||||
const normalized = value.toUpperCase();
|
||||
if (normalized === 'VERIFIED' || normalized === 'PASS') return '已核验';
|
||||
if (normalized === 'FAILED' || normalized === 'REJECT') return '异常';
|
||||
return '待核验';
|
||||
}
|
||||
|
||||
function formatStationType(value: string) {
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized === 'self' || normalized === 'internal') return '自营站';
|
||||
if (normalized === 'external' || normalized === 'partner') return '合作站';
|
||||
return '加氢站';
|
||||
}
|
||||
|
||||
function exportDailyRows(rows: HydrogenDailyRow[], details: Record<string, DailyDetailState>) {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const summaryRows = rows.flatMap(row => row.stations.length > 0
|
||||
? row.stations.map(station => ({
|
||||
日期: row.date,
|
||||
加氢站: station.name,
|
||||
单价元每Kg: station.pricePerKg,
|
||||
加氢量Kg: station.kg,
|
||||
成本元: station.fee,
|
||||
日环比: row.chainPct,
|
||||
}))
|
||||
: [{ 日期: row.date, 加氢站: '', 单价元每Kg: 0, 加氢量Kg: row.totalKg, 成本元: row.totalFee, 日环比: row.chainPct }]);
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(summaryRows), '日报汇总');
|
||||
|
||||
const recordRows = Object.values(details).flatMap(state => {
|
||||
if (!state.data) return [];
|
||||
return state.data.stations.flatMap(station => station.customers.flatMap(customer => (
|
||||
customer.vehicles.map(vehicle => ({
|
||||
日期: state.data?.date,
|
||||
时间: vehicle.time,
|
||||
加氢站: station.name,
|
||||
客户: customer.name,
|
||||
车牌: vehicle.plateNo,
|
||||
车辆归属: vehicle.vehicleScope === 'lingniu' ? '羚牛' : '外部',
|
||||
来源: vehicle.source,
|
||||
核验状态: formatVerifyStatus(vehicle.verifyStatus),
|
||||
单价元每Kg: vehicle.unitPrice,
|
||||
加氢量Kg: vehicle.kg,
|
||||
成本元: vehicle.fee,
|
||||
}))
|
||||
)));
|
||||
});
|
||||
if (recordRows.length > 0) XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(recordRows), '已下钻流水');
|
||||
const start = rows.at(-1)?.date ?? '开始';
|
||||
const end = rows[0]?.date ?? '结束';
|
||||
XLSX.writeFile(workbook, `氢能按日_${start}_${end}.xlsx`);
|
||||
}
|
||||
|
||||
function formatNumber(value: number, digits: number): string {
|
||||
return value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Fuel, TrendingUp, Truck, Zap } from 'lucide-react';
|
||||
import type { HydrogenDailyVehicleScope } from '../model';
|
||||
|
||||
interface DailyKpiGridProps {
|
||||
rangeLabel: string;
|
||||
rangeText: string;
|
||||
totalKg: number;
|
||||
activeDays: number;
|
||||
dayCount: number;
|
||||
averageKg: number;
|
||||
stationCount: number;
|
||||
vehicleScope: HydrogenDailyVehicleScope;
|
||||
lingniuKg: number;
|
||||
externalKg: number;
|
||||
selectedStationName?: string;
|
||||
}
|
||||
|
||||
const VEHICLE_LABEL: Record<HydrogenDailyVehicleScope, string> = {
|
||||
all: '全部车辆',
|
||||
lingniu: '羚牛车辆',
|
||||
external: '外部车辆',
|
||||
};
|
||||
|
||||
export function DailyKpiGrid({
|
||||
rangeLabel,
|
||||
rangeText,
|
||||
totalKg,
|
||||
activeDays,
|
||||
dayCount,
|
||||
averageKg,
|
||||
stationCount,
|
||||
vehicleScope,
|
||||
lingniuKg,
|
||||
externalKg,
|
||||
selectedStationName,
|
||||
}: DailyKpiGridProps) {
|
||||
return (
|
||||
<section className="grid grid-cols-2 gap-2.5 md:grid-cols-4 md:gap-3">
|
||||
<KpiCard
|
||||
icon={Fuel}
|
||||
title={`${rangeLabel}加氢量`}
|
||||
value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })}
|
||||
unit="Kg"
|
||||
helper={rangeText}
|
||||
tone="blue"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Truck}
|
||||
title="车辆结构"
|
||||
value={VEHICLE_LABEL[vehicleScope]}
|
||||
helper={vehicleScope === 'all'
|
||||
? `羚牛 ${formatKg(lingniuKg)} · 外部 ${formatKg(externalKg)}`
|
||||
: `仅统计${VEHICLE_LABEL[vehicleScope]}`}
|
||||
tone="green"
|
||||
smallValue
|
||||
/>
|
||||
<KpiCard
|
||||
icon={TrendingUp}
|
||||
title="有效天数"
|
||||
value={activeDays}
|
||||
unit="天"
|
||||
helper={`区间 ${dayCount} 天 · 日均 ${averageKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`}
|
||||
tone="amber"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Zap}
|
||||
title={selectedStationName ? '当前加氢站' : '涉及加氢站'}
|
||||
value={selectedStationName ?? stationCount}
|
||||
unit={selectedStationName ? undefined : '站'}
|
||||
helper={selectedStationName ?? '按区间明细站点去重'}
|
||||
tone="purple"
|
||||
smallValue={Boolean(selectedStationName)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatKg(value: number): string {
|
||||
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`;
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
value,
|
||||
unit,
|
||||
helper,
|
||||
tone,
|
||||
smallValue = false,
|
||||
}: {
|
||||
icon: typeof Fuel;
|
||||
title: string;
|
||||
value: string | number;
|
||||
unit?: string;
|
||||
helper: string;
|
||||
tone: 'blue' | 'green' | 'amber' | 'purple';
|
||||
smallValue?: boolean;
|
||||
}) {
|
||||
const toneClass = {
|
||||
blue: 'bg-blue-50 text-blue-600',
|
||||
green: 'bg-emerald-50 text-emerald-600',
|
||||
amber: 'bg-amber-50 text-amber-600',
|
||||
purple: 'bg-violet-50 text-violet-600',
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<article className="min-w-0 rounded-[10px] border border-slate-200/70 bg-white px-3 py-3 shadow-sm md:px-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-[12px] font-medium text-slate-500">{title}</span>
|
||||
<span className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ${toneClass}`}>
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex min-w-0 items-baseline gap-1 font-mono tabular-nums text-slate-900">
|
||||
<strong className={`${smallValue ? 'truncate text-[17px] md:text-[19px]' : 'text-[23px] md:text-[25px]'} leading-tight font-extrabold`}>
|
||||
{value}
|
||||
</strong>
|
||||
{unit ? <span className="shrink-0 text-[11px] font-semibold text-slate-400">{unit}</span> : null}
|
||||
</div>
|
||||
<p className="mt-1 truncate font-mono text-[10px] font-medium text-slate-400" title={helper}>{helper}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
LabelList,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { HydrogenDailyRow } from '../../types';
|
||||
import type { HydrogenDailyTrendPoint } from '../model';
|
||||
|
||||
interface DailyTrendChartProps {
|
||||
rows: HydrogenDailyTrendPoint[];
|
||||
averageKg: number;
|
||||
peakDay: HydrogenDailyRow | null;
|
||||
lowDay: HydrogenDailyRow | null;
|
||||
zeroDays: number;
|
||||
selectedDate?: string | null;
|
||||
onSelectDate?: (date: string) => void;
|
||||
}
|
||||
|
||||
export function DailyTrendChart({
|
||||
rows,
|
||||
averageKg,
|
||||
peakDay,
|
||||
lowDay,
|
||||
zeroDays,
|
||||
selectedDate,
|
||||
onSelectDate,
|
||||
}: DailyTrendChartProps) {
|
||||
const selectActiveDate = (state: { activeLabel?: string | number } | null) => {
|
||||
if (state?.activeLabel) onSelectDate?.(String(state.activeLabel));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-[14px] border border-slate-200/70 bg-white p-3.5 shadow-sm md:p-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-[14px] font-bold text-slate-800">
|
||||
每日加氢量
|
||||
<span className="ml-1 text-[11px] font-normal text-slate-400">(点击柱体定位到对应日期明细)</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 md:justify-end">
|
||||
<div className="flex items-center gap-3 text-[11px] font-medium text-slate-600">
|
||||
<Legend color="#0284c7" label="羚牛车辆" />
|
||||
<Legend color="#f59e0b" label="外部车辆" />
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] font-medium text-slate-400">时间单位:日 · 单位 Kg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-4 rounded-md bg-slate-50 px-3 py-2 text-[11px] text-slate-500">
|
||||
<TrendFact label="峰值日" value={peakDay ? `${peakDay.date.slice(5)} · ${formatCompact(peakDay.totalKg)}` : '—'} />
|
||||
<TrendFact label="低谷日" value={lowDay ? `${lowDay.date.slice(5)} · ${formatCompact(lowDay.totalKg)}` : '—'} />
|
||||
<TrendFact label="零数日" value={`${zeroDays} 天`} valueClass={zeroDays > 0 ? 'text-amber-600' : 'text-emerald-600'} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 overflow-x-auto pb-1">
|
||||
<div className="h-[230px] min-w-[680px] md:min-w-0">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
initialDimension={{ width: 680, height: 230 }}
|
||||
>
|
||||
<BarChart
|
||||
data={rows}
|
||||
margin={{ top: 25, right: 12, bottom: 0, left: -6 }}
|
||||
onClick={selectActiveDate}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<CartesianGrid vertical={false} stroke="#e2e8f0" strokeDasharray="3 4" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(value: string) => value.slice(5)}
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e2e8f0' }}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
width={52}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickFormatter={formatAxis}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value, name) => [
|
||||
`${Number(value ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`,
|
||||
name === 'lingniuKg' ? '羚牛车辆' : '外部车辆',
|
||||
]}
|
||||
labelFormatter={date => `日期 ${date}`}
|
||||
contentStyle={{ borderRadius: 8, borderColor: '#e2e8f0', fontSize: 12, boxShadow: '0 10px 30px rgba(15,23,42,.12)' }}
|
||||
cursor={{ fill: 'rgba(2,132,199,.05)' }}
|
||||
/>
|
||||
{averageKg > 0 ? (
|
||||
<ReferenceLine
|
||||
y={averageKg}
|
||||
stroke="#2563eb"
|
||||
strokeDasharray="5 4"
|
||||
label={{ value: `均值 ${formatCompact(averageKg)}`, position: 'insideTopLeft', fill: '#1d4ed8', fontSize: 10, fontWeight: 600 }}
|
||||
/>
|
||||
) : null}
|
||||
<defs>
|
||||
<linearGradient id="dailyLingniuBar" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#38bdf8" />
|
||||
<stop offset="100%" stopColor="#0284c7" />
|
||||
</linearGradient>
|
||||
<linearGradient id="dailyExternalBar" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#fbbf24" />
|
||||
<stop offset="100%" stopColor="#f59e0b" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Bar dataKey="lingniuKg" stackId="daily" fill="url(#dailyLingniuBar)" maxBarSize={30}>
|
||||
{rows.map(row => <Cell key={`lingniu-${row.date}`} fill={row.date === selectedDate ? '#0369a1' : 'url(#dailyLingniuBar)'} />)}
|
||||
</Bar>
|
||||
<Bar dataKey="externalKg" stackId="daily" fill="url(#dailyExternalBar)" radius={[4, 4, 0, 0]} maxBarSize={30}>
|
||||
<LabelList
|
||||
dataKey="totalKg"
|
||||
position="top"
|
||||
formatter={value => formatCompact(Number(value ?? 0))}
|
||||
style={{ fill: '#64748b', fontSize: 9, fontWeight: 600 }}
|
||||
/>
|
||||
{rows.map(row => <Cell key={`external-${row.date}`} fill={row.date === selectedDate ? '#d97706' : 'url(#dailyExternalBar)'} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
|
||||
<span className="h-2 w-2 rounded-sm" style={{ backgroundColor: color }} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendFact({ label, value, valueClass = 'text-slate-800' }: { label: string; value: string; valueClass?: string }) {
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<span>{label}</span>
|
||||
<strong className={`truncate font-mono tabular-nums ${valueClass}`}>{value}</strong>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAxis(value: number): string {
|
||||
if (value >= 10_000) return `${(value / 10_000).toFixed(value % 10_000 === 0 ? 0 : 1)}万`;
|
||||
if (value >= 1_000) return `${Math.round(value / 1_000)}k`;
|
||||
return `${Math.round(value)}`;
|
||||
}
|
||||
|
||||
function formatCompact(value: number): string {
|
||||
return value.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Building2, Gauge, Wallet } from 'lucide-react';
|
||||
import type { HydrogenDailyStationOption } from '../model';
|
||||
|
||||
interface StationDailyOverviewProps {
|
||||
station: HydrogenDailyStationOption;
|
||||
totalKg: number;
|
||||
totalFee: number;
|
||||
averagePrice: number;
|
||||
}
|
||||
|
||||
export function StationDailyOverview({ station, totalKg, totalFee, averagePrice }: StationDailyOverviewProps) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-[14px] border border-sky-100 bg-sky-50/50 px-4 py-3 shadow-sm md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] bg-white text-sky-600 shadow-sm ring-1 ring-sky-100">
|
||||
<Building2 size={17} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold text-sky-700">单站按日视图</div>
|
||||
<h2 className="truncate text-[15px] font-bold text-slate-900" title={station.name}>{station.name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 md:min-w-[430px]">
|
||||
<StationFact icon={Gauge} label="区间加氢量" value={`${totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} />
|
||||
<StationFact icon={Wallet} label="区间成本" value={`¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`} />
|
||||
<StationFact icon={Gauge} label="成本均价" value={`${averagePrice.toFixed(2)} 元/Kg`} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StationFact({ icon: Icon, label, value }: { icon: typeof Gauge; label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-lg bg-white/90 px-2.5 py-2 ring-1 ring-sky-100/80">
|
||||
<div className="flex items-center gap-1 text-[10px] font-medium text-slate-400"><Icon size={11} />{label}</div>
|
||||
<div className="mt-1 truncate font-mono text-[11px] font-bold tabular-nums text-slate-800" title={value}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { HydrogenDailyRow } from '../types.js';
|
||||
import {
|
||||
buildHydrogenDailyTrend,
|
||||
getQuickRange,
|
||||
filterHydrogenRowsByStation,
|
||||
getHydrogenDailyStations,
|
||||
getRangeModeLabel,
|
||||
mergeHydrogenDailyRows,
|
||||
normalizeRange,
|
||||
summarizeHydrogenRows,
|
||||
} from './model.js';
|
||||
|
||||
test('快捷日期继续使用本地自然日并覆盖本周、本月和近15天', () => {
|
||||
const now = new Date(2026, 7, 12, 23, 30);
|
||||
assert.deepEqual(getQuickRange('thisWeek', now), {
|
||||
start: '2026-08-10',
|
||||
end: '2026-08-12',
|
||||
});
|
||||
assert.deepEqual(getQuickRange('thisMonth', now), {
|
||||
start: '2026-08-01',
|
||||
end: '2026-08-12',
|
||||
});
|
||||
assert.deepEqual(getQuickRange('last15', now), {
|
||||
start: '2026-07-29',
|
||||
end: '2026-08-12',
|
||||
});
|
||||
});
|
||||
|
||||
test('全部车辆由两类真实日报按日期和站点合并', () => {
|
||||
const lingniu: HydrogenDailyRow[] = [{
|
||||
date: '2026-08-11', totalKg: 100, totalFee: 2_400, chainPct: 0, customerType: 'lingniu',
|
||||
stations: [{ id: 1, name: 'A站', kg: 100, fee: 2_400, pricePerKg: 24, chainPct: 0 }],
|
||||
}];
|
||||
const external: HydrogenDailyRow[] = [{
|
||||
date: '2026-08-11', totalKg: 50, totalFee: 1_000, chainPct: 0, customerType: 'external',
|
||||
stations: [{ id: 1, name: 'A站', kg: 50, fee: 1_000, pricePerKg: 20, chainPct: 0 }],
|
||||
}];
|
||||
|
||||
const merged = mergeHydrogenDailyRows(lingniu, external)!;
|
||||
assert.equal(merged[0].totalKg, 150);
|
||||
assert.equal(merged[0].totalFee, 3_400);
|
||||
assert.equal(merged[0].stations[0].pricePerKg, 22.67);
|
||||
assert.deepEqual(buildHydrogenDailyTrend(lingniu, external, 'all', null), [{
|
||||
date: '2026-08-11', totalKg: 150, lingniuKg: 100, externalKg: 50,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('趋势数据同时遵守车辆归属和单站筛选', () => {
|
||||
const lingniu: HydrogenDailyRow[] = [{
|
||||
date: '2026-08-11', totalKg: 130, totalFee: 3_000, chainPct: 0, customerType: 'lingniu',
|
||||
stations: [
|
||||
{ id: 1, name: 'A站', kg: 80, fee: 1_920, pricePerKg: 24, chainPct: 0 },
|
||||
{ id: 2, name: 'B站', kg: 50, fee: 1_080, pricePerKg: 21.6, chainPct: 0 },
|
||||
],
|
||||
}];
|
||||
const external: HydrogenDailyRow[] = [{
|
||||
date: '2026-08-11', totalKg: 20, totalFee: 400, chainPct: 0, customerType: 'external',
|
||||
stations: [{ id: 1, name: 'A站', kg: 20, fee: 400, pricePerKg: 20, chainPct: 0 }],
|
||||
}];
|
||||
|
||||
assert.deepEqual(buildHydrogenDailyTrend(lingniu, external, 'lingniu', 1), [{
|
||||
date: '2026-08-11', totalKg: 80, lingniuKg: 80, externalKg: 0,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('自定义日期倒置时仅交换查询边界', () => {
|
||||
assert.deepEqual(normalizeRange('2026-08-12', '2026-08-01'), {
|
||||
start: '2026-08-01',
|
||||
end: '2026-08-12',
|
||||
});
|
||||
assert.equal(getRangeModeLabel('custom'), '自定义区间');
|
||||
assert.equal(getRangeModeLabel('last15'), '近 15 天');
|
||||
});
|
||||
|
||||
test('每日加氢统计保持排序、有效天、站点去重和峰谷口径', () => {
|
||||
const rows: HydrogenDailyRow[] = [
|
||||
{
|
||||
date: '2026-08-12',
|
||||
totalKg: 0,
|
||||
totalFee: 0,
|
||||
chainPct: -1,
|
||||
customerType: 'lingniu',
|
||||
stations: [{ id: 1, name: 'A站', kg: 0, fee: 0, pricePerKg: 0, chainPct: -1 }],
|
||||
},
|
||||
{
|
||||
date: '2026-08-10',
|
||||
totalKg: 100,
|
||||
totalFee: 2_000,
|
||||
chainPct: 0,
|
||||
customerType: 'lingniu',
|
||||
stations: [{ id: 1, name: 'A站', kg: 100, fee: 2_000, pricePerKg: 20, chainPct: 0 }],
|
||||
},
|
||||
{
|
||||
date: '2026-08-11',
|
||||
totalKg: 300,
|
||||
totalFee: 7_500,
|
||||
chainPct: 2,
|
||||
customerType: 'lingniu',
|
||||
stations: [{ id: 2, name: 'B站', kg: 300, fee: 7_500, pricePerKg: 25, chainPct: 2 }],
|
||||
},
|
||||
];
|
||||
|
||||
const summary = summarizeHydrogenRows(rows);
|
||||
assert.deepEqual(rows.map(row => row.date), ['2026-08-12', '2026-08-10', '2026-08-11']);
|
||||
assert.deepEqual(summary.trendData.map(row => row.date), ['2026-08-10', '2026-08-11', '2026-08-12']);
|
||||
assert.equal(summary.totalKg, 400);
|
||||
assert.equal(summary.totalFee, 9_500);
|
||||
assert.equal(summary.activeDays, 2);
|
||||
assert.equal(summary.avgKg, 200);
|
||||
assert.equal(summary.avgPrice, 23.75);
|
||||
assert.equal(summary.stationCount, 2);
|
||||
assert.equal(summary.peakDay?.date, '2026-08-11');
|
||||
assert.equal(summary.lowDay?.date, '2026-08-10');
|
||||
assert.equal(summary.zeroDays, 1);
|
||||
});
|
||||
|
||||
test('空数据保持零值且没有峰谷日', () => {
|
||||
assert.deepEqual(summarizeHydrogenRows(null), {
|
||||
trendData: [],
|
||||
totalKg: 0,
|
||||
totalFee: 0,
|
||||
activeDays: 0,
|
||||
stationCount: 0,
|
||||
avgKg: 0,
|
||||
avgPrice: 0,
|
||||
peakDay: null,
|
||||
lowDay: null,
|
||||
zeroDays: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('站点筛选按站点 ID 重算日报总量、费用和环比', () => {
|
||||
const rows: HydrogenDailyRow[] = [
|
||||
{
|
||||
date: '2026-08-11', totalKg: 150, totalFee: 3_400, chainPct: 0, customerType: 'lingniu',
|
||||
stations: [
|
||||
{ id: 1, name: 'A站', kg: 100, fee: 2_400, pricePerKg: 24, chainPct: 0 },
|
||||
{ id: 2, name: 'B站', kg: 50, fee: 1_000, pricePerKg: 20, chainPct: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2026-08-10', totalKg: 80, totalFee: 1_760, chainPct: 0, customerType: 'lingniu',
|
||||
stations: [{ id: 1, name: 'A站', kg: 80, fee: 1_760, pricePerKg: 22, chainPct: 0 }],
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(getHydrogenDailyStations(rows), [
|
||||
{ id: 1, name: 'A站', totalKg: 180, totalFee: 4_160 },
|
||||
{ id: 2, name: 'B站', totalKg: 50, totalFee: 1_000 },
|
||||
]);
|
||||
|
||||
const filtered = filterHydrogenRowsByStation(rows, 1)!;
|
||||
assert.deepEqual(filtered.map(row => ({ date: row.date, kg: row.totalKg, fee: row.totalFee, chain: row.chainPct })), [
|
||||
{ date: '2026-08-11', kg: 100, fee: 2_400, chain: 0.25 },
|
||||
{ date: '2026-08-10', kg: 80, fee: 1_760, chain: 0 },
|
||||
]);
|
||||
assert.equal(summarizeHydrogenRows(filtered).totalFee, 4_160);
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { HydrogenDailyRow } from '../types';
|
||||
|
||||
export {
|
||||
formatYmd,
|
||||
getQuickRange,
|
||||
getRangeModeLabel,
|
||||
normalizeRange,
|
||||
QUICK_PICK_OPTIONS,
|
||||
type RangeMode,
|
||||
} from '../daily-range/model';
|
||||
|
||||
export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
|
||||
const source = rows ?? [];
|
||||
// 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。
|
||||
const trendData = [...source].sort((left, right) => left.date.localeCompare(right.date));
|
||||
const totalKg = source.reduce((total, row) => total + row.totalKg, 0);
|
||||
const totalFee = source.reduce((total, row) => total + row.totalFee, 0);
|
||||
const activeDays = source.filter(row => row.totalKg > 0).length;
|
||||
const stationIds = new Set<number>();
|
||||
source.forEach(row => row.stations.forEach(station => stationIds.add(station.id)));
|
||||
const peakDay = trendData.reduce<HydrogenDailyRow | null>(
|
||||
(best, item) => (!best || item.totalKg > best.totalKg ? item : best),
|
||||
null,
|
||||
);
|
||||
const lowDay = trendData
|
||||
.filter(item => item.totalKg > 0)
|
||||
.reduce<HydrogenDailyRow | null>(
|
||||
(low, item) => (!low || item.totalKg < low.totalKg ? item : low),
|
||||
null,
|
||||
);
|
||||
|
||||
return {
|
||||
trendData,
|
||||
totalKg,
|
||||
totalFee,
|
||||
activeDays,
|
||||
stationCount: stationIds.size,
|
||||
avgKg: activeDays > 0 ? totalKg / activeDays : 0,
|
||||
avgPrice: totalKg > 0 ? totalFee / totalKg : 0,
|
||||
peakDay,
|
||||
lowDay,
|
||||
zeroDays: source.filter(row => row.totalKg === 0).length,
|
||||
};
|
||||
}
|
||||
|
||||
export interface HydrogenDailyStationOption {
|
||||
id: number;
|
||||
name: string;
|
||||
totalKg: number;
|
||||
totalFee: number;
|
||||
}
|
||||
|
||||
export type HydrogenDailyVehicleScope = 'all' | 'lingniu' | 'external';
|
||||
export type HydrogenDailyBoardScope = 'global' | 'station';
|
||||
|
||||
export interface HydrogenDailyTrendPoint {
|
||||
date: string;
|
||||
totalKg: number;
|
||||
lingniuKg: number;
|
||||
externalKg: number;
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* “全部车辆”由羚牛与外部车辆两次真实查询合并得到。合并只累加账本
|
||||
* 已返回的加氢量和成本,不引入原型里的演示数据。
|
||||
*/
|
||||
export function mergeHydrogenDailyRows(
|
||||
lingniuRows: HydrogenDailyRow[] | null,
|
||||
externalRows: HydrogenDailyRow[] | null,
|
||||
): HydrogenDailyRow[] | null {
|
||||
if (lingniuRows === null || externalRows === null) return null;
|
||||
|
||||
const rowsByDate = new Map<string, HydrogenDailyRow[]>();
|
||||
for (const row of [...lingniuRows, ...externalRows]) {
|
||||
const rows = rowsByDate.get(row.date) ?? [];
|
||||
rows.push(row);
|
||||
rowsByDate.set(row.date, rows);
|
||||
}
|
||||
|
||||
const merged = [...rowsByDate.entries()].map(([date, rows]) => {
|
||||
const stationsById = new Map<number, HydrogenDailyRow['stations'][number]>();
|
||||
for (const row of rows) {
|
||||
for (const station of row.stations) {
|
||||
const current = stationsById.get(station.id);
|
||||
const kg = round2((current?.kg ?? 0) + station.kg);
|
||||
const fee = round2((current?.fee ?? 0) + station.fee);
|
||||
stationsById.set(station.id, {
|
||||
id: station.id,
|
||||
name: station.name || current?.name || `站点 #${station.id}`,
|
||||
kg,
|
||||
fee,
|
||||
// 跨车辆归属聚合后展示实际成本加权均价。
|
||||
pricePerKg: kg > 0 ? round2(fee / kg) : 0,
|
||||
chainPct: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
const stations = [...stationsById.values()].sort((left, right) => right.kg - left.kg);
|
||||
return {
|
||||
date,
|
||||
totalKg: round2(stations.reduce((sum, station) => sum + station.kg, 0)),
|
||||
totalFee: round2(stations.reduce((sum, station) => sum + station.fee, 0)),
|
||||
chainPct: 0,
|
||||
customerType: 'lingniu' as const,
|
||||
stations,
|
||||
};
|
||||
});
|
||||
|
||||
return recomputeHydrogenDailyChains(merged);
|
||||
}
|
||||
|
||||
function recomputeHydrogenDailyChains(rows: HydrogenDailyRow[]): HydrogenDailyRow[] {
|
||||
const ascending = [...rows]
|
||||
.map(row => ({ ...row, stations: row.stations.map(station => ({ ...station })) }))
|
||||
.sort((left, right) => left.date.localeCompare(right.date));
|
||||
|
||||
let previousTotalKg = 0;
|
||||
const stationPreviousKg = new Map<number, number>();
|
||||
for (const row of ascending) {
|
||||
row.chainPct = previousTotalKg > 0 ? (row.totalKg - previousTotalKg) / previousTotalKg : 0;
|
||||
previousTotalKg = row.totalKg;
|
||||
for (const station of row.stations) {
|
||||
const previousStationKg = stationPreviousKg.get(station.id) ?? 0;
|
||||
station.chainPct = previousStationKg > 0 ? (station.kg - previousStationKg) / previousStationKg : 0;
|
||||
stationPreviousKg.set(station.id, station.kg);
|
||||
}
|
||||
}
|
||||
return ascending.sort((left, right) => right.date.localeCompare(left.date));
|
||||
}
|
||||
|
||||
/** Build one stable station selector from the current date range. */
|
||||
export function getHydrogenDailyStations(rows: HydrogenDailyRow[] | null): HydrogenDailyStationOption[] {
|
||||
const stations = new Map<number, HydrogenDailyStationOption>();
|
||||
for (const row of rows ?? []) {
|
||||
for (const station of row.stations) {
|
||||
const current = stations.get(station.id);
|
||||
stations.set(station.id, {
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
totalKg: (current?.totalKg ?? 0) + station.kg,
|
||||
totalFee: (current?.totalFee ?? 0) + station.fee,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...stations.values()].sort((left, right) => right.totalKg - left.totalKg || left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily endpoint returns all stations in the selected date range. This keeps
|
||||
* station drill-down local and recomputes each day's totals and chain change.
|
||||
*/
|
||||
export function filterHydrogenRowsByStation(
|
||||
rows: HydrogenDailyRow[] | null,
|
||||
stationId: number | null,
|
||||
): HydrogenDailyRow[] | null {
|
||||
if (rows === null || stationId === null) return rows;
|
||||
|
||||
const filtered = rows.map(row => {
|
||||
const stations = row.stations.filter(station => station.id === stationId);
|
||||
return {
|
||||
...row,
|
||||
totalKg: stations.reduce((sum, station) => sum + station.kg, 0),
|
||||
totalFee: stations.reduce((sum, station) => sum + station.fee, 0),
|
||||
stations,
|
||||
};
|
||||
});
|
||||
return recomputeHydrogenDailyChains(filtered);
|
||||
}
|
||||
|
||||
/** Build the prototype's blue/orange stacked daily series from real queries. */
|
||||
export function buildHydrogenDailyTrend(
|
||||
lingniuRows: HydrogenDailyRow[] | null,
|
||||
externalRows: HydrogenDailyRow[] | null,
|
||||
vehicleScope: HydrogenDailyVehicleScope,
|
||||
stationId: number | null,
|
||||
): HydrogenDailyTrendPoint[] {
|
||||
if (lingniuRows === null || externalRows === null) return [];
|
||||
const lingniu = filterHydrogenRowsByStation(lingniuRows, stationId) ?? [];
|
||||
const external = filterHydrogenRowsByStation(externalRows, stationId) ?? [];
|
||||
const byDate = new Map<string, HydrogenDailyTrendPoint>();
|
||||
|
||||
for (const row of lingniu) {
|
||||
byDate.set(row.date, {
|
||||
date: row.date,
|
||||
lingniuKg: vehicleScope === 'external' ? 0 : row.totalKg,
|
||||
externalKg: 0,
|
||||
totalKg: vehicleScope === 'external' ? 0 : row.totalKg,
|
||||
});
|
||||
}
|
||||
for (const row of external) {
|
||||
const current = byDate.get(row.date) ?? {
|
||||
date: row.date,
|
||||
lingniuKg: 0,
|
||||
externalKg: 0,
|
||||
totalKg: 0,
|
||||
};
|
||||
const externalKg = vehicleScope === 'lingniu' ? 0 : row.totalKg;
|
||||
current.externalKg = externalKg;
|
||||
current.totalKg = round2(current.lingniuKg + externalKg);
|
||||
byDate.set(row.date, current);
|
||||
}
|
||||
|
||||
return [...byDate.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
LabelList,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { HydrogenRegionShare, HydrogenStationFull, HydrogenStationTop } from '../../types';
|
||||
import {
|
||||
buildRegionDrillPayload,
|
||||
buildStationDrillPayload,
|
||||
type OverviewDrillPayload,
|
||||
type OverviewDrillRequest,
|
||||
type OverviewScope,
|
||||
} from '../model';
|
||||
import { OverviewDrillDialog } from './OverviewDrillDialog';
|
||||
|
||||
const REGION_COLORS = [
|
||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
||||
'#94a3b8',
|
||||
];
|
||||
|
||||
interface YAxisTickProps {
|
||||
x?: number;
|
||||
y?: number;
|
||||
index?: number;
|
||||
payload?: { value: string };
|
||||
}
|
||||
|
||||
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
|
||||
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
||||
{index + 1}
|
||||
</text>
|
||||
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
||||
{payload?.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
interface DistributionChartsProps {
|
||||
top5: HydrogenStationTop[];
|
||||
regions: HydrogenRegionShare[];
|
||||
stations: HydrogenStationFull[];
|
||||
yearKg: number;
|
||||
onSelectStation?: (stationId: number) => void;
|
||||
scope?: OverviewScope;
|
||||
scopeLabel?: string | null;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}
|
||||
|
||||
export function DistributionCharts({ top5, regions, stations, yearKg, onSelectStation, scope = 'global', scopeLabel, onDrillRequest }: DistributionChartsProps) {
|
||||
const [regionGranularity, setRegionGranularity] = useState<'province' | 'city'>('city');
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
const provinceRegions = useMemo(() => {
|
||||
const totals = new Map<string, number>();
|
||||
for (const station of stations) {
|
||||
const province = station.province?.trim() || '未归属';
|
||||
totals.set(province, (totals.get(province) ?? 0) + station.kg);
|
||||
}
|
||||
return [...totals.entries()]
|
||||
.map(([region, kg]) => ({ region, kg, share: kg / Math.max(1, yearKg) }))
|
||||
.sort((a, b) => b.kg - a.kg);
|
||||
}, [stations, yearKg]);
|
||||
const visibleRegions = regionGranularity === 'province' ? provinceRegions : regions;
|
||||
const openStation = (stationId: number) => {
|
||||
const station = stations.find(item => item.id === stationId);
|
||||
if (!station) return;
|
||||
setSelectedStationId(station.id);
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
|
||||
else setDrill(buildStationDrillPayload(station));
|
||||
};
|
||||
const openRegion = (region: HydrogenRegionShare) => {
|
||||
setSelectedStationId(null);
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'region', key: `${regionGranularity}:${region.region}`, label: region.region });
|
||||
else setDrill(buildRegionDrillPayload(region, stations, regionGranularity));
|
||||
};
|
||||
const scopeText = scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : '';
|
||||
return (
|
||||
<>
|
||||
<div className="ehb-two-charts-row">
|
||||
<div className="ehb-chart-box">
|
||||
<div className="ehb-chart-box-head">
|
||||
<div className="ehb-chart-box-title">加氢站加氢量 Top5{scopeText}</div>
|
||||
<div className="ehb-chart-box-meta">单位:Kg</div>
|
||||
</div>
|
||||
<div className="ehb-chart-box-body h-[260px]">
|
||||
<ResponsiveContainer width="100%" height={260} minWidth={0} initialDimension={{ width: 1, height: 260 }}>
|
||||
<BarChart
|
||||
data={top5}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 24, left: 16, bottom: 0 }}
|
||||
barSize={10}
|
||||
className="cursor-pointer"
|
||||
onClick={(state) => {
|
||||
const stationId = (state as { activePayload?: { payload?: HydrogenStationTop }[] } | undefined)?.activePayload?.[0]?.payload?.id;
|
||||
if (stationId) openStation(stationId);
|
||||
}}
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={180}
|
||||
tick={<RankYAxisTick />}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(59, 130, 246, 0.04)' }}
|
||||
contentStyle={{ borderRadius: '12px', fontSize: '12px', padding: '8px 12px', border: 'none', boxShadow: '0 4px 20px rgba(0,0,0,0.08)' }}
|
||||
formatter={(val: unknown) => [`${Number(val ?? 0).toLocaleString('zh-CN')} Kg`, '加氢量']}
|
||||
/>
|
||||
<Bar dataKey="kg" fill="#3b82f6" radius={[0, 4, 4, 0]}>
|
||||
<LabelList dataKey="kg" position="right" formatter={(v: unknown) => {
|
||||
const value = Number(v ?? 0);
|
||||
return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value);
|
||||
}} fontSize={11} fontWeight={700} fill="#475569" />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ehb-chart-box">
|
||||
<div className="ehb-chart-box-head">
|
||||
<div className="ehb-chart-box-title">各区域加氢占比{scopeText}</div>
|
||||
<div className="ehb-mini-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-mini-tab ${regionGranularity === 'province' ? 'is-active' : ''}`}
|
||||
onClick={() => setRegionGranularity('province')}
|
||||
>
|
||||
按省
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-mini-tab ${regionGranularity === 'city' ? 'is-active' : ''}`}
|
||||
onClick={() => setRegionGranularity('city')}
|
||||
>
|
||||
按市
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-chart-box-body h-[260px] flex items-center justify-center">
|
||||
<div className="relative w-1/2 h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={visibleRegions}
|
||||
dataKey="kg"
|
||||
nameKey="region"
|
||||
innerRadius={48}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
className="cursor-pointer outline-none"
|
||||
onClick={(entry) => openRegion(entry as unknown as HydrogenRegionShare)}
|
||||
>
|
||||
{visibleRegions.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">{(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]">
|
||||
{visibleRegions.map((r, i) => (
|
||||
<button key={r.region} type="button" onClick={() => openRegion(r)} className="flex min-w-0 items-center gap-1.5 rounded px-1 py-0.5 text-left hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500">
|
||||
<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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<OverviewDrillDialog
|
||||
payload={drill}
|
||||
onClose={() => { setDrill(null); setSelectedStationId(null); }}
|
||||
onPrimaryAction={selectedStationId && onSelectStation ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export 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,176 @@
|
||||
import { Activity, ChevronDown, Shield, TrendingDown } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { HydrogenMonthlyPoint, HydrogenStationFull } from '../../types';
|
||||
import {
|
||||
buildStationDrillPayload,
|
||||
formatKg as fmtKg,
|
||||
type OverviewDrillPayload,
|
||||
type OverviewDrillRequest,
|
||||
} from '../model';
|
||||
import { OverviewDrillDialog } from './OverviewDrillDialog';
|
||||
|
||||
interface InsightCardsProps {
|
||||
monthAvgKg: number;
|
||||
bestMonth: HydrogenMonthlyPoint | null;
|
||||
latestMonth: HydrogenMonthlyPoint | undefined;
|
||||
monthMomentum: number | null;
|
||||
top5Share: number;
|
||||
customerGrossMarginPct: number;
|
||||
stationAvgKg: number;
|
||||
stationCount: number;
|
||||
yearProfitValue: string;
|
||||
yearProfitUnit: string;
|
||||
yearRevenueValue: string;
|
||||
yearRevenueUnit: string;
|
||||
stations: HydrogenStationFull[];
|
||||
onSelectStation: (stationId: number) => void;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}
|
||||
|
||||
export function InsightCards({
|
||||
monthAvgKg,
|
||||
bestMonth,
|
||||
latestMonth,
|
||||
monthMomentum,
|
||||
top5Share,
|
||||
customerGrossMarginPct,
|
||||
stationAvgKg,
|
||||
stationCount,
|
||||
yearProfitValue,
|
||||
yearProfitUnit,
|
||||
yearRevenueValue,
|
||||
yearRevenueUnit,
|
||||
stations,
|
||||
onSelectStation,
|
||||
onDrillRequest,
|
||||
}: InsightCardsProps) {
|
||||
const [rankingOpen, setRankingOpen] = useState(false);
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
const rankingRef = useRef<HTMLDivElement>(null);
|
||||
const highestKg = stations[0]?.kg || 1;
|
||||
useEffect(() => {
|
||||
if (!rankingOpen) return;
|
||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||
if (rankingRef.current && !rankingRef.current.contains(event.target as Node)) setRankingOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', closeOnOutsideClick);
|
||||
return () => document.removeEventListener('mousedown', closeOnOutsideClick);
|
||||
}, [rankingOpen]);
|
||||
const openStation = (station: HydrogenStationFull) => {
|
||||
setRankingOpen(false);
|
||||
setSelectedStationId(station.id);
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
|
||||
else setDrill(buildStationDrillPayload(station));
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="ehb-insight" aria-label="经营洞察">
|
||||
<div className="ehb-insight__card">
|
||||
<div className="ehb-insight__icon is-down">
|
||||
<TrendingDown size={18} aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<div className="ehb-insight__title">月度加氢异常波动</div>
|
||||
<div className="ehb-insight__value is-neg">
|
||||
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
|
||||
</div>
|
||||
<div className="ehb-insight__desc">
|
||||
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
|
||||
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
|
||||
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`ehb-insight__card ehb-insight__card--rank ${rankingOpen ? 'is-open' : ''}`}
|
||||
ref={rankingRef}
|
||||
onClick={() => setRankingOpen((v) => !v)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title="点击查看加氢站加氢量排名"
|
||||
>
|
||||
<div className="ehb-insight__icon">
|
||||
<Shield size={18} aria-hidden />
|
||||
</div>
|
||||
<div className="ehb-insight__rank-body">
|
||||
<div className="ehb-insight__title">头部加氢站占比</div>
|
||||
<div className="ehb-insight__value">Top5 {top5Share.toFixed(1)}%</div>
|
||||
<div className="ehb-insight__desc">
|
||||
共 {stationCount} 站 · 单站年均 {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit} · 点击展开加氢量排名
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`ehb-insight__rank-chevron ${rankingOpen ? 'is-open' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{rankingOpen && (
|
||||
<div
|
||||
className="ehb-station-rank-dropdown"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="listbox"
|
||||
aria-label="加氢站加氢量排名"
|
||||
>
|
||||
<div className="ehb-station-rank-dropdown__head">
|
||||
<span>加氢站加氢量排名</span>
|
||||
<span className="ehb-station-rank-dropdown__meta">
|
||||
高 → 低 · 共 {stationCount} 站
|
||||
</span>
|
||||
</div>
|
||||
<div className="ehb-station-rank-dropdown__list">
|
||||
{stations.map((station, index) => (
|
||||
<button
|
||||
key={`${station.id}-${station.name}-${index}`}
|
||||
type="button"
|
||||
className="ehb-station-rank-item"
|
||||
onClick={() => openStation(station)}
|
||||
title="点击钻取该站明细"
|
||||
>
|
||||
<span className={`ehb-station-rank-item__rank ${index < 3 ? 'is-top' : ''}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="ehb-station-rank-item__main">
|
||||
<span className="ehb-station-rank-item__name">{station.name}</span>
|
||||
<span className="ehb-station-rank-item__bar">
|
||||
<span style={{ width: `${Math.max(2, station.kg / highestKg * 100)}%` }} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="ehb-station-rank-item__val">
|
||||
{fmtKg(station.kg).value} {fmtKg(station.kg).unit}
|
||||
</span>
|
||||
<span className="ehb-station-rank-item__share">{(station.share * 100).toFixed(1)}%</span>
|
||||
</button>
|
||||
))}
|
||||
{stations.length === 0 && (
|
||||
<div className="ehb-station-rank-empty">当前筛选下暂无站点数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ehb-insight__card">
|
||||
<div className={`ehb-insight__icon ${customerGrossMarginPct >= 0 ? 'is-ok' : 'is-neg'}`}>
|
||||
<Activity size={18} aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<div className="ehb-insight__title">客户单毛利率</div>
|
||||
<div className={`ehb-insight__value ${customerGrossMarginPct >= 0 ? 'is-pos' : 'is-neg'}`}>
|
||||
{customerGrossMarginPct.toFixed(1)}%
|
||||
</div>
|
||||
<div className="ehb-insight__desc">
|
||||
客户单毛利 {yearProfitValue}{yearProfitUnit} · 客户收入 {yearRevenueValue}{yearRevenueUnit}
|
||||
{customerGrossMarginPct < 0 ? ' · 需关注客户价格与站点成本' : ' · 当前客户单保持正毛利'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<OverviewDrillDialog
|
||||
payload={drill}
|
||||
onClose={() => { setDrill(null); setSelectedStationId(null); }}
|
||||
onPrimaryAction={selectedStationId ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Activity, Fuel, Search, Truck, Wallet, Zap } from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import type { HydrogenKpi } from '../../types';
|
||||
import {
|
||||
buildMetricDrillPayload,
|
||||
formatKg as fmtKg,
|
||||
formatYuan as fmtYuan,
|
||||
type OverviewDrillPayload,
|
||||
type OverviewDrillRequest,
|
||||
type OverviewMetricKey,
|
||||
type OverviewScope,
|
||||
} from '../model';
|
||||
import { OverviewDrillDialog } from './OverviewDrillDialog';
|
||||
|
||||
interface KpiCardProps {
|
||||
icon: ReactNode;
|
||||
metricKey: OverviewMetricKey;
|
||||
label: string;
|
||||
hero: { value: string; unit: string };
|
||||
rows: { label: string; value: string }[];
|
||||
tone: 'blue' | 'green' | 'amber' | 'purple';
|
||||
valueClass?: string;
|
||||
onOpen: (key: OverviewMetricKey) => void;
|
||||
}
|
||||
|
||||
const TONE_CLASS = {
|
||||
blue: 'bg-blue-50 text-blue-600',
|
||||
green: 'bg-emerald-50 text-emerald-600',
|
||||
amber: 'bg-amber-50 text-amber-600',
|
||||
purple: 'bg-violet-50 text-violet-600',
|
||||
} as const;
|
||||
|
||||
function KpiCard({ icon, metricKey, label, hero, rows, tone, onOpen }: KpiCardProps) {
|
||||
const isYuan = hero.value.startsWith('¥');
|
||||
const numValue = isYuan ? hero.value.replace('¥', '') : hero.value;
|
||||
return (
|
||||
<div
|
||||
className={`ehb-kpi-dual is-${tone}`}
|
||||
onClick={() => onOpen(metricKey)}
|
||||
title="点击查看真实汇总及可用下钻明细"
|
||||
>
|
||||
<div className="ehb-kpi-dual__head">
|
||||
<span className="ehb-kpi-dual__label">
|
||||
{label}
|
||||
<span className="ehb-kpi-drill-hint">
|
||||
<Search size={10} /> 钻取
|
||||
</span>
|
||||
</span>
|
||||
<span className={`ehb-kpi-dual__badge is-${tone}`}>{icon}</span>
|
||||
</div>
|
||||
<div className="ehb-kpi-dual__val">
|
||||
{isYuan && <span className="ehb-kpi-dual__symbol">¥</span>}
|
||||
<span className="ehb-kpi-dual__num">{numValue}</span>
|
||||
{hero.unit && <span className="ehb-kpi-dual__unit">{hero.unit}</span>}
|
||||
</div>
|
||||
<div className="ehb-kpi-dual__deck">
|
||||
{rows.map((row) => (
|
||||
<span key={row.label}>{row.label} {row.value}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface KpiSectionProps {
|
||||
kpi: HydrogenKpi;
|
||||
scope?: OverviewScope;
|
||||
scopeLabel?: string | null;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}
|
||||
|
||||
export function KpiSection({ kpi: k, scope = 'global', scopeLabel, onDrillRequest }: KpiSectionProps) {
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
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 openDrill = (key: OverviewMetricKey) => {
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'metric', key, label: key });
|
||||
else setDrill(buildMetricDrillPayload(k, key));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ehb-host-kpi" aria-label={`${scope === 'station' ? scopeLabel || '单站' : '全局'}经营指标`}>
|
||||
<KpiCard metricKey="yearKg" icon={<Fuel size={14} />} tone="blue" label="累计加氢量" hero={yearKgFmt} rows={[{ label: '我司', value: `${ourYearKgFmt.value}${ourYearKgFmt.unit}` }, { label: '客户', value: `${customerYearKgFmt.value}${customerYearKgFmt.unit}` }]} onOpen={openDrill} />
|
||||
<KpiCard metricKey="yearFee" icon={<Wallet size={14} />} tone="blue" 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}` }]} onOpen={openDrill} />
|
||||
<KpiCard metricKey="yearProfit" icon={<Activity size={14} />} tone="green" label="客户单毛利" hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }} rows={[{ label: '收入', value: `¥${yearRevenueFmt.value}${yearRevenueFmt.unit}` }, { label: '成本', value: `¥${customerYearFeeFmt.value}${customerYearFeeFmt.unit}` }]} onOpen={openDrill} />
|
||||
<KpiCard metricKey="monthKg" icon={<Truck size={14} />} tone="amber" label="本月加氢" hero={monthKgFmt} rows={[{ label: '加氢费', value: `¥${monthFeeFmt.value}${monthFeeFmt.unit}` }, { label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` }]} onOpen={openDrill} />
|
||||
<KpiCard metricKey="todayKg" icon={<Zap size={14} />} tone="purple" label="本日加氢" hero={todayKgFmt} rows={[{ label: '加氢费', value: `¥${todayFeeFmt.value}${todayFeeFmt.unit}` }, { label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` }]} onOpen={openDrill} />
|
||||
</div>
|
||||
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
LabelList,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { useState } from 'react';
|
||||
import type { HydrogenMonthlyPoint } from '../../types';
|
||||
import {
|
||||
buildMonthDrillPayload,
|
||||
formatYuan as fmtYuan,
|
||||
type OverviewDrillPayload,
|
||||
type OverviewDrillRequest,
|
||||
type OverviewScope,
|
||||
} from '../model';
|
||||
import { OverviewDrillDialog } from './OverviewDrillDialog';
|
||||
|
||||
type MonthlyChartPoint = HydrogenMonthlyPoint & { monthLabel: string };
|
||||
|
||||
interface MonthlyChartsProps {
|
||||
activeYear: number;
|
||||
monthly: HydrogenMonthlyPoint[];
|
||||
monthlyDual: MonthlyChartPoint[];
|
||||
scope?: OverviewScope;
|
||||
scopeLabel?: string | null;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}
|
||||
|
||||
export function MonthlyCharts({ activeYear, monthly, monthlyDual, scope = 'global', scopeLabel, onDrillRequest }: MonthlyChartsProps) {
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const openMonthPoint = (point: MonthlyChartPoint | undefined) => {
|
||||
if (!point) return;
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'month', key: point.month, label: `${point.month} 月度经营明细` });
|
||||
else setDrill(buildMonthDrillPayload(point));
|
||||
};
|
||||
const openMonthFromChart = (state: unknown) => {
|
||||
openMonthPoint((state as { activePayload?: { payload?: MonthlyChartPoint }[] } | undefined)?.activePayload?.[0]?.payload);
|
||||
};
|
||||
const openMonthFromBar = (entry: unknown) => {
|
||||
openMonthPoint((entry as { payload?: MonthlyChartPoint } | undefined)?.payload);
|
||||
};
|
||||
const scopeText = scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : '';
|
||||
return (
|
||||
<>
|
||||
{monthly.length > 0 && (
|
||||
<div className="ehb-chart-box">
|
||||
<div className="ehb-chart-box-head">
|
||||
<div className="ehb-chart-box-title">{activeYear} 年月度加氢量{scopeText}</div>
|
||||
<div className="ehb-chart-box-meta">单位:Kg</div>
|
||||
</div>
|
||||
<div className="ehb-chart-box-body">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }} onClick={openMonthFromChart} className="cursor-pointer">
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
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)' }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={24} iconSize={8} wrapperStyle={{ fontSize: 11, paddingBottom: 4 }} />
|
||||
<Bar dataKey="lingniuKg" name="羚牛车辆" stackId="kg" fill="#4ba3df" radius={[0, 0, 0, 0]} onClick={openMonthFromBar} />
|
||||
<Bar dataKey="externalKg" name="外部车辆" stackId="kg" fill="#f4bb45" radius={[4, 4, 0, 0]} onClick={openMonthFromBar}>
|
||||
<LabelList dataKey="kg" position="top" formatter={value => {
|
||||
const total = Number(value ?? 0);
|
||||
return total >= 1000 ? `${(total / 1000).toFixed(1)}k` : total.toFixed(0);
|
||||
}} fill="#475569" fontSize={10} fontWeight={700} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{monthly.length > 0 && (
|
||||
<div className="ehb-chart-box">
|
||||
<div className="ehb-chart-box-head">
|
||||
<div className="ehb-chart-box-title">{activeYear} 年月度收支对比{scopeText}</div>
|
||||
<div className="ehb-chart-box-meta">单位:元</div>
|
||||
</div>
|
||||
<div className="ehb-chart-box-body">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }} onClick={openMonthFromChart} className="cursor-pointer">
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={20}
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v, name) => {
|
||||
const f = fmtYuan(Number(v ?? 0));
|
||||
return [`¥${f.value} ${f.unit}`, name];
|
||||
}}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} onClick={openMonthFromBar} />
|
||||
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} onClick={openMonthFromBar} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { ChevronLeft, Database, ExternalLink, Search, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { OverviewDrillPayload } from '../model';
|
||||
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
|
||||
|
||||
interface OverviewDrillDialogProps {
|
||||
payload: OverviewDrillPayload | null;
|
||||
onClose: () => void;
|
||||
onPrimaryAction?: () => void;
|
||||
onGroupByChange?: (groupBy: 'station' | 'customer' | 'vehicle') => void;
|
||||
onRowSelect?: (row: Record<string, string | number>) => void;
|
||||
}
|
||||
|
||||
const TONE_CLASS = {
|
||||
default: 'text-slate-900',
|
||||
blue: 'text-sky-600',
|
||||
green: 'text-emerald-600',
|
||||
amber: 'text-amber-600',
|
||||
red: 'text-rose-600',
|
||||
} as const;
|
||||
|
||||
export function OverviewDrillDialog({ payload, onClose, onPrimaryAction, onGroupByChange, onRowSelect }: OverviewDrillDialogProps) {
|
||||
const [sortKey, setSortKey] = useState('');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
useEffect(() => {
|
||||
if (!payload) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [onClose, payload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!payload?.columns.length) return;
|
||||
setSortKey(payload.columns[0].key);
|
||||
setSortDirection('desc');
|
||||
}, [payload]);
|
||||
|
||||
const sortedRows = useMemo(
|
||||
() => payload ? sortBy(payload.rows, sortKey, sortDirection, (row, key) => row[key]) : [],
|
||||
[payload, sortDirection, sortKey],
|
||||
);
|
||||
const changeSort = (nextKey: string) => {
|
||||
const next = toggleSort(sortKey, sortDirection, nextKey);
|
||||
setSortKey(next.key);
|
||||
setSortDirection(next.direction);
|
||||
};
|
||||
|
||||
if (!payload || typeof document === 'undefined') return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[1000] flex items-center justify-center bg-slate-950/70 p-3 backdrop-blur-[8px] md:p-5"
|
||||
onMouseDown={event => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<section
|
||||
className="flex max-h-[90vh] w-full max-w-[1100px] flex-col overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.35)]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="overview-drill-title"
|
||||
>
|
||||
<header className="flex items-center justify-between gap-3 bg-slate-900 px-3 py-3 text-white md:px-5 md:py-4">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<button type="button" onClick={onClose} className="inline-flex h-8 shrink-0 items-center gap-1 rounded-lg border border-white/20 bg-white/10 px-2.5 text-[12px] font-bold text-slate-100 transition hover:border-sky-400 hover:bg-sky-400/15 hover:text-sky-300">
|
||||
<ChevronLeft size={16} />
|
||||
<span className="hidden sm:inline">返回</span>
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<h2 id="overview-drill-title" className="flex items-center gap-2 truncate text-[14px] font-bold text-slate-50 md:text-[16px]">
|
||||
<Search size={16} className="shrink-0 text-sky-300" />
|
||||
<span className="truncate">{payload.title}</span>
|
||||
</h2>
|
||||
<p className="mt-0.5 truncate text-[11px] font-medium text-slate-400 md:text-[12px]">{payload.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-white/10 text-slate-300 transition hover:bg-rose-500/80 hover:text-white" aria-label="关闭下钻弹层">
|
||||
<X size={17} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto bg-slate-50 p-3 md:p-5">
|
||||
<div className="grid grid-cols-2 gap-2 rounded-lg border border-slate-200 bg-white p-3 md:grid-cols-4 md:gap-4 md:px-[18px] md:py-[14px]">
|
||||
{payload.metrics.map(metric => (
|
||||
<div key={metric.label} className="min-w-0">
|
||||
<div className="text-[11px] font-medium text-slate-500">{metric.label}</div>
|
||||
<div className={`mt-1 truncate text-[16px] font-extrabold tabular-nums ${TONE_CLASS[metric.tone ?? 'default']}`} title={metric.value}>{metric.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{payload.groupByOptions?.length && onGroupByChange ? (
|
||||
<div className="mt-4 flex items-center justify-between gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2.5">
|
||||
<div className="text-[11px] font-medium text-slate-500">汇总维度</div>
|
||||
<div className="flex rounded-md bg-slate-100 p-0.5">
|
||||
{payload.groupByOptions.map(groupBy => (
|
||||
<button
|
||||
key={groupBy}
|
||||
type="button"
|
||||
onClick={() => onGroupByChange(groupBy)}
|
||||
className={`rounded px-3 py-1.5 text-[11px] font-bold transition ${payload.groupBy === groupBy ? 'bg-white text-sky-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
按{groupBy === 'customer' ? '客户' : groupBy === 'station' ? '加氢站' : '车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{payload.columns.length > 0 && payload.rows.length > 0 ? (
|
||||
<div className="mt-4 max-h-[440px] overflow-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table className="w-full min-w-[620px] border-collapse text-left">
|
||||
<thead className="sticky top-0 z-10 bg-slate-100">
|
||||
<tr>
|
||||
{payload.columns.map(column => (
|
||||
<th key={column.key} className={`border-b border-slate-200 px-3 py-2.5 text-[11px] font-bold text-slate-600 ${column.align === 'right' ? 'text-right' : 'text-left'}`}><SortableColumnHeader label={column.label} sortKey={column.key} activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align={column.align === 'right' ? 'right' : 'left'} /></th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={rowIndex}
|
||||
className={`border-b border-slate-100 last:border-0 hover:bg-slate-50 ${onRowSelect && payload.rowActionLabel ? 'cursor-pointer' : ''}`}
|
||||
onClick={() => onRowSelect?.(row)}
|
||||
title={onRowSelect && payload.rowActionLabel ? payload.rowActionLabel : undefined}
|
||||
>
|
||||
{payload.columns.map(column => (
|
||||
<td key={column.key} className={`whitespace-nowrap px-3 py-2.5 text-[12px] text-slate-700 ${column.align === 'right' ? 'text-right font-semibold tabular-nums' : 'text-left'}`}>{row[column.key]}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex min-h-[180px] flex-col items-center justify-center rounded-lg border border-dashed border-slate-300 bg-white px-5 text-center">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-lg bg-sky-50 text-sky-600"><Database size={19} /></span>
|
||||
<div className="mt-3 text-[13px] font-bold text-slate-700">真实明细尚未接入当前总览接口</div>
|
||||
<p className="mt-1 max-w-[620px] text-[11px] font-medium leading-5 text-slate-500">{payload.emptyMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{payload.primaryActionLabel && onPrimaryAction ? (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button type="button" onClick={onPrimaryAction} className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-sky-600 px-3.5 text-[12px] font-bold text-white shadow-sm transition hover:bg-sky-700">
|
||||
{payload.primaryActionLabel}<ExternalLink size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { ChevronDown, ChevronRight, Database, Download, Search, Truck, X } from 'lucide-react';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
|
||||
import type {
|
||||
HydrogenOverviewDetailGroup,
|
||||
HydrogenOverviewDetailGroupBy,
|
||||
HydrogenOverviewDetailRecord,
|
||||
HydrogenOverviewDetailResponse,
|
||||
} from '../../types';
|
||||
import type { HydrogenVehicleScope } from '../../api';
|
||||
|
||||
type Selection = {
|
||||
stationId?: number | null;
|
||||
customerId?: number | null;
|
||||
customerName?: string | null;
|
||||
plateNo?: string | null;
|
||||
};
|
||||
|
||||
type TreeSortKey = 'name' | 'ownership' | 'source' | 'verify' | 'recordCount' | 'kg' | 'cost' | 'revenue';
|
||||
|
||||
interface OverviewDrillTreeDialogProps {
|
||||
title: string;
|
||||
initialVehicleScope: HydrogenVehicleScope;
|
||||
initialSelection?: Selection;
|
||||
load: (
|
||||
groupBy: HydrogenOverviewDetailGroupBy | null,
|
||||
selection: Selection,
|
||||
vehicleScope: HydrogenVehicleScope,
|
||||
includeAll?: boolean,
|
||||
) => Promise<HydrogenOverviewDetailResponse>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const number = (value: number, digits = 2) => value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
const customerKey = (stationId: number | string, customer: HydrogenOverviewDetailGroup) => `${stationId}:${customer.id}:${customer.name}`;
|
||||
const vehicleKey = (stationId: number | string, customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => `${customerKey(stationId, customer)}:${vehicle.name}`;
|
||||
|
||||
/** 原型定义的四层账本树:加氢站 -> 客户 -> 车辆 -> 单笔订单与核对明细。 */
|
||||
export function OverviewDrillTreeDialog({ title, initialVehicleScope, initialSelection = {}, load, onClose }: OverviewDrillTreeDialogProps) {
|
||||
const [root, setRoot] = useState<HydrogenOverviewDetailResponse | null>(null);
|
||||
const [customers, setCustomers] = useState<Record<string, HydrogenOverviewDetailGroup[]>>({});
|
||||
const [vehicles, setVehicles] = useState<Record<string, HydrogenOverviewDetailGroup[]>>({});
|
||||
const [orders, setOrders] = useState<Record<string, HydrogenOverviewDetailRecord[]>>({});
|
||||
const [openStations, setOpenStations] = useState<Record<string, boolean>>({});
|
||||
const [openCustomers, setOpenCustomers] = useState<Record<string, boolean>>({});
|
||||
const [openVehicles, setOpenVehicles] = useState<Record<string, boolean>>({});
|
||||
const [loading, setLoading] = useState<string | null>('root');
|
||||
const [stationFilter, setStationFilter] = useState('all');
|
||||
const [customerFilter, setCustomerFilter] = useState('all');
|
||||
const [plateFilter, setPlateFilter] = useState('all');
|
||||
const [vehicleScope, setVehicleScope] = useState<HydrogenVehicleScope>(initialVehicleScope);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [sortKey, setSortKey] = useState<TreeSortKey>('kg');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
|
||||
const loadRoot = useCallback(async (scope: HydrogenVehicleScope) => {
|
||||
setLoading('root');
|
||||
setCustomers({});
|
||||
setVehicles({});
|
||||
setOrders({});
|
||||
setOpenStations({});
|
||||
setOpenCustomers({});
|
||||
setOpenVehicles({});
|
||||
try {
|
||||
setRoot(await load('station', initialSelection, scope));
|
||||
} catch {
|
||||
setRoot(null);
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}, [initialSelection, load]);
|
||||
|
||||
useEffect(() => { void loadRoot(vehicleScope); }, [loadRoot, vehicleScope]);
|
||||
|
||||
const toggleStation = async (station: HydrogenOverviewDetailGroup) => {
|
||||
const key = String(station.id);
|
||||
if (openStations[key]) {
|
||||
setOpenStations(value => ({ ...value, [key]: false }));
|
||||
return;
|
||||
}
|
||||
setOpenStations(value => ({ ...value, [key]: true }));
|
||||
if (customers[key]) return;
|
||||
setLoading(`station:${key}`);
|
||||
try {
|
||||
const data = await load('customer', { ...initialSelection, stationId: Number(station.id) || null }, vehicleScope);
|
||||
setCustomers(value => ({ ...value, [key]: data.groups }));
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCustomer = async (stationId: number | string, customer: HydrogenOverviewDetailGroup) => {
|
||||
const key = customerKey(stationId, customer);
|
||||
if (openCustomers[key]) {
|
||||
setOpenCustomers(value => ({ ...value, [key]: false }));
|
||||
return;
|
||||
}
|
||||
setOpenCustomers(value => ({ ...value, [key]: true }));
|
||||
if (vehicles[key]) return;
|
||||
setLoading(`customer:${key}`);
|
||||
try {
|
||||
const data = await load('vehicle', {
|
||||
...initialSelection,
|
||||
stationId: Number(stationId) || null,
|
||||
customerId: Number(customer.id) || 0,
|
||||
customerName: customer.name,
|
||||
}, vehicleScope);
|
||||
setVehicles(value => ({ ...value, [key]: data.groups }));
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleVehicle = async (stationId: number | string, customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => {
|
||||
const key = vehicleKey(stationId, customer, vehicle);
|
||||
if (openVehicles[key]) {
|
||||
setOpenVehicles(value => ({ ...value, [key]: false }));
|
||||
return;
|
||||
}
|
||||
setOpenVehicles(value => ({ ...value, [key]: true }));
|
||||
if (orders[key]) return;
|
||||
setLoading(`vehicle:${key}`);
|
||||
try {
|
||||
const data = await load(null, {
|
||||
...initialSelection,
|
||||
stationId: Number(stationId) || null,
|
||||
customerId: Number(customer.id) || 0,
|
||||
customerName: customer.name,
|
||||
plateNo: vehicle.name,
|
||||
}, vehicleScope);
|
||||
setOrders(value => ({ ...value, [key]: data.records }));
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const stationOptions = root?.groups ?? [];
|
||||
const selectedStation = stationFilter === 'all' ? null : stationOptions.find(station => String(station.id) === stationFilter) ?? null;
|
||||
const customerOptions = selectedStation ? customers[String(selectedStation.id)] ?? [] : [];
|
||||
const selectedCustomer = customerFilter === 'all' ? null : customerOptions.find(customer => customerKey(selectedStation?.id ?? '', customer) === customerFilter) ?? null;
|
||||
const plateOptions = selectedStation && selectedCustomer ? vehicles[customerKey(selectedStation.id, selectedCustomer)] ?? [] : [];
|
||||
const visibleStations = useMemo(() => {
|
||||
const filtered = stationFilter === 'all' ? stationOptions : stationOptions.filter(station => String(station.id) === stationFilter);
|
||||
return sortTreeGroups(filtered, sortKey, sortDirection);
|
||||
}, [sortDirection, sortKey, stationFilter, stationOptions]);
|
||||
|
||||
const selectStation = (value: string) => {
|
||||
setStationFilter(value);
|
||||
setCustomerFilter('all');
|
||||
setPlateFilter('all');
|
||||
const station = stationOptions.find(item => String(item.id) === value);
|
||||
if (station && !openStations[String(station.id)]) void toggleStation(station);
|
||||
};
|
||||
|
||||
const selectCustomer = (value: string) => {
|
||||
setCustomerFilter(value);
|
||||
setPlateFilter('all');
|
||||
const customer = customerOptions.find(item => customerKey(selectedStation?.id ?? '', item) === value);
|
||||
if (selectedStation && customer && !openCustomers[value]) void toggleCustomer(selectedStation.id, customer);
|
||||
};
|
||||
|
||||
const changeVehicleScope = (scope: HydrogenVehicleScope) => {
|
||||
if (scope === vehicleScope) return;
|
||||
setVehicleScope(scope);
|
||||
setStationFilter('all');
|
||||
setCustomerFilter('all');
|
||||
setPlateFilter('all');
|
||||
};
|
||||
|
||||
const changeSort = (nextKey: TreeSortKey) => {
|
||||
const next = toggleSort(sortKey, sortDirection, nextKey);
|
||||
setSortKey(next.key);
|
||||
setSortDirection(next.direction);
|
||||
};
|
||||
|
||||
const exportAll = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const detail = await load(null, {
|
||||
...initialSelection,
|
||||
stationId: selectedStation ? Number(selectedStation.id) || null : null,
|
||||
customerId: selectedCustomer ? Number(selectedCustomer.id) || 0 : null,
|
||||
customerName: selectedCustomer?.name ?? null,
|
||||
plateNo: plateFilter === 'all' ? null : plateFilter,
|
||||
}, vehicleScope, true);
|
||||
const rows = detail.records.map(record => ({
|
||||
加氢时间: record.refuelTime,
|
||||
加氢站: record.stationName,
|
||||
客户: record.customerName,
|
||||
车牌: record.plateNo,
|
||||
车辆归属: record.vehicleScope === 'lingniu' ? '羚牛车辆' : '外部车辆',
|
||||
数据来源: record.source,
|
||||
核对状态: verifyText(record.verifyStatus),
|
||||
订单编号: record.orderNo,
|
||||
加氢量Kg: record.kg,
|
||||
成本元: record.cost,
|
||||
客户收入元: record.revenue,
|
||||
}));
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), '穿透账单');
|
||||
XLSX.writeFile(workbook, `${title.replaceAll(/[\\/:*?"<>|]/g, '_')}_穿透账单.xlsx`);
|
||||
if (detail.truncated) window.alert('当前筛选范围超过 20,000 笔,导出已截取前 20,000 笔。请收窄筛选范围后再次导出。');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof document === 'undefined') return null;
|
||||
const summary = root?.summary;
|
||||
return createPortal(
|
||||
<div className="ehb-modal-overlay" onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
|
||||
<section className="ehb-modal-card" role="dialog" aria-modal="true" aria-label={`${title}穿透明细`}>
|
||||
<header className="ehb-modal-head">
|
||||
<div className="ehb-modal-head__title-group">
|
||||
<div>
|
||||
<h2 className="ehb-modal-head__title">{title}</h2>
|
||||
<p className="ehb-modal-head__sub">加氢站 → 客户 → 车辆 → 单笔订单与核对明细</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="ehb-modal-close-btn" aria-label="关闭下钻弹层"><X size={17} /></button>
|
||||
</header>
|
||||
<div className="ehb-modal-body">
|
||||
<div className="ehb-modal-meta-bar">
|
||||
<Metric label="统计范围" value={title} />
|
||||
<Metric label="加氢总量" value={`${number(summary?.kg ?? 0, 2)} Kg`} tone="text-sky-600" />
|
||||
<Metric label="涉及加氢站" value={`${summary?.stationCount ?? 0} 站`} />
|
||||
<Metric label="账本流水" value={`${summary?.recordCount ?? 0} 笔`} />
|
||||
</div>
|
||||
|
||||
<div className="ehb-modal-filter-row">
|
||||
<div className="ehb-modal-filter-group">
|
||||
<SearchSelect allLabel="全部加氢站" value={stationFilter} onChange={selectStation} options={stationOptions.map(item => ({ value: String(item.id), label: item.name }))} />
|
||||
<SearchSelect allLabel="全部客户" value={customerFilter} onChange={selectCustomer} disabled={!selectedStation} options={customerOptions.map(item => ({ value: customerKey(selectedStation?.id ?? '', item), label: item.name }))} />
|
||||
<SearchSelect allLabel="全部车辆" value={plateFilter} onChange={setPlateFilter} disabled={!selectedCustomer} options={plateOptions.map(item => ({ value: item.name, label: item.name }))} />
|
||||
<div className="flex h-8 overflow-hidden rounded-md border border-slate-300 bg-white text-[11px]">
|
||||
{([{ key: 'all', label: '全部车辆' }, { key: 'lingniu', label: '仅羚牛车辆' }, { key: 'external', label: '仅外部车辆' }] as const).map(item => (
|
||||
<button key={item.key} type="button" onClick={() => changeVehicleScope(item.key)} className={`inline-flex items-center gap-1 px-2.5 font-medium transition-colors ${vehicleScope === item.key ? 'bg-sky-50 text-sky-700 shadow-sm' : 'text-slate-500 hover:bg-slate-50'}`}>
|
||||
{item.key !== 'all' ? <Truck size={13} /> : null}{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="ehb-modal-hint-text">提示:点击表格行可四级层层展开</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void exportAll()} disabled={exporting} className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-slate-300 bg-white px-2.5 text-[11px] font-semibold text-slate-600 transition-colors hover:border-sky-300 hover:bg-sky-50 hover:text-sky-700 disabled:opacity-60">
|
||||
<Download size={14} />{exporting ? '正在导出…' : '导出 Excel 穿透账单'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mb-2 text-center text-[11px] text-slate-400 md:hidden">左右滑动查看完整数据与凭证列</p>
|
||||
<div className="ehb-modal-table-wrap is-v-scroll">
|
||||
<table className="ehb-modal-table min-w-[1080px]">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="min-w-[240px] border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="加氢站 / 客户 / 车辆与凭证链路" sortKey="name" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
||||
<th className="border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="类型 / 归属" sortKey="ownership" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
||||
<th className="border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="数据来源及凭证号" sortKey="source" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
||||
<th className="border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="核对状态" sortKey="verify" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
||||
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="加氢笔数" sortKey="recordCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="加氢总量 (Kg)" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="成本 (元)" sortKey="cost" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="客户收入 (元)" sortKey="revenue" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleStations.map(station => {
|
||||
const stationKey = String(station.id);
|
||||
return <StationBranch key={`${stationKey}:${station.name}`} station={station} expanded={Boolean(openStations[stationKey])} loadingKey={loading} customers={customers[stationKey] ?? []} customerFilter={customerFilter} plateFilter={plateFilter} openCustomers={openCustomers} vehicles={vehicles} orders={orders} openVehicles={openVehicles} sortKey={sortKey} sortDirection={sortDirection} onStation={() => void toggleStation(station)} onCustomer={customer => void toggleCustomer(station.id, customer)} onVehicle={(customer, vehicle) => void toggleVehicle(station.id, customer, vehicle)} />;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!root && loading === null ? <div className="mt-4 flex items-center gap-2 rounded-lg border border-dashed border-slate-300 bg-white p-5 text-sm text-slate-500"><Database size={18} />真实账本读取失败,请关闭后重试。</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>, document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value, tone = 'text-slate-900' }: { label: string; value: string; tone?: string }) {
|
||||
return <div className="ehb-modal-meta-item"><div className="ehb-modal-meta-label">{label}</div><div className={`ehb-modal-meta-val ${tone}`} title={value}>{value}</div></div>;
|
||||
}
|
||||
|
||||
function SearchSelect({ allLabel, value, onChange, options, disabled = false }: { allLabel: string; value: string; onChange: (value: string) => void; options: { value: string; label: string }[]; disabled?: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const selected = options.find(option => option.value === value);
|
||||
const filtered = options.filter(option => option.label.toLowerCase().includes(keyword.trim().toLowerCase()));
|
||||
const pick = (next: string) => {
|
||||
onChange(next);
|
||||
setOpen(false);
|
||||
setKeyword('');
|
||||
};
|
||||
return <div className={`ehb-bi-search-select w-[170px] ${disabled ? 'is-disabled' : ''}`}>
|
||||
<button type="button" aria-label={allLabel} aria-expanded={open} disabled={disabled} onClick={() => setOpen(value => !value)} className={`ehb-bi-search-select__trigger ${open ? 'is-open' : ''} ${selected ? 'has-value' : ''}`}>
|
||||
<span className="ehb-bi-search-select__label">{selected?.label ?? allLabel}</span><ChevronDown size={14} className="ehb-bi-search-select__chevron" />
|
||||
</button>
|
||||
{open ? <div className="ehb-bi-search-select__dropdown">
|
||||
<label className="ehb-bi-search-select__search"><Search size={13} /><input autoFocus value={keyword} onChange={event => setKeyword(event.target.value)} placeholder={`搜索${allLabel.replace('全部', '')}…`} /></label>
|
||||
<div className="ehb-bi-search-select__list">
|
||||
<button type="button" onClick={() => pick('all')} className={`ehb-bi-search-select__item ${value === 'all' ? 'is-selected' : ''}`}>{allLabel}</button>
|
||||
{filtered.map(option => <button key={option.value} type="button" onClick={() => pick(option.value)} className={`ehb-bi-search-select__item ${value === option.value ? 'is-selected' : ''}`}>{option.label}</button>)}
|
||||
{filtered.length === 0 ? <p className="ehb-bi-search-select__empty">暂无匹配结果</p> : null}
|
||||
</div>
|
||||
</div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ExpandMark({ open }: { open: boolean }) { return open ? <ChevronDown size={15} className="shrink-0 text-sky-600" /> : <ChevronRight size={15} className="shrink-0 text-sky-600" />; }
|
||||
|
||||
function StationBranch({ station, expanded, loadingKey, customers, customerFilter, plateFilter, openCustomers, vehicles, orders, openVehicles, sortKey, sortDirection, onStation, onCustomer, onVehicle }: {
|
||||
station: HydrogenOverviewDetailGroup; expanded: boolean; loadingKey: string | null; customers: HydrogenOverviewDetailGroup[]; customerFilter: string; plateFilter: string; openCustomers: Record<string, boolean>; vehicles: Record<string, HydrogenOverviewDetailGroup[]>; orders: Record<string, HydrogenOverviewDetailRecord[]>; openVehicles: Record<string, boolean>; sortKey: TreeSortKey; sortDirection: SortDirection; onStation: () => void; onCustomer: (customer: HydrogenOverviewDetailGroup) => void; onVehicle: (customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => void;
|
||||
}) {
|
||||
const stationKey = String(station.id);
|
||||
const visibleCustomers = sortTreeGroups(customerFilter === 'all' ? customers : customers.filter(customer => customerKey(station.id, customer) === customerFilter), sortKey, sortDirection);
|
||||
return <>
|
||||
<TreeRow className={expanded ? 'bg-sky-50 font-bold' : 'bg-slate-50 font-bold'} onClick={onStation} label={<><ExpandMark open={expanded} />{station.name}<span className="ml-2 text-[11px] font-medium text-slate-400">({station.customerCount} 家客户)</span></>} ownership="-" source="全量自动归集" verify="-" group={station} />
|
||||
{expanded && visibleCustomers.map(customer => {
|
||||
const key = customerKey(stationKey, customer);
|
||||
const childVehicles = vehicles[key] ?? [];
|
||||
const visibleVehicles = sortTreeGroups(plateFilter === 'all' ? childVehicles : childVehicles.filter(vehicle => vehicle.name === plateFilter), sortKey, sortDirection);
|
||||
return <Fragment key={key}>
|
||||
<TreeRow className={openCustomers[key] ? 'bg-slate-100' : 'bg-white'} onClick={() => onCustomer(customer)} indent={1} label={<><ExpandMark open={Boolean(openCustomers[key])} />└─ 客户:{customer.name}</>} ownership="客户" source={`${childVehicles.length || '待'} 辆车挂载`} verify="-" group={customer} />
|
||||
{openCustomers[key] && visibleVehicles.map(vehicle => {
|
||||
const key = vehicleKey(stationKey, customer, vehicle);
|
||||
return <Fragment key={key}>
|
||||
<TreeRow className={openVehicles[key] ? 'bg-slate-100 text-[11px]' : 'bg-white text-[11px]'} onClick={() => onVehicle(customer, vehicle)} indent={2} label={<><ExpandMark open={Boolean(openVehicles[key])} /><strong>{vehicle.name}</strong><span className="ml-1 text-[10px] font-normal text-slate-400">({vehicle.recordCount} 笔订单)</span></>} ownership={vehicle.vehicleScope === 'lingniu' ? '羚牛车辆' : '外部车辆'} source={vehicle.source ?? '未知来源'} verify={verifyText(vehicle.verifyStatus)} group={vehicle} />
|
||||
{openVehicles[key] && <OrderRows orders={orders[key] ?? []} sortKey={sortKey} sortDirection={sortDirection} />}
|
||||
</Fragment>;
|
||||
})}
|
||||
{openCustomers[key] && loadingKey === `customer:${key}` ? <LoadingRow text="正在读取车辆汇总…" /> : null}
|
||||
</Fragment>;
|
||||
})}
|
||||
{expanded && loadingKey === `station:${stationKey}` ? <LoadingRow text="正在读取客户汇总…" /> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
function TreeRow({ label, ownership, source, verify, group, indent = 0, className, onClick }: { label: ReactNode; ownership: string; source: string; verify: string; group: HydrogenOverviewDetailGroup; indent?: number; className: string; onClick: () => void }) {
|
||||
return <tr className={`${className} cursor-pointer border-b border-slate-100 transition-colors hover:bg-slate-50`} onClick={onClick}><td className="px-3 py-2.5" style={{ paddingLeft: `${12 + indent * 16}px` }}><span className="inline-flex items-center gap-1.5">{label}</span></td><td className="px-3 py-2.5 text-slate-500">{ownership}</td><td className="px-3 py-2.5 text-[11px] text-slate-500">{source}</td><td className="px-3 py-2.5">{verify === '-' ? <span className="text-slate-400">-</span> : <VerifyTag value={verify} />}</td><td className="px-3 py-2.5 text-right font-mono text-slate-600">{group.recordCount} 笔</td><td className="px-3 py-2.5 text-right font-mono font-semibold text-sky-600">{number(group.kg, 3)}</td><td className="px-3 py-2.5 text-right font-mono">{number(group.cost)}</td><td className="px-3 py-2.5 text-right font-mono">{number(group.revenue)}</td></tr>;
|
||||
}
|
||||
|
||||
function OrderRows({ orders, sortKey, sortDirection }: { orders: HydrogenOverviewDetailRecord[]; sortKey: TreeSortKey; sortDirection: SortDirection }) {
|
||||
return <>{sortTreeOrders(orders, sortKey, sortDirection).map(order => <tr key={order.id} className="border-b border-slate-100 bg-slate-50 text-[11px]"><td className="px-3 py-2" style={{ paddingLeft: '76px' }}><span className="mr-1.5 text-slate-300">└──</span><span className="mr-1 text-[10px] text-slate-500">订单编号</span><span className="font-mono font-semibold text-sky-600">{order.orderNo || order.id}</span><span className="ml-1 text-[10px] text-slate-500">({order.refuelTime})</span></td><td className="px-3 py-2 text-[10px] text-slate-500">单价 ¥{order.costPrice.toFixed(2)}/Kg</td><td className="px-3 py-2">{order.source}</td><td className="px-3 py-2"><VerifyTag value={verifyText(order.verifyStatus)} /></td><td className="px-3 py-2 text-right font-mono text-slate-400">1 笔</td><td className="px-3 py-2 text-right font-mono text-sky-600">{number(order.kg, 3)}</td><td className="px-3 py-2 text-right font-mono">{number(order.cost)}</td><td className="px-3 py-2 text-right font-mono">{number(order.revenue)}</td></tr>)}</>;
|
||||
}
|
||||
|
||||
function sortTreeGroups(rows: HydrogenOverviewDetailGroup[], sortKey: TreeSortKey, sortDirection: SortDirection) {
|
||||
return sortBy(rows, sortKey, sortDirection, (row, key) => {
|
||||
if (key === 'ownership') return row.vehicleScope ?? '';
|
||||
if (key === 'verify') return row.verifyStatus ?? '';
|
||||
return row[key === 'name' ? 'name' : key] ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
function sortTreeOrders(rows: HydrogenOverviewDetailRecord[], sortKey: TreeSortKey, sortDirection: SortDirection) {
|
||||
return sortBy(rows, sortKey, sortDirection, (row, key) => {
|
||||
if (key === 'name') return row.orderNo || row.id;
|
||||
if (key === 'ownership') return row.vehicleScope;
|
||||
if (key === 'verify') return row.verifyStatus;
|
||||
if (key === 'recordCount') return 1;
|
||||
return row[key] ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
function LoadingRow({ text }: { text: string }) { return <tr><td className="px-8 py-3 text-xs text-slate-400" colSpan={8}>{text}</td></tr>; }
|
||||
|
||||
function verifyText(value?: string) {
|
||||
const normalized = (value ?? '').toUpperCase();
|
||||
if (normalized === 'VERIFIED' || normalized === 'PASS') return '已核对';
|
||||
if (normalized === 'PARTIAL') return '部分核对';
|
||||
if (normalized === 'FAILED' || normalized === 'REJECT') return '异常';
|
||||
return '未核对';
|
||||
}
|
||||
|
||||
function VerifyTag({ value }: { value: string }) {
|
||||
const color = value === '已核对' ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : value === '部分核对' ? 'border-amber-100 bg-amber-50 text-amber-700' : value === '异常' ? 'border-rose-100 bg-rose-50 text-rose-700' : 'border-slate-200 bg-slate-50 text-slate-500';
|
||||
return <span className={`inline-flex rounded border px-1.5 py-0.5 text-[10px] font-medium ${color}`}>{value}</span>;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { ChevronDown, RefreshCw, Truck } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { HydrogenVehicleScope } from '../../api';
|
||||
import { formatRefreshTime } from '../model';
|
||||
|
||||
interface BiYearSelectProps {
|
||||
value: number;
|
||||
years: number[];
|
||||
onChange: (year: number) => void;
|
||||
}
|
||||
|
||||
function BiYearSelect({ value, years, onChange }: BiYearSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className="ehb-year-select-wrapper" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-year-select-btn ${isOpen ? 'is-active' : ''}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label="年份选择"
|
||||
>
|
||||
<span className="ehb-year-text">{value} 年</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
style={{
|
||||
transition: 'transform 0.2s ease',
|
||||
transform: isOpen ? 'rotate(180deg)' : 'none',
|
||||
color: '#64748b',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="ehb-year-dropdown">
|
||||
<div className="ehb-year-dropdown__header">切换数据年份</div>
|
||||
<div className="ehb-year-dropdown__list">
|
||||
{years.map((y) => (
|
||||
<button
|
||||
key={y}
|
||||
type="button"
|
||||
className={`ehb-year-dropdown__item ${y === value ? 'is-selected' : ''}`}
|
||||
onClick={() => {
|
||||
onChange(y);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{y} 年</span>
|
||||
{y === value && <span className="ehb-year-check">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewHeaderProps {
|
||||
activeYear: number;
|
||||
availableYears: number[];
|
||||
vehicleScope: HydrogenVehicleScope;
|
||||
verifyScope?: 'all' | 'verified';
|
||||
onVerifyScopeChange?: (scope: 'all' | 'verified') => void;
|
||||
selectedStationId?: number | null;
|
||||
selectedStationName?: string | null;
|
||||
stations?: { id: number; name: string }[];
|
||||
latestLedgerTime?: string | null;
|
||||
lastRefreshAt?: number;
|
||||
refreshing?: boolean;
|
||||
onSelectYear: (year: number) => void;
|
||||
onVehicleScopeChange: (scope: HydrogenVehicleScope) => void;
|
||||
onStationChange?: (stationId: number | null) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function OverviewHeader({
|
||||
activeYear,
|
||||
availableYears,
|
||||
vehicleScope,
|
||||
verifyScope = 'all',
|
||||
onVerifyScopeChange,
|
||||
latestLedgerTime,
|
||||
lastRefreshAt = 0,
|
||||
refreshing = false,
|
||||
onSelectYear,
|
||||
onVehicleScopeChange,
|
||||
onRefresh,
|
||||
}: OverviewHeaderProps) {
|
||||
return (
|
||||
<section className="ehb-daily-filter-card" style={{ marginBottom: 12 }} aria-label="总览筛选工具栏">
|
||||
<div className="ehb-daily-filter-row">
|
||||
<div className="ehb-daily-filter-group">
|
||||
<BiYearSelect value={activeYear} years={availableYears} onChange={onSelectYear} />
|
||||
<div className="ehb-pill-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-pill-btn ${verifyScope === 'all' ? 'is-active' : ''}`}
|
||||
onClick={() => onVerifyScopeChange?.('all')}
|
||||
>
|
||||
全量订单
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-pill-btn ${verifyScope === 'verified' ? 'is-active' : ''}`}
|
||||
onClick={() => onVerifyScopeChange?.('verified')}
|
||||
title="真实账本已返回全量有效订单"
|
||||
>
|
||||
仅已核对
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ehb-daily-filter-group">
|
||||
<div className="ehb-fleet-segmented">
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-fleet-btn ${vehicleScope === 'all' ? 'is-active' : ''}`}
|
||||
onClick={() => onVehicleScopeChange('all')}
|
||||
>
|
||||
全部车辆
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-fleet-btn ${vehicleScope === 'lingniu' ? 'is-active' : ''}`}
|
||||
onClick={() => onVehicleScopeChange('lingniu')}
|
||||
>
|
||||
<Truck size={14} />
|
||||
仅羚牛车辆
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-fleet-btn ${vehicleScope === 'external' ? 'is-active' : ''}`}
|
||||
onClick={() => onVehicleScopeChange('external')}
|
||||
>
|
||||
<Truck size={14} />
|
||||
仅外部车辆
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="ehb-chrome__clock" style={{ fontSize: 12, color: '#64748b' }}>
|
||||
{latestLedgerTime ? `账本 ${latestLedgerTime.slice(5, 16)}` : lastRefreshAt ? formatRefreshTime(lastRefreshAt) : '已同步'}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-btn ehb-btn--ghost"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
title="数据刷新"
|
||||
>
|
||||
<RefreshCw size={14} className={refreshing ? 'animate-spin' : ''} aria-hidden />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user