Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c13ab6d3b | ||
|
|
1e4afacac7 | ||
|
|
0c26b973ef | ||
|
|
6ea1b3d5dd | ||
|
|
c49a02e3d9 | ||
|
|
d7538ed6eb | ||
|
|
6822eafd09 | ||
|
|
7c8e886663 | ||
|
|
c4e2c03348 | ||
|
|
f82867b159 | ||
|
|
25e53b8f66 | ||
|
|
9750d35ba4 | ||
|
|
7802e5b0eb | ||
|
|
e4c8195ede | ||
|
|
5196db5df7 | ||
|
|
523f7b312d | ||
|
|
c199f031c5 | ||
|
|
795fb207cb | ||
|
|
954d626afc | ||
|
|
507bc90ed2 | ||
|
|
5d14a28b0f | ||
|
|
b28ca49491 | ||
|
|
9a9c9d08e1 | ||
|
|
680c7e0662 | ||
|
|
7556dbcebc | ||
|
|
3daa9808ce |
@@ -1,14 +0,0 @@
|
||||
# Copy to .env.local; never commit real credentials. Environment values win.
|
||||
HYDROGEN_DB_HOST=
|
||||
HYDROGEN_DB_PORT=3306
|
||||
HYDROGEN_DB_USER=
|
||||
HYDROGEN_DB_PASSWORD=
|
||||
HYDROGEN_DB_NAME=
|
||||
# 单站与外部客户数据租户;仅由服务端设置,不接受网页传入。
|
||||
HYDROGEN_TENANT_ID=000000
|
||||
# Optional legacy/electric business database; use a database-level read-only user.
|
||||
DB_HOST=
|
||||
DB_PORT=3306
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_NAME=
|
||||
@@ -1,5 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.worktrees
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --registry=https://registry.npmmirror.com/
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
@@ -10,7 +10,7 @@ FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --registry=https://registry.npmmirror.com/
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY src/server ./src/server
|
||||
COPY src/shared ./src/shared
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# 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,6 +10,11 @@ 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"
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# 登录方式与 Portainer 配置
|
||||
|
||||
不设置 `BI_AUTH_MODE` 时默认 `sso`,沿用业务系统 jumpToken 登录。
|
||||
设置 `BI_AUTH_MODE=password` 时只开放固定密码登录,关闭 SSO 换票入口。
|
||||
浏览器从 `/api/auth/config` 获取模式,密码不会打包到前端,不使用 VITE_ 密码变量。
|
||||
|
||||
## Portainer Stack
|
||||
|
||||
在现有 BI 服务的 `environment` 中合并以下配置,保留原镜像、端口、数据库等配置:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
BI_AUTH_MODE: ${BI_AUTH_MODE:-sso}
|
||||
BI_AUTH_PASSWORD: ${BI_AUTH_PASSWORD:-}
|
||||
JWT_SECRET: ${JWT_SECRET:?必须配置独立的随机签名密钥}
|
||||
DEV_BYPASS_AUTH: "0"
|
||||
NODE_ENV: production
|
||||
```
|
||||
|
||||
在 Stack 的 Environment variables 区域录入以下名称和值:
|
||||
|
||||
| 名称 | 固定密码模式 | SSO 模式 |
|
||||
| --- | --- | --- |
|
||||
| BI_AUTH_MODE | password | sso |
|
||||
| BI_AUTH_PASSWORD | 非空密码,允许短密码,推荐独立随机密码 | 留空 |
|
||||
| JWT_SECRET | 独立随机密钥,至少 32 字符 | 保留部署专用密钥 |
|
||||
|
||||
不要把真实密码提交进 Git。Portainer 中填写的 Stack 变量必须通过上面的 environment 映射才能进入容器。
|
||||
更新 Stack 并重新创建容器;单纯 Restart 不会更新容器环境变量。
|
||||
容器方式部署:Duplicate/Edit → Advanced container settings → Env,填写同样变量,再重新部署。
|
||||
需要先构建包含本功能的新镜像;旧镜像不认识这些变量。
|
||||
|
||||
## 权限、安全和验证
|
||||
|
||||
- 固定密码身份是共享只读身份,具有全量看板数据读取范围及能源访问权限,无调度/反馈管理角色;服务端拒绝写入方法。
|
||||
- 会话有效期 8 小时。更换密码、JWT_SECRET 或切换模式后,原密码会话失效。
|
||||
- 每个服务实例 15 分钟最多 20 次密码验证请求;多副本需在网关配置共享限流。共享限流可能被恶意请求耗尽,建议只在内网或受控网络开放。
|
||||
- 外网必须使用 HTTPS,避免明文传输密码和令牌。密码/环境变量对拥有容器管理权限的人可见。
|
||||
- 密码为空或全为空白,或签名密钥少于 32 字符时拒绝登录;无默认访问密码。不要沿用镜像中的旧默认 JWT_SECRET。
|
||||
- 短密码容易被猜中,仅建议在内网或受限访问环境使用;登录限流保持开启。
|
||||
- DB_READ_ONLY=1 时密码登录仍可用,业务写入仍被禁止。
|
||||
- 测试未登录访问、错误密码、正确密码、刷新恢复会话;切回 sso 后检查跳转登录。
|
||||
- 开发免登录仅 SSO 开发模式生效,生产必须保持 DEV_BYPASS_AUTH=0。
|
||||
|
||||
Portainer 官方说明:https://docs.portainer.io/user/docker/containers/advanced
|
||||
以及 https://docs.portainer.io/sts/user/docker/stacks/add
|
||||
@@ -1,671 +0,0 @@
|
||||
# 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 和经营数据正常。
|
||||
@@ -1,13 +0,0 @@
|
||||
# 本地只读预览
|
||||
|
||||
使用 Node.js 22+ 和 npm,在本项目内执行 `npm ci`,避免依赖其他工作目录的 node_modules 软链接。
|
||||
|
||||
将 `.env.local.example` 复制为 `.env.local` 并填写连接配置,或通过进程环境变量提供配置。使用数据库侧只读账号;不要提交真实凭据。
|
||||
|
||||
执行 `npm run dev:local`,浏览器访问 http://127.0.0.1:8115/energy#hydrogen 。前后端固定绑定本机 8115 / 3001;端口占用时退出,不自动换端口。停止终端进程即可停止服务;此命令不配置开机自启。
|
||||
|
||||
本地入口关闭 mock、启用本地免登录,禁用后台建表与定时归档,并拒绝 API 的 POST / PUT / PATCH / DELETE。请勿把免登录预览暴露到公网。氢能查询启用只读 SQL 检查;这不能替代数据库账号的只读权限。
|
||||
|
||||
`npm run dev` / `npm start` 保留原部署方式;设置 `DB_READ_ONLY=1` 时同样不启动后台任务,且 API 禁止写入。生产登录流程需要写请求,因此不要把本地只读模式当作生产部署配置。
|
||||
|
||||
验证:`npm test`、`npm run lint`、`npm run build`;健康检查为 `GET http://127.0.0.1:3001/api/health`。健康检查成功仅代表进程可用,还需检查氢能 meta / overview 的真实数据响应。
|
||||
@@ -0,0 +1,198 @@
|
||||
# OneOS 日里程与累计里程差额排查报告
|
||||
|
||||
- 排查日期:2026-09-16(Asia/Shanghai)
|
||||
- 涉及数据日期:2026-09-11 至 2026-09-15
|
||||
- 接收方:OneOS 开放平台及里程计算服务维护人员
|
||||
- 状态:已复现;待上游核查计算基准、跨日缺报及协议选源逻辑
|
||||
|
||||
## 1. 问题概述
|
||||
|
||||
BI 用户发现:昨日累计总里程减去前日累计总里程,不等于昨日的区间里程;继续向前检查,多日均存在差额。
|
||||
|
||||
本次在相同车辆范围、相同协议优先级下复现,并绕过 BI 直接请求 OneOS 单日接口核对异常车辆。上游返回本身就存在相邻日期累计差与日里程不一致的情况,不能仅用 BI 显示取整解释。
|
||||
|
||||
已确认的现象:
|
||||
|
||||
1. 某查询日返回的累计里程对应更早日期的报文;后续更新后的累计差跨越多天,与当日日里程不一致。
|
||||
2. 同一车辆相邻日期切换 MQTT / GB32960,累计读数发生跳变,且切换后可能选中很早的读数。
|
||||
|
||||
尚未确认:日里程的内部计算公式、有效数据判定、跨日补算规则及异常过滤规则。本文不将差额直接认定为漏算里程,也不将累计差直接认定为真实日行驶里程。
|
||||
|
||||
## 2. 查询范围与 BI 计算口径
|
||||
|
||||
本次 BI 查询未附加客户、部门、车牌或里程筛选条件,每天返回 916 台车辆。
|
||||
|
||||
```text
|
||||
GET /api/mileage/monitoring
|
||||
?startDate=2026-09-15
|
||||
&endDate=2026-09-15
|
||||
&sourcePriority=instrument
|
||||
&limit=2000
|
||||
```
|
||||
|
||||
`instrument` 对应上游协议优先级:
|
||||
|
||||
```json
|
||||
["GB32960", "MQTT"]
|
||||
```
|
||||
|
||||
BI 当前计算方式:
|
||||
|
||||
| 指标 | 计算方式 |
|
||||
| --- | --- |
|
||||
| 单车累计里程 | OneOS `totalMileageKm`,不以日里程反推 |
|
||||
| 单车单日里程 | OneOS `dailyMileageKm` |
|
||||
| 单车区间里程 | 区间内每日 `dailyMileageKm` 求和 |
|
||||
| 累计总里程 | 查询范围内车辆的累计里程求和;空值按 0 参与汇总 |
|
||||
| 当日总里程 | 查询范围内车辆的日里程求和;汇总接口对结果取整 |
|
||||
|
||||
以下对账表使用逐车日里程原始数值求和,保留三位小数,避免整数显示带来的误差。
|
||||
|
||||
## 3. 汇总对账结果
|
||||
|
||||
单位:km。定义 `差额 = 当天累计总里程 - 前一天累计总里程 - 当天日里程合计`。
|
||||
|
||||
| 日期 | 累计总里程 | 相邻日累计差 | 当日日里程合计 | 差额 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 2026-09-11 | 48,448,486.800 | — | 98,182.208 | — |
|
||||
| 2026-09-12 | 48,546,128.300 | 97,641.500 | 97,321.774 | +319.726 |
|
||||
| 2026-09-13 | 48,619,784.800 | 73,656.500 | 82,322.847 | −8,666.347 |
|
||||
| 2026-09-14 | 48,721,190.600 | 101,405.800 | 94,242.687 | +7,163.113 |
|
||||
| 2026-09-15 | 48,824,917.800 | 103,727.200 | 103,554.604 | +172.596 |
|
||||
|
||||
按车牌匹配相邻日期,差额来源分解如下:
|
||||
|
||||
| 日期 | 两日累计非空且协议相同的车辆数 / 净差额 | 两日累计非空且协议变化的车辆数 / 净差额 | 至少一日累计为空的车辆数 / 净差额 |
|
||||
| --- | --- | --- | --- |
|
||||
| 09-12 | 759 / +319.726 | 0 / 0 | 157 / 0 |
|
||||
| 09-13 | 758 / +674.653 | 1 / −9,341.000 | 157 / 0 |
|
||||
| 09-14 | 759 / +7,163.113 | 0 / 0 | 157 / 0 |
|
||||
| 09-15 | 759 / +172.596 | 0 / 0 | 157 / 0 |
|
||||
|
||||
本次样本中,空累计值组对差额净贡献为 0;同协议数据也存在明显不一致,不能只修复协议切换问题。
|
||||
|
||||
## 4. 异常样本一:累计读数跨日,日里程未与累计差对齐
|
||||
|
||||
以下记录已通过 OneOS 单日接口直接复核,响应均为 HTTP 200、`code=SUCCESS`,记录状态均为 `NORMAL`。
|
||||
|
||||
### 4.1 粤AGQ8377:9 月 15 日差额 +70.2 km
|
||||
|
||||
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
| 2026-09-13 | GB32960 | 14,366.0 | 6.1 | 2026-09-13 07:17:27 |
|
||||
| 2026-09-14 | GB32960 | 14,366.0 | 0 | 2026-09-13 07:17:27 |
|
||||
| 2026-09-15 | GB32960 | 14,467.4 | 31.2 | 2026-09-15 20:57:04 |
|
||||
|
||||
```text
|
||||
累计差 = 14,467.4 - 14,366.0 = 101.4
|
||||
差额 = 101.4 - 31.2 = 70.2 km
|
||||
```
|
||||
|
||||
查询 9 月 14 日时,累计读数仍来自 9 月 13 日。请核查 9 月 15 日日里程的起点读数及其时间,并解释 70.2 km 在日统计中的归属或过滤原因。
|
||||
|
||||
### 4.2 粤AGR6839:9 月 15 日差额 +38.0 km
|
||||
|
||||
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
| 2026-09-14 | GB32960 | 45,122.2 | 0 | 2026-09-11 11:10:53 |
|
||||
| 2026-09-15 | GB32960 | 45,162.8 | 2.6 | 2026-09-15 10:34:02 |
|
||||
|
||||
累计差为 40.6 km,日里程为 2.6 km,差额为 38.0 km。请核查跨多日无新读数后的统计基准及补算策略。
|
||||
|
||||
### 4.3 粤AGP3609:9 月 14 日差额 +808.7 km
|
||||
|
||||
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
| 2026-09-12 | GB32960 | 48,102.9 | 1,293.6 | 2026-09-12 15:08:47 |
|
||||
| 2026-09-13 | GB32960 | 48,102.9 | 0 | 2026-09-12 15:08:47 |
|
||||
| 2026-09-14 | GB32960 | 48,981.7 | 70.1 | 2026-09-14 23:59:51 |
|
||||
| 2026-09-15 | GB32960 | 50,349.1 | 1,367.4 | 2026-09-15 23:59:50 |
|
||||
|
||||
9 月 14 日累计差为 878.8 km,日里程为 70.1 km,差额为 808.7 km;9 月 15 日累计差与日里程则同为 1,367.4 km。该车适合对比正常日期与跨日缺报日期的计算路径。
|
||||
|
||||
## 5. 异常样本二:协议切换导致累计值倒退
|
||||
|
||||
车辆:沪A60591F。以下记录也已直接请求上游复核,均返回 `NORMAL`。
|
||||
|
||||
| 查询日期 | sourceProtocol | totalMileageKm | dailyMileageKm | dataTime(+08:00) |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
| 2026-09-12 | MQTT | 51,416 | 0 | 2026-09-12 04:53:49 |
|
||||
| 2026-09-13 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
|
||||
| 2026-09-14 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
|
||||
| 2026-09-15 | GB32960 | 42,075 | 0 | 2026-07-16 23:59:59 |
|
||||
|
||||
```text
|
||||
9 月 13 日累计差 = 42,075 - 51,416 = -9,341 km
|
||||
9 月 13 日日里程 = 0 km
|
||||
```
|
||||
|
||||
该车单独贡献 −9,341 km,叠加其他同协议车辆 +674.653 km 的差额后,形成当日全量净差额 −8,666.347 km。
|
||||
|
||||
请重点核查:
|
||||
|
||||
1. 同一请求优先级下,9 月 12 日采用 MQTT,9 月 13 日为何改用 7 月 16 日的 GB32960 读数?
|
||||
2. 协议“有效数据”的定义是否包含数据时间、新鲜度和查询日期边界?
|
||||
3. `NORMAL` 是否仅表示读数数值有效,而不表示查询当日存在有效报文?
|
||||
4. 两协议累计读数是否具有同一基准?切换时是否应提供不连续标识?
|
||||
5. 请核查该车协议记录对应的 VIN、设备及绑定历史,排除历史绑定变化造成的读数混用。
|
||||
|
||||
## 6. 上游直接复现方法
|
||||
|
||||
接口:`POST https://open.d.lnoneos.com/api/v1/vehicles/mileage/query`。
|
||||
|
||||
使用已授权的 API Key,通过环境变量注入,不将真实凭证写入报告或日志。
|
||||
|
||||
```bash
|
||||
curl --fail-with-body --silent --show-error \
|
||||
'https://open.d.lnoneos.com/api/v1/vehicles/mileage/query' \
|
||||
-H "Authorization: Bearer ${ONEOS_MILEAGE_API_KEY}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"date": "2026-09-15",
|
||||
"plateNumbers": ["粤AGQ8377", "粤AGR6839", "粤AGP3609", "沪A60591F"],
|
||||
"protocolPriority": ["GB32960", "MQTT"]
|
||||
}'
|
||||
```
|
||||
|
||||
依次将 `date` 替换为 `2026-09-12`、`2026-09-13`、`2026-09-14`、`2026-09-15`。
|
||||
|
||||
逐车对比字段:`date`、`plateNumber`、`vin`、`status`、`dailyMileageKm`、`totalMileageKm`、`sourceProtocol`、`dataTime`、`calculatedAt`、`updatedAt`。
|
||||
|
||||
本次直接复核保留了报告中的关键字段,未记录响应 `traceId`;上游重放时请保存完整响应及 `traceId`。历史数据如已重算或补报,当前返回值可能与本报告不同,请同时提供重算时间和修改前后结果。
|
||||
|
||||
## 7. 请上游提供的定位结果
|
||||
|
||||
### 7.1 明确字段契约
|
||||
|
||||
- `dailyMileageKm`:是当天首尾读数差、前日末值到当日末值的差,还是逐报文有效增量之和?是否丢弃跨日增量、异常跳变或负增量?
|
||||
- `totalMileageKm`:是查询日最后读数、截至查询日最近读数,还是其他快照?无当日报文时是否延用历史值?
|
||||
- `dataTime`:具体对应累计读数、日里程终点还是其他时间?
|
||||
- 两个里程字段是否保证来自同一协议、同一设备、同一计算快照?
|
||||
- 自然日边界是否统一为 Asia/Shanghai 的 00:00:00 至次日 00:00:00?
|
||||
- 是否承诺相邻日累计差等于日里程;若不承诺,请明确哪些场景不成立及差额如何解释。
|
||||
|
||||
### 7.2 提供样本计算明细
|
||||
|
||||
对上述四辆车,请返回:
|
||||
|
||||
1. 各协议参与选源的候选记录、时间及未选中原因。
|
||||
2. 日里程计算起止报文的时间、累计读数、设备/VIN 标识。
|
||||
3. 有效增量、被过滤增量及过滤原因。
|
||||
4. 缺报、补报、跨日增量归属,以及历史重算记录。
|
||||
5. 对每个差额给出可核对的数值分解,不能只解释为“统计口径不同”。
|
||||
|
||||
## 8. 修复或口径确认后的验收建议
|
||||
|
||||
1. 使用同一车辆集合和协议配置,重放 9 月 11 至 15 日数据,逐车核对后再核对汇总。
|
||||
2. 对连续上报且同协议的正常车辆,若契约约定可对账,应满足累计差与日里程一致;容差按上游实际数值精度明确。
|
||||
3. 对跨日缺报车辆,明确跨日增量是否补算、归属哪天,并能解释全部差额。
|
||||
4. 对协议切换车辆,避免将不同基准的累计值视为连续可减的序列;提供明确的协议切换和不连续信息。
|
||||
5. 对陈旧读数,区分“历史累计值仍有效”和“当日有有效报文”,避免用单一 `NORMAL` 混淆两者。
|
||||
6. 验证单日接口与区间接口对同一车、同一天的日里程、累计值和选源结果一致。
|
||||
7. 如修复涉及历史重算,提供重算范围及完成时间;BI 历史接口缓存最长 6 小时,应在刷新后复核。
|
||||
|
||||
## 9. 本次排查边界
|
||||
|
||||
- 已检查 BI 里程映射、日/区间聚合和汇总逻辑,并对异常样本直接调用上游单日接口复核。
|
||||
- 尚未访问 OneOS 原始报文、计算服务源码或内部存储,不能确定其内部算法根因。
|
||||
- 本次未修改业务代码、未变更里程口径、未触发历史数据重算。
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.14",
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
|
||||
+1
-4
@@ -1,12 +1,9 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"private": true,
|
||||
"version": "1.1.15",
|
||||
"version": "1.1.14",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:local": "concurrently -k -n server,client \"npm run dev:local:server\" \"npm run dev:local:client\"",
|
||||
"dev:local:server": "node --import tsx src/server/local.ts",
|
||||
"dev:local:client": "DEV_MOCK_API=0 VITE_DEV_BYPASS_AUTH=1 vite --host 127.0.0.1 --port 8115 --strictPort",
|
||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||
"dev:server": "tsx watch src/server/index.ts",
|
||||
"dev:client": "vite --port=3000 --host=0.0.0.0",
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<svg width="150" height="36" viewBox="0 0 150 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.0459 4.69253L24.4084 4.6875L17.3427 16.9334C16.0108 19.2369 14.67 21.5335 13.357 23.8492C12.8279 24.6836 12.7967 25.5367 14.0379 25.5663C17.6611 25.6526 21.3268 25.5356 24.9495 25.6042C24.4224 26.7246 23.2874 28.5534 22.6381 29.6706L21.6984 31.3061L13.8032 31.3074C11.2823 31.3084 7.42295 31.774 6.15339 28.9623C5.10923 26.6498 6.73472 24.2701 7.86608 22.3109L10.1604 18.3408L18.0459 4.69253Z" fill="#2F2828"/>
|
||||
<path d="M30.7029 4.69293L38.1664 4.6931C40.8704 4.68656 45.5224 4.1044 46.4284 7.56236C47.0408 9.9001 45.3543 12.286 44.21 14.218C42.2951 14.125 39.809 14.1965 37.8457 14.1908C38.3321 13.3838 40.2747 10.8182 38.8199 10.5324C37.4531 10.2639 35.1947 10.4548 33.7653 10.4209C32.0072 13.3847 30.3349 16.4036 28.5844 19.3723C28.4964 19.5215 28.3602 19.7716 28.257 19.9038L21.9204 19.9023L30.7029 4.69293Z" fill="#2F2828"/>
|
||||
<path d="M89.1232 4.67032C89.7835 4.62775 90.9099 4.66078 91.6053 4.65867L90.978 7.75816L98.7808 7.75999C98.6607 8.46499 98.5297 9.16816 98.3877 9.86921L90.5769 9.86774C90.1934 11.0614 89.853 13.7315 89.4731 15.1689L97.9522 15.1851C97.8321 15.8946 97.6872 16.5697 97.5364 17.2721C94.9175 17.1811 91.7297 17.2538 89.0954 17.2677C88.594 19.3603 88.1541 21.9308 87.7303 24.0635C86.9061 24.0335 86.0812 24.0264 85.2563 24.0421C85.7525 22.0904 86.18 19.3243 86.6177 17.2638L85.2372 17.257L77.0092 17.2648C77.1446 16.5758 77.2727 15.8857 77.3957 15.1944C79.8624 15.1112 82.3687 15.2077 84.839 15.1714C85.5417 15.161 86.2942 15.1588 86.9939 15.2057C87.447 13.5537 87.6608 11.5742 88.1087 9.87872C86.2986 9.82772 84.3142 9.86591 82.4909 9.86569C81.7604 11.226 81.0028 12.4816 80.2657 13.8163L77.5362 13.8114C79.6816 10.4975 80.6808 8.44223 82.2311 4.86287C83.0545 4.83579 84.0075 4.85801 84.8405 4.85771C84.4152 5.82928 83.998 6.81499 83.512 7.75669C85.1516 7.77967 86.8351 7.76247 88.4769 7.76452C88.7163 6.8407 88.9417 5.61995 89.1232 4.67032Z" fill="#2F2828"/>
|
||||
<path d="M70.2503 4.65824L73.7308 4.65625C74.7336 7.08713 76.1309 9.57515 77.5099 11.8054C76.5818 11.7752 75.527 11.798 74.5887 11.7967C73.5397 9.99962 72.6069 8.13727 71.7957 6.22117C71.2298 6.89947 70.7149 7.63591 70.1079 8.35496L72.1324 8.35415C72.359 9.56198 72.4332 11.2339 72.4619 12.4558L70.2398 12.4478C70.2372 11.0171 70.1331 9.90113 70.0025 8.48344C69.1539 9.57259 67.962 10.814 66.9868 11.7877L65.7182 11.7932L64.2958 11.7963C64.2202 12.4282 64.0755 13.0643 63.9444 13.6881L60.6789 13.6869C60.5363 14.5168 60.3766 15.3436 60.1998 16.1668L63.9171 16.1664L63.5281 18.0826C62.277 18.0555 60.9445 18.0784 59.687 18.0782C58.9474 20.2403 57.828 22.2532 56.3813 24.0224C55.6509 24.0768 54.3913 24.03 53.5917 24.0442C55.3706 21.7397 56.0946 20.7987 57.3125 18.0802L53.6008 18.0731C53.7608 17.396 53.882 16.8655 53.9824 16.1718C55.2848 16.1587 56.5873 16.1591 57.8898 16.1732L58.3652 13.6896L54.9231 13.6853C55.1139 13.0172 55.1958 12.5027 55.3015 11.8164C56.2519 11.7504 57.7795 11.7881 58.7587 11.8109C58.8945 11.0981 59.037 10.3867 59.1861 9.67657C57.9106 9.6642 56.6351 9.66427 55.3597 9.67657C55.4544 9.05996 55.5544 8.44415 55.66 7.8293C57.5354 7.80764 59.4484 7.82827 61.3266 7.83003C61.9933 6.86852 62.6551 5.74668 63.0825 4.66341L65.5379 4.65888C64.9966 5.88817 64.5907 6.67983 63.9019 7.83288L65.5427 7.82645C65.4452 8.4301 65.2987 9.07927 65.1744 9.68205C63.9717 9.64854 62.7013 9.67152 61.4934 9.67532C61.3775 10.3714 61.2146 11.1007 61.0712 11.7947L63.9987 11.7913C66.5788 9.20798 68.0943 7.61586 70.2503 4.65824Z" fill="#2F2828"/>
|
||||
<path d="M122.532 11.8166C125.798 11.7231 129.441 11.7977 132.734 11.7984C132.466 13.5695 131.978 15.624 131.64 17.4038C131.42 18.5691 130.997 20.9151 130.604 21.9327C130.313 22.7085 129.869 23.4177 129.299 24.0182C128.475 24.0895 127.218 24.0195 126.289 24.0612C126.736 23.701 127.014 23.4316 127.42 23.0228C128.176 22.1519 128.359 21.6669 128.744 20.5722C126.97 20.5048 124.822 20.5601 123.018 20.5584C122.945 21.3158 122.507 23.1882 122.339 24.0641C121.646 24.031 120.793 24.0186 120.111 24.0547C120.247 23.0506 120.522 21.8389 120.724 20.8256L121.799 15.4428C121.945 14.7076 122.299 12.3722 122.532 11.8166ZM129.104 18.8694C129.21 18.2708 129.304 17.7024 129.465 17.1139C127.646 17.0946 125.48 17.1746 123.726 17.1053C123.609 17.6915 123.487 18.2767 123.359 18.8607C125.251 18.8633 127.22 18.8364 129.104 18.8694ZM124.059 15.3809C124.989 15.3848 129.088 15.5282 129.805 15.3507C129.899 14.7602 130.003 14.261 130.135 13.6774C128.223 13.6522 126.311 13.6515 124.399 13.6752C124.302 14.1834 124.187 14.895 124.059 15.3809Z" fill="#2F2828"/>
|
||||
<path d="M28.257 19.9042C30.3408 19.8373 32.5067 19.9107 34.6003 19.8799C33.5304 21.7989 32.4375 23.705 31.3217 25.5977L37.6027 25.587C37.1076 26.6547 36.2454 28.0061 35.655 29.0623C35.3804 29.5534 34.6405 30.8847 34.3403 31.3077L28.0049 31.3022C28.8125 29.6956 29.8532 28.1298 30.7172 26.5471C30.8889 26.2323 31.0858 25.9059 31.2849 25.6084C29.1922 25.6072 27.049 25.5816 24.9604 25.61C25.6026 24.4466 26.26 23.2918 26.9324 22.1456C27.3144 21.4843 27.8407 20.5151 28.257 19.9042Z" fill="#007143"/>
|
||||
<path d="M99.7206 10.1076C106.203 9.98573 113.088 10.0971 119.599 10.1018C118.777 13.8389 118.005 17.7955 117.477 21.5875C117.37 22.3581 118.045 23.5899 118.033 24.042C117.125 24.0338 116.218 24.0361 115.311 24.0488C114.92 21.1109 115.519 19.0866 116.068 16.2425C116.357 14.7439 116.652 13.209 116.981 11.7202L99.4205 11.7137C99.5633 11.1642 99.6343 10.6691 99.7206 10.1076Z" fill="#2F2828"/>
|
||||
<path d="M64.5255 12.945C68.4291 12.8445 72.7488 12.9376 76.6835 12.9394C76.5327 13.651 76.4317 14.5237 75.9691 15.0919C75.4018 15.7882 74.7928 16.4582 74.197 17.1312L70.7419 21.0305L69.7438 21.059C70.2176 22.0474 70.6693 23.0462 71.0985 24.0548C70.1752 24.0352 69.2515 24.0344 68.3282 24.0525C67.9964 23.2947 67.6583 22.5397 67.3138 21.7875C66.4548 19.874 65.5159 17.9972 64.5 16.1621C65.3591 16.1484 66.2391 16.156 67.1001 16.1542C67.8294 17.3343 68.5019 18.5485 69.1155 19.7926C70.6881 18.1842 72.2203 16.5366 73.7103 14.8514L64.1427 14.8496C64.2745 14.2155 64.402 13.5807 64.5255 12.945Z" fill="#2F2828"/>
|
||||
<path d="M100.218 12.4635C104.946 12.4535 109.674 12.4727 114.401 12.5212C114.273 12.9715 114.112 13.3776 113.946 13.814C112.657 14.4639 111.179 15.1061 109.857 15.7056C111.599 15.949 113.275 15.9778 115.027 16.028C114.844 16.6716 114.645 17.3102 114.427 17.943C112.64 17.957 110.455 17.6924 108.705 17.3225C108.285 17.2407 107.508 16.991 107.072 16.8654C104.084 18.0184 101.851 17.949 98.7192 17.9316C98.791 17.2822 98.9052 16.6738 99.0208 16.0327C102.633 15.9161 105.599 15.8462 108.933 14.123C106.178 14.2266 102.668 14.1367 99.8604 14.135C99.9731 13.5764 100.092 13.0192 100.218 12.4635Z" fill="#2F2828"/>
|
||||
<path d="M98.7625 18.3504C100.425 18.3036 102.282 18.3451 103.963 18.3452L114.077 18.3459C113.979 18.982 113.891 19.4244 113.733 20.0481C111.681 19.9793 109.274 20.0318 107.196 20.0488C107.14 20.5854 106.873 21.7822 106.759 22.3601C108.872 22.2731 111.751 22.3361 113.883 22.3568C113.718 22.9049 113.8 23.5534 113.504 23.9553C113.406 24.0871 113.154 24.042 112.96 24.0414C107.731 23.9823 102.283 24.0035 97.0497 24.0421C97.15 23.4629 97.2678 22.9333 97.3996 22.3599C99.6372 22.3158 102.265 22.3081 104.496 22.3613C104.62 21.6 104.787 20.8055 104.936 20.0457C102.829 19.9936 100.54 20.0389 98.4199 20.0394L98.7625 18.3504Z" fill="#2F2828"/>
|
||||
<path d="M133.256 13.9166C133.997 13.9023 134.788 13.9161 135.533 13.9168C135.386 14.8072 135.174 15.7896 135.001 16.6831C137.384 16.4394 139.769 16.2153 142.155 16.0109C141.98 16.6792 141.965 17.2281 141.716 17.9362C141.421 17.9321 141.131 17.9513 140.839 17.9823C138.756 18.2028 136.663 18.3336 134.585 18.5982C134.457 19.6086 134.11 21.1394 133.89 22.1662L135.596 22.1591H140.893C140.792 22.7924 140.684 23.4247 140.569 24.0557C137.586 23.9698 134.244 24.0318 131.246 24.0396C131.9 20.9278 132.738 17.0234 133.256 13.9166Z" fill="#2F2828"/>
|
||||
<path d="M135.098 4.62931C135.853 4.62166 136.607 4.62419 137.363 4.6369C137.162 5.41299 137.017 6.28154 136.868 7.07464C139.102 6.84738 141.908 6.69114 144.053 6.37755C143.943 7.01342 143.835 7.73358 143.657 8.34683L136.465 8.98722C136.314 9.81163 136.157 10.6349 135.994 11.4569C136.445 11.4309 137.106 11.4499 137.573 11.4482C139.397 11.4591 141.222 11.4559 143.046 11.4386C142.935 12.1076 142.839 12.6758 142.674 13.3374C139.624 13.2772 136.387 13.3227 133.323 13.3214C133.552 12.5671 133.793 11.1594 133.955 10.3413C134.324 8.43507 134.706 6.53103 135.098 4.62931Z" fill="#2F2828"/>
|
||||
<path d="M102.916 4.63357C103.716 4.61473 104.573 4.6323 105.378 4.63596L104.934 5.48128L121.832 5.48318C121.739 6.10161 121.672 6.52446 121.507 7.12456C119.818 7.05448 117.769 7.10604 116.053 7.10625L103.984 7.11772C103.38 7.9858 102.762 8.89904 102.054 9.68109C101.302 9.67604 100.124 9.63799 99.419 9.71446C99.8816 9.06719 100.543 8.33907 101.04 7.66743C101.837 6.59222 102.296 5.78928 102.916 4.63357Z" fill="#2F2828"/>
|
||||
<path d="M126 4.67433C126.546 4.62029 128.007 4.65657 128.619 4.65502C127.72 6.37437 126.984 7.53498 125.875 9.09798C127.492 9.05503 129.258 9.08605 130.886 9.08474C130.822 8.136 130.773 7.13368 130.53 6.2156C131.306 6.15771 132.166 6.17288 132.949 6.17043C133.021 7.06163 133.221 8.13834 133.316 9.09988C133.309 9.63037 133.157 10.3841 133.057 10.9028C129.465 10.8026 125.423 10.8945 121.795 10.8945C123.602 8.78078 124.778 7.14823 126 4.67433Z" fill="#2F2828"/>
|
||||
<path d="M37.8456 14.1928C39.809 14.1984 42.2951 14.127 44.2099 14.22L41.9937 18.0919C41.7788 18.4647 41.1512 19.6063 40.9155 19.9025L34.5791 19.8957C35.6336 17.9833 36.7911 16.1017 37.8456 14.1928Z" fill="#007143"/>
|
||||
<path d="M40.9155 19.902C43.0195 19.8499 45.1502 19.9224 47.2506 19.8789C46.4721 21.4168 44.8928 24.105 43.9715 25.602L37.6138 25.6077C38.4831 23.9831 39.9311 21.447 40.9155 19.902Z" fill="#007143"/>
|
||||
<path d="M104.415 7.76391L120.496 7.76172C120.35 8.3135 120.238 8.87355 120.16 9.43887C118.084 9.37345 115.82 9.41552 113.733 9.4153L104.085 9.42408C104.155 8.93055 104.312 8.26469 104.415 7.76391Z" fill="#2F2828"/>
|
||||
<path d="M96.4304 26.879C99.2507 26.6667 98.9579 30.7714 95.9312 31.3474C92.5488 31.3098 93.6973 27.1881 96.4304 26.879ZM95.9883 30.5709C96.9304 30.2786 97.7955 29.3384 97.4662 28.3229C97.3944 28.096 97.2327 27.9087 97.0175 27.8056C96.7964 27.6975 96.4978 27.6448 96.2518 27.6614C95.3083 27.9393 94.49 28.8604 94.7842 29.8668C94.8501 30.099 95.0075 30.2944 95.2205 30.4079C95.4752 30.5427 95.7065 30.5599 95.9883 30.5709Z" fill="#2F2828"/>
|
||||
<path d="M120.579 26.8756C123.424 26.7179 123.034 30.7691 120.086 31.3456C116.806 31.3978 117.733 27.1994 120.579 26.8756ZM120.22 30.5368C122.019 29.9774 122.223 27.4751 120.314 27.6674C119.642 27.9156 119.439 28.0285 119.093 28.7081C118.616 29.6484 119.009 30.7721 120.22 30.5368Z" fill="#2F2828"/>
|
||||
<path d="M116.66 26.9238L117.992 26.9219C117.747 28.2834 117.347 29.9417 117.049 31.3153L116.157 31.3083C116.347 30.6023 116.495 29.7902 116.644 29.0672C116.688 28.9313 116.767 28.502 116.801 28.3408C116.395 29.0335 115.459 30.9511 114.675 31.3444C114.566 31.399 114.465 31.3038 114.371 31.2359C114.165 30.71 113.925 28.8772 113.836 28.242L113.198 31.3117L112.31 31.3165L113.23 26.9279L114.491 26.9225C114.605 27.7811 114.833 28.7942 114.973 29.6896C115.472 28.9993 116.215 27.6823 116.66 26.9238Z" fill="#2F2828"/>
|
||||
<path d="M86.5921 26.9211C86.9917 26.9147 87.4287 26.9264 87.8225 26.9095C90.4759 26.7956 90.1867 29.6754 88.5223 30.9111C87.5305 31.4374 86.7699 31.3173 85.6661 31.3024C85.7957 30.2878 86.3578 28.007 86.5921 26.9211ZM86.7231 30.4522C87.1359 30.4503 87.7347 30.4871 88.1007 30.316C88.6401 29.8127 89.3033 28.7778 88.6848 28.0956C88.3158 27.6887 87.7976 27.7551 87.2911 27.7533C87.1257 28.5612 86.9295 29.669 86.7231 30.4522Z" fill="#2F2828"/>
|
||||
<path d="M123.457 26.9226C124.213 26.9143 125.634 26.8007 126.186 27.3128C126.71 27.7978 125.896 28.5423 125.68 28.9376C125.595 29.0947 126.087 29.5814 126.078 29.8632C126.065 30.2604 125.746 30.6635 125.492 30.9391C124.737 31.4476 123.501 31.3147 122.564 31.3117C122.81 29.8751 123.187 28.3774 123.457 26.9226ZM123.593 30.5161C124.141 30.5116 124.358 30.5255 124.891 30.3884C125.067 30.151 125.129 30.0825 125.184 29.7933C125.024 29.2759 124.325 29.3801 123.862 29.3789L123.593 30.5161ZM123.999 28.6156C124.282 28.6098 124.866 28.6145 125.123 28.5796C125.245 28.3715 125.336 28.252 125.328 28.0082C125.082 27.6121 124.658 27.6901 124.214 27.6711C124.144 27.9762 124.058 28.3111 123.999 28.6156Z" fill="#2F2828"/>
|
||||
<path d="M90.735 26.9215C92.024 26.9243 94.8238 26.5442 93.2493 29.0441C93.0876 29.3009 92.7831 29.446 92.5137 29.583C92.7384 30.1413 92.9478 30.7419 93.1556 31.3095C92.8314 31.3198 92.3036 31.3998 92.0979 31.1551C91.9179 30.6395 91.7971 30.0956 91.3931 29.741C90.8792 29.6812 90.7592 30.8999 90.6765 31.3136L89.8201 31.3149C90.1114 29.8477 90.4166 28.3831 90.735 26.9215ZM91.2167 28.8258C91.4736 28.8227 92.2707 28.8268 92.4815 28.7911C92.6447 28.5511 92.7304 28.4484 92.7926 28.1652C92.6755 27.6152 91.9171 27.7496 91.4458 27.7535C91.366 28.1101 91.2891 28.4675 91.2167 28.8258Z" fill="#2F2828"/>
|
||||
<path d="M69.3081 26.9222L70.1771 26.9141C69.9698 28.186 69.5337 30.0383 69.2388 31.3177L68.1065 31.3098C67.8398 30.3494 67.4692 29.2815 67.1696 28.3128C67.0189 29.2546 66.7757 30.3753 66.5616 31.3076L65.6787 31.3079L66.6075 26.9221L67.7409 26.9223C68.0639 27.8733 68.37 28.898 68.6717 29.8604C68.873 28.8787 69.0851 27.8992 69.3081 26.9222Z" fill="#2F2828"/>
|
||||
<path d="M108.881 29.8868C109.027 29.2478 109.289 27.5001 109.54 27.0002C109.723 26.889 110.12 26.9136 110.352 26.9105C110.263 27.8288 109.679 30.3103 109.457 31.3037L108.341 31.3085C108.011 30.3531 107.719 29.367 107.384 28.4004L106.772 31.3134L105.9 31.3077C106.155 29.911 106.523 28.3126 106.832 26.9175L107.966 26.9206C108.2 27.8185 108.6 28.9795 108.881 29.8868Z" fill="#2F2828"/>
|
||||
<path d="M61.0197 26.9225L61.8629 26.9102C61.7079 28.0991 61.2332 30.0627 60.9722 31.3099L59.8503 31.3086C59.519 30.3592 59.2069 29.3429 58.8931 28.3815L58.2722 31.3125L57.3802 31.3049C57.7123 30.0129 58.0673 28.24 58.3208 26.9184L59.4601 26.922C59.6904 27.8033 60.1149 29.0009 60.4008 29.8927C60.5467 28.9635 60.7999 27.8383 61.0197 26.9225Z" fill="#2F2828"/>
|
||||
<path d="M78.6518 26.9215L79.5294 26.9146C79.4255 27.4595 79.2959 28.0172 79.1766 28.5603C79.689 28.553 80.216 28.5592 80.7298 28.5592L81.0951 26.923L81.9493 26.9141C81.8519 27.9269 81.2576 30.0868 81.0526 31.1643C81.0343 31.2615 80.9867 31.26 80.8865 31.3094C80.6281 31.3129 80.44 31.3262 80.1889 31.262C80.2658 30.6683 80.4253 30.0042 80.5534 29.4127L78.998 29.4118C78.867 30.0925 78.7586 30.6456 78.5669 31.3166L77.7339 31.3159C77.9893 29.9039 78.3363 28.3198 78.6518 26.9215Z" fill="#2F2828"/>
|
||||
<path d="M103.391 26.918L106.201 26.9222L106.032 27.7871C105.48 27.7389 104.623 27.7531 104.059 27.7493C104.008 28.0771 103.939 28.4261 103.88 28.7546L105.576 28.7521L105.445 29.4786C105.093 29.4693 104.014 29.408 103.727 29.5427L103.677 29.7236L103.488 30.4471L105.46 30.4415C105.391 30.7111 105.332 31.0336 105.273 31.3098C104.347 31.2889 103.359 31.3061 102.428 31.3067C102.714 29.9523 103.028 28.2233 103.391 26.918Z" fill="#2F2828"/>
|
||||
<path d="M101.23 26.8806C101.741 26.8186 102.269 26.978 102.715 27.2175C102.67 27.4649 102.637 27.8569 102.466 28.0164C102.325 28.0441 101.618 27.7141 101.272 27.6162C100.683 27.7595 100.181 27.9029 99.8684 28.4645C99.2265 29.6163 99.5405 30.8072 101.032 30.4857C101.475 30.0635 101.026 29.4324 101.687 29.1473C101.888 29.1663 102.02 29.1878 102.216 29.2257C102.312 29.5647 101.987 30.9407 101.742 31.0531C97.9931 32.7738 97.5708 27.2933 101.23 26.8806Z" fill="#2F2828"/>
|
||||
<path d="M74.9385 26.9214C75.1712 26.9118 75.4808 26.8744 75.6821 26.9707C75.7905 27.2226 75.7246 27.2838 75.6726 27.6035C75.3345 28.829 75.382 30.2578 74.2211 31.0737C73.6941 31.4969 72.454 31.4555 72.0957 30.8256C71.57 29.9017 72.2194 27.9607 72.4365 26.9236L73.3245 26.923C73.2315 27.4568 72.5294 29.9806 72.8643 30.2786C74.3522 31.6025 74.7181 27.8186 74.9385 26.9214Z" fill="#2F2828"/>
|
||||
<path d="M64.4934 26.8798C65.0658 26.8499 65.4038 26.9622 65.9183 27.1614C65.9004 27.4821 65.8231 27.8782 65.7694 28.201C65.3237 27.8267 65.0369 27.7669 64.4898 27.6111C64.0346 27.7299 63.4555 27.898 63.1815 28.3015C62.8659 28.7125 62.5666 30.01 63.1207 30.2988C64.5127 31.0242 64.4083 30.3038 64.6865 29.2341C64.6957 29.1988 65.0789 29.1547 65.1737 29.1403L65.443 29.2169C65.5798 29.5824 65.2474 30.6016 65.1325 31.0264C64.7903 31.1835 64.4256 31.2859 64.0516 31.3298C60.9761 31.6856 61.3322 27.1955 64.4934 26.8798Z" fill="#2F2828"/>
|
||||
<path d="M85.1552 26.9215L86.2297 26.918C85.7693 27.4771 85.3097 28.0605 84.8551 28.6258C84.028 29.5226 84.0163 30.1406 83.7762 31.3103L82.8773 31.3089C83.4446 29.0983 83.1291 29.0171 82.3298 26.9199L83.3107 26.9197C83.512 27.4507 83.7059 27.9844 83.8926 28.5204L85.1552 26.9215Z" fill="#2F2828"/>
|
||||
<path d="M139.373 26.9247L140.422 26.918C140.11 27.4009 139.441 28.178 139.059 28.6479C138.207 29.6578 138.305 30.0537 137.985 31.3112L137.113 31.3094C137.262 30.7606 137.396 30.0375 137.516 29.4702C137.197 28.6188 136.873 27.7696 136.543 26.9227L137.516 26.921C137.728 27.4476 137.932 27.9767 138.13 28.5083C138.556 27.9893 138.97 27.4613 139.373 26.9247Z" fill="#2F2828"/>
|
||||
<path d="M57.3993 4.67527C58.1061 4.65383 58.8637 4.66377 59.5748 4.66016C59.8347 5.63537 59.8946 6.41442 59.9652 7.40979C59.3798 7.36896 58.4268 7.39837 57.8139 7.39757C57.7331 6.3945 57.6463 5.64817 57.3993 4.67527Z" fill="#2F2828"/>
|
||||
<path d="M133.406 26.9219L136.447 26.9253L136.333 27.7751C135.986 27.7619 135.585 27.7671 135.234 27.7638C135.054 28.5778 134.891 29.4212 134.722 30.2397L134.468 31.3148L133.589 31.3145C133.858 30.1379 134.11 28.9573 134.343 27.7731C134.007 27.7561 133.613 27.7725 133.273 27.7783L133.406 26.9219Z" fill="#2F2828"/>
|
||||
<path d="M53.4287 26.918L54.3232 26.9254L53.5875 30.453L55.5179 30.4498L55.3763 31.3123C54.4204 31.2926 53.4227 31.3031 52.4631 31.2988C52.7142 30.6151 53.2746 27.7241 53.4287 26.918Z" fill="#2F2828"/>
|
||||
<path d="M128.859 26.924L129.753 26.9219C129.475 28.0093 129.226 29.3489 128.993 30.4563L130.915 30.4496L130.763 31.3133C129.848 31.2883 128.851 31.3078 127.93 31.3087L128.859 26.924Z" fill="#2F2828"/>
|
||||
<path d="M127.308 26.9204L128.164 26.918L127.239 31.3152L126.351 31.3151C126.65 29.8952 126.949 28.3149 127.308 26.9204Z" fill="#2F2828"/>
|
||||
<path d="M56.7724 26.918L57.6656 26.9141C57.3133 28.3287 57.0132 29.8795 56.7123 31.3129L55.8318 31.3086C56.1293 29.8417 56.4429 28.3781 56.7724 26.918Z" fill="#2F2828"/>
|
||||
<path d="M70.8595 26.9214L71.7381 26.918C71.567 28.0722 71.0941 30.1443 70.8119 31.3022L69.9707 31.3105C70.1852 29.9437 70.5812 28.3064 70.8595 26.9214Z" fill="#2F2828"/>
|
||||
<path d="M132.146 26.9187L133.056 26.918C132.809 28.082 132.418 30.2402 132.087 31.3152L131.25 31.3052C131.533 29.8444 131.902 28.3846 132.146 26.9187Z" fill="#2F2828"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 18 KiB |
+1
-19
@@ -3,7 +3,6 @@ import { Shell } from "./components/Shell";
|
||||
import AuthProvider from "./auth/AuthProvider";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
import UnauthorizedPage from "./auth/UnauthorizedPage";
|
||||
import PasswordLogin from "./auth/PasswordLogin";
|
||||
import { canAccessEnergy } from "./shared/auth/roles";
|
||||
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
|
||||
import { buildModules } from "./app/modules";
|
||||
@@ -14,16 +13,10 @@ import {
|
||||
|
||||
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
|
||||
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
|
||||
const HydrogenPrototypeBoard = lazy(
|
||||
() =>
|
||||
import("./vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiBoardApp").then(
|
||||
({ EnergyBiBoardApp }) => ({ default: EnergyBiBoardApp }),
|
||||
),
|
||||
);
|
||||
normalizeBrowserPath();
|
||||
|
||||
function AuthGate() {
|
||||
const { isLoading, isAuthenticated, error, user, mode } = useAuth();
|
||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||
const [route, setRoute] = useState(readBrowserRoute);
|
||||
const { routeKey, pathSet } = route;
|
||||
|
||||
@@ -72,7 +65,6 @@ function AuthGate() {
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (mode === 'password') return <PasswordLogin />;
|
||||
return <UnauthorizedPage message={error || undefined} />;
|
||||
}
|
||||
|
||||
@@ -91,16 +83,6 @@ function AuthGate() {
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (routeKey === "energy/hydrogen-board") {
|
||||
if (!canAccessEnergy(user?.roles)) {
|
||||
return <UnauthorizedPage message="无能源管理模块访问权限" />;
|
||||
}
|
||||
return (
|
||||
<Suspense fallback={<LoadingState label="正在加载氢能经营看板" />}>
|
||||
<HydrogenPrototypeBoard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// /energy 整组按能源权限控制
|
||||
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
|
||||
|
||||
@@ -35,5 +35,4 @@ test('keeps energy heatmap visibility and the independent energy navigation', ()
|
||||
buildModules('energy', []).map(module => module.id),
|
||||
['hydrogen', 'electric', 'etc'],
|
||||
);
|
||||
assert.equal(buildModules('energy', [])[0]?.label, '氢费BI');
|
||||
});
|
||||
|
||||
+5
-5
@@ -1,12 +1,12 @@
|
||||
import { lazy } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
BatteryCharging,
|
||||
Fuel,
|
||||
MapPinned,
|
||||
Receipt,
|
||||
Route,
|
||||
Truck,
|
||||
Wallet,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import type { ModuleConfig } from '../components/Shell';
|
||||
import { canAccessEnergy, canAccessScheduling } from '../shared/auth/roles';
|
||||
@@ -48,9 +48,9 @@ const SCHEDULING_MODULE: ModuleConfig = {
|
||||
};
|
||||
|
||||
const ENERGY_MODULES: ModuleConfig[] = [
|
||||
{ id: 'hydrogen', label: '氢费BI', icon: Fuel, component: HydrogenModule },
|
||||
{ id: 'electric', label: '电能', icon: Zap, component: ElectricModule },
|
||||
{ id: 'etc', label: 'ETC', icon: Wallet, component: EtcModule },
|
||||
{ id: 'hydrogen', label: '氢能', icon: Fuel, component: HydrogenModule },
|
||||
{ id: 'electric', label: '电能', icon: BatteryCharging, component: ElectricModule },
|
||||
{ id: 'etc', label: 'ETC', icon: Receipt, component: EtcModule },
|
||||
];
|
||||
|
||||
/** 根据主路径和角色生成导航;返回新数组,避免调用方修改静态注册表。 */
|
||||
|
||||
@@ -9,14 +9,11 @@ test('normalizes legacy asset paths without losing search parameters', () => {
|
||||
test('keeps canonical and hidden administration paths unchanged', () => {
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/asset', search: '', hash: '#assets' }), null);
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/admin/feedback', search: '', hash: '' }), null);
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/energy/hydrogen-board', search: '', hash: '' }), null);
|
||||
});
|
||||
|
||||
test('derives path groups and hidden routes from path or hash', () => {
|
||||
assert.equal(getPathSet('/energy'), 'energy');
|
||||
assert.equal(getPathSet('/energy/hydrogen-board'), 'energy');
|
||||
assert.equal(getPathSet('/asset'), 'asset');
|
||||
assert.equal(getRouteKey('/energy/hydrogen-board', ''), 'energy/hydrogen-board');
|
||||
assert.equal(getRouteKey('/asset', '#/ele/import'), 'ele/import');
|
||||
assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback');
|
||||
assert.equal(getRouteKey('/asset', '#mileage'), '');
|
||||
|
||||
+2
-11
@@ -5,13 +5,7 @@ interface BrowserLocation {
|
||||
search: string;
|
||||
hash: string;
|
||||
}
|
||||
const ROOT_PATHS = new Set([
|
||||
'/asset',
|
||||
'/energy',
|
||||
'/energy/hydrogen-board',
|
||||
'/ele/import',
|
||||
'/admin/feedback',
|
||||
]);
|
||||
const ROOT_PATHS = new Set(['/asset', '/energy', '/ele/import', '/admin/feedback']);
|
||||
|
||||
const LEGACY_PATHS: Record<string, { path: string; hash?: string }> = {
|
||||
'/': { path: '/asset' },
|
||||
@@ -37,13 +31,10 @@ export function normalizeBrowserPath(): void {
|
||||
}
|
||||
|
||||
export function getPathSet(pathname: string): PathSet {
|
||||
return pathname === '/energy' || pathname === '/energy/hydrogen-board' ? 'energy' : 'asset';
|
||||
return pathname === '/energy' ? 'energy' : 'asset';
|
||||
}
|
||||
|
||||
export function getRouteKey(pathname: string, hash: string): string {
|
||||
if (pathname === '/energy/hydrogen-board') {
|
||||
return 'energy/hydrogen-board';
|
||||
}
|
||||
if (pathname === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') {
|
||||
return 'ele/import';
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { setTokenGetter } from './api-client';
|
||||
const AUTH_API = '/api/auth';
|
||||
|
||||
export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [mode, setMode] = useState<'sso' | 'password'>('sso');
|
||||
const [state, setState] = useState<AuthState>({
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
@@ -20,6 +19,8 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setTokenGetter(() => tokenRef.current);
|
||||
|
||||
// 防止 StrictMode 双重调用(jumpToken 一次性使用)
|
||||
if (authStarted.current) return;
|
||||
authStarted.current = true;
|
||||
|
||||
// 监听 401 事件
|
||||
const onUnauthorized = () => {
|
||||
@@ -29,25 +30,14 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
window.addEventListener('auth:unauthorized', onUnauthorized);
|
||||
|
||||
if (!authStarted.current) { authStarted.current = true; authenticate(); }
|
||||
authenticate();
|
||||
|
||||
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
|
||||
}, []);
|
||||
|
||||
async function authenticate() {
|
||||
let currentMode: 'sso' | 'password';
|
||||
try {
|
||||
const response = await fetch(`${AUTH_API}/config`, { cache: 'no-store' });
|
||||
const config = await response.json();
|
||||
if (!response.ok || !['sso', 'password'].includes(config.mode)) throw new Error();
|
||||
currentMode = config.mode;
|
||||
setMode(currentMode);
|
||||
} catch {
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: '无法获取验证配置,请刷新重试' });
|
||||
return;
|
||||
}
|
||||
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
|
||||
if (currentMode === 'sso' && import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
@@ -69,15 +59,15 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
tokenRef.current = savedToken;
|
||||
// 验证 token 是否仍然有效(尝试请求 health)
|
||||
try {
|
||||
const res = await fetch(`${AUTH_API}/me`, {
|
||||
const res = await fetch('/api/health', {
|
||||
headers: { Authorization: `Bearer ${savedToken}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const savedUser = await res.json();
|
||||
const savedUser = sessionStorage.getItem('bi_user');
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: savedUser,
|
||||
user: savedUser ? JSON.parse(savedUser) : null,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
@@ -85,12 +75,6 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
} catch { /* token 无效,继续流程 */ }
|
||||
sessionStorage.removeItem('bi_jwt');
|
||||
sessionStorage.removeItem('bi_user');
|
||||
tokenRef.current = null;
|
||||
}
|
||||
|
||||
if (currentMode === 'password') {
|
||||
setState({ isLoading: false, isAuthenticated: false, user: null, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 从 URL 提取 jumpToken
|
||||
@@ -135,18 +119,8 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithPassword(password: string) {
|
||||
const response = await fetch(`${AUTH_API}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.token) throw new Error(data.message || '登录失败');
|
||||
tokenRef.current = data.token;
|
||||
sessionStorage.setItem('bi_jwt', data.token);
|
||||
sessionStorage.setItem('bi_user', JSON.stringify(data.user));
|
||||
setState({ isLoading: false, isAuthenticated: true, user: data.user, error: null });
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ ...state, mode, loginWithPassword }}>
|
||||
<AuthContext.Provider value={state}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from './useAuth';
|
||||
|
||||
export default function PasswordLogin() {
|
||||
const { loginWithPassword, error: sessionError } = useAuth();
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
return <main className="min-h-screen flex items-center justify-center bg-slate-950 p-6">
|
||||
<form className="w-full max-w-sm rounded-2xl bg-white p-7 shadow-xl" onSubmit={async event => {
|
||||
event.preventDefault(); if (busy || !loginWithPassword) return;
|
||||
setBusy(true); setError('');
|
||||
try { await loginWithPassword(password); } catch (e) { setError(e instanceof Error ? e.message : '登录失败,请重试'); }
|
||||
finally { setBusy(false); setPassword(''); }
|
||||
}}>
|
||||
<p className="text-xs font-semibold text-blue-600">羚牛 · 能源 BI</p>
|
||||
<h1 className="mt-2 text-2xl font-bold text-slate-900">访问看板</h1>
|
||||
<p className="mt-2 mb-6 text-sm text-slate-500">请输入管理员提供的访问密码。此入口仅提供只读访问。</p>
|
||||
<label htmlFor="bi-password" className="text-sm font-medium text-slate-700">访问密码</label>
|
||||
<input id="bi-password" type="password" autoComplete="current-password" required maxLength={1024} value={password} onChange={e => setPassword(e.target.value)} className="mt-2 w-full rounded-lg border border-slate-300 px-3 py-3 text-base focus:outline-blue-600" />
|
||||
{error || sessionError ? <p role="alert" className="mt-3 text-sm text-red-600">{error || sessionError}</p> : null}
|
||||
<button disabled={busy || !password} className="mt-5 min-h-11 w-full rounded-lg bg-blue-600 px-4 py-3 font-semibold text-white disabled:opacity-50">{busy ? '正在验证…' : '进入看板'}</button>
|
||||
</form>
|
||||
</main>;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export interface AuthState {
|
||||
mode?: 'sso' | 'password';
|
||||
loginWithPassword?: (password: string) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
user: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Building2, ChevronRight } from 'lucide-react';
|
||||
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { DemoModeProvider } from './Blur';
|
||||
import FeedbackFab from './FeedbackFab';
|
||||
@@ -83,9 +83,6 @@ 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, '-');
|
||||
@@ -95,16 +92,14 @@ 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>
|
||||
) : null}
|
||||
{/* Web 侧边栏 (md 及以上):能源各模块使用同一套规格,以电能为基准。 */}
|
||||
{/* 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} />
|
||||
@@ -123,8 +118,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
aria-expanded={isGroup ? isOpen : undefined}
|
||||
aria-haspopup={isGroup ? 'menu' : undefined}
|
||||
className={cn(
|
||||
'group relative flex flex-col items-center justify-center transition-all',
|
||||
'h-16 w-16 rounded-2xl text-[10px] font-black',
|
||||
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
|
||||
isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white',
|
||||
)}
|
||||
title={m.label}
|
||||
@@ -155,9 +149,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-2 border-t border-white/10 pt-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-400/10 text-emerald-300 ring-1 ring-emerald-300/20" title={user?.userName || '当前用户'}>
|
||||
<svg width="28" height="22" viewBox="4 3 44 30" role="img" aria-label="羚牛 Logo">
|
||||
<image href="/lingniu-logo-light.svg" width="150" height="36" style={{ filter: 'brightness(0) invert(1)' }} />
|
||||
</svg>
|
||||
<ShieldCheck size={18} />
|
||||
</div>
|
||||
<div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div>
|
||||
</div>
|
||||
@@ -165,17 +157,15 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
|
||||
{/* 内容区 */}
|
||||
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
|
||||
{showDesktopBreadcrumb ? (
|
||||
<div className={cn('pointer-events-none fixed right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex', isHydrogenPrototype ? 'left-[72px]' : 'left-20')}>
|
||||
<div className="pointer-events-none fixed left-20 right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex">
|
||||
<span className="text-slate-600">羚牛氢能 BI</span>
|
||||
<span className="mx-2 text-slate-300">/</span>
|
||||
<span>{activeLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
|
||||
{ActiveComponent && <ActiveComponent />}
|
||||
</Suspense>
|
||||
{!isHydrogenPrototype ? <FeedbackFab module={activeModule} /> : null}
|
||||
<FeedbackFab module={activeModule} />
|
||||
</main>
|
||||
|
||||
{/* 移动端底部导航 (md 以下) */}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
getWeeklyFlowRange,
|
||||
selectFlowDetails,
|
||||
type VehicleModalSelection,
|
||||
type FlowSelection,
|
||||
} from './model';
|
||||
import { SearchSelect } from '../../components/SearchSelect';
|
||||
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
||||
@@ -87,7 +88,7 @@ export default function AssetsModule() {
|
||||
const [flowStats, setFlowStats] = useState<FlowStatsResponse | null>(null);
|
||||
const [flowLoading, setFlowLoading] = useState(false);
|
||||
const [flowDailyExpanded, setFlowDailyExpanded] = useState(false);
|
||||
const [selectedFlow, setSelectedFlow] = useState<{ date: string; type: FlowType } | null>(null);
|
||||
const [selectedFlow, setSelectedFlow] = useState<FlowSelection | null>(null);
|
||||
|
||||
// Dept/Region/Customer data
|
||||
const [deptData, setDeptData] = useState<DeptGroup[]>([]);
|
||||
@@ -384,11 +385,11 @@ export default function AssetsModule() {
|
||||
const source = rows ?? flowStats?.details ?? [];
|
||||
if (source.length === 0) return;
|
||||
const table = source.map((item) => ({
|
||||
日期: item.date,
|
||||
业务日期: item.date,
|
||||
类型: item.typeLabel,
|
||||
车牌: item.plateNumber,
|
||||
流转时间: item.eventTime || '',
|
||||
提交时间: item.submitTime || '',
|
||||
运维签章时间: item.eventTime || '',
|
||||
建单时间: item.submitTime || '',
|
||||
部门: item.department || '',
|
||||
业务负责人: item.manager || '',
|
||||
客户: item.customerName || '',
|
||||
|
||||
@@ -2,17 +2,18 @@ import React from 'react';
|
||||
import { Download, X } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { FlowDetailItem, FlowType } from '../api';
|
||||
import type { FlowSelection } from '../model';
|
||||
|
||||
export const FLOW_META: Record<FlowType, { label: string; tone: string; chip: string }> = {
|
||||
delivered: { label: '交车', tone: 'text-blue-600 bg-blue-50 border-blue-100', chip: 'bg-blue-50 text-blue-700 border-blue-100' },
|
||||
returned: { label: '还车', tone: 'text-orange-600 bg-orange-50 border-orange-100', chip: 'bg-orange-50 text-orange-700 border-orange-100' },
|
||||
replaced: { label: '替换', tone: 'text-violet-600 bg-violet-50 border-violet-100', chip: 'bg-violet-50 text-violet-700 border-violet-100' },
|
||||
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;
|
||||
selectedFlow: FlowSelection | null;
|
||||
selectedFlowDetails: FlowDetailItem[];
|
||||
setSelectedFlow: React.Dispatch<React.SetStateAction<{ date: string; type: FlowType } | null>>;
|
||||
setSelectedFlow: React.Dispatch<React.SetStateAction<FlowSelection | null>>;
|
||||
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
|
||||
}
|
||||
export function FlowDetailModal({
|
||||
@@ -21,6 +22,9 @@ export function FlowDetailModal({
|
||||
setSelectedFlow,
|
||||
exportFlowDetails,
|
||||
}: FlowDetailModalProps) {
|
||||
const dateLabel = selectedFlow
|
||||
? ('date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`)
|
||||
: '';
|
||||
return (
|
||||
<>
|
||||
{/* Flow Detail Modal */}
|
||||
@@ -37,16 +41,17 @@ export function FlowDetailModal({
|
||||
<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>
|
||||
<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}明细
|
||||
{dateLabel} · {FLOW_META[selectedFlow.type].label}明细
|
||||
</h3>
|
||||
<div className="mt-1 text-[12px] font-bold text-slate-300">
|
||||
共 {selectedFlowDetails.length} 条,包含车牌、流转时间、提交时间、部门、负责人和客户
|
||||
共 {selectedFlowDetails.length} 车次,包含车牌、运维签章时间、建单时间、部门、负责人和客户
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭流转明细"
|
||||
onClick={() => setSelectedFlow(null)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||
>
|
||||
@@ -56,7 +61,7 @@ export function FlowDetailModal({
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportFlowDetails(selectedFlowDetails, `${selectedFlow.date}-${FLOW_META[selectedFlow.type].label}明细`)}
|
||||
onClick={() => exportFlowDetails(selectedFlowDetails, `${dateLabel}-${FLOW_META[selectedFlow.type].label}明细`)}
|
||||
disabled={selectedFlowDetails.length === 0}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-xl bg-white px-3 text-[11px] font-black text-slate-900 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
@@ -72,8 +77,8 @@ export function FlowDetailModal({
|
||||
<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-40 px-3 py-3">运维签章时间</th>
|
||||
<th className="w-40 px-3 py-3">建单时间</th>
|
||||
<th className="w-36 px-3 py-3">部门</th>
|
||||
<th className="w-28 px-3 py-3">业务负责人</th>
|
||||
<th className="px-3 py-3">客户</th>
|
||||
@@ -82,7 +87,7 @@ export function FlowDetailModal({
|
||||
<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 font-black text-slate-950">{item.plateNumber}{item.type === 'replaced' && <span className="mt-1 block text-[10px] text-violet-600">替换交车</span>}</td>
|
||||
<td className="px-3 py-3 text-slate-500">{item.eventTime || '-'}</td>
|
||||
<td className="px-3 py-3 text-blue-600">{item.submitTime || '-'}</td>
|
||||
<td className="px-3 py-3">{item.department || '-'}</td>
|
||||
@@ -105,13 +110,13 @@ export function FlowDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-[10px] font-bold text-slate-400">
|
||||
<div>提交</div>
|
||||
<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="font-black text-slate-400">运维签章时间</div>
|
||||
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2">
|
||||
|
||||
@@ -66,6 +66,7 @@ export function VehicleDetailModal({
|
||||
showPlateNumbers.category === 'Delivered' ? '本周已交车' :
|
||||
showPlateNumbers.category === 'Returned' ? '已还车' :
|
||||
showPlateNumbers.category === 'Replaced' ? '已替换' :
|
||||
showPlateNumbers.category === 'Abnormal' ? '异动' :
|
||||
showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` :
|
||||
showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}
|
||||
</p>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function AssetSummaryDesktopTable({
|
||||
}: AssetSummarySectionProps) {
|
||||
return (
|
||||
<div className="hidden lg:block overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse table-fixed min-w-[1200px]">
|
||||
<table className="w-full text-left border-collapse table-fixed min-w-[1300px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-100">
|
||||
<th className="p-3 font-semibold border-r border-gray-100 w-24">车型</th>
|
||||
@@ -26,6 +26,7 @@ export function AssetSummaryDesktopTable({
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-北京</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-新疆</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">库存-其他</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center text-amber-600 w-24">异动</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center w-24">待交车</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-green-50/30 w-24">当前在运营</th>
|
||||
<th className="p-3 font-semibold border-r border-gray-100 text-center bg-blue-50/20 w-24">本周交车</th>
|
||||
@@ -43,7 +44,7 @@ export function AssetSummaryDesktopTable({
|
||||
'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'}`}>
|
||||
<td colSpan={15} className={`p-3 font-bold ${theme === 'vibrant' ? 'text-white' : 'text-blue-700'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedAssetTypes.has(typeGroup.type) ?
|
||||
@@ -55,6 +56,7 @@ export function AssetSummaryDesktopTable({
|
||||
<div className={`flex gap-6 text-[11px] font-normal mr-4 ${theme === 'vibrant' ? 'text-white/80' : 'text-gray-500'}`}>
|
||||
<span>资产 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
|
||||
<span>库存 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
|
||||
<span>异动 <span className="font-bold">{typeGroup.totalAbnormal}</span></span>
|
||||
<span>运营 <span className={theme === 'vibrant' ? 'font-bold text-white' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,6 +121,12 @@ export function AssetSummaryDesktopTable({
|
||||
) : ''}
|
||||
</td>
|
||||
))}
|
||||
<td className="p-3 text-center border-r border-gray-100">
|
||||
<button type="button" className="font-bold text-amber-600 hover:underline"
|
||||
onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Abnormal', source: 'asset', title: `${model.model} - 异动` }); }}>
|
||||
{model.abnormal}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-center border-r border-gray-100">
|
||||
{model.pending > 0 ? (
|
||||
<button
|
||||
|
||||
@@ -33,6 +33,7 @@ export function AssetSummaryMobileCards({
|
||||
<div className={`flex gap-3 text-[9px] font-normal ${theme === 'vibrant' ? 'opacity-90' : 'text-gray-500'}`}>
|
||||
<span>资产 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-gray-700'}>{typeGroup.totalAssets}</span></span>
|
||||
<span>库存 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-blue-600'}>{typeGroup.totalInventory}</span></span>
|
||||
<span>异动 <span className="font-bold">{typeGroup.totalAbnormal}</span></span>
|
||||
<span>运营 <span className={theme === 'vibrant' ? 'font-bold' : 'font-bold text-green-600'}>{typeGroup.totalOperating}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,9 +56,10 @@ export function AssetSummaryMobileCards({
|
||||
{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">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<span className="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-blue-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', source: 'asset', title: model.model }); }}>资产 {model.total}</span>
|
||||
<span className="text-[10px] bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-orange-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Inventory', source: 'asset', title: `${model.model} - 库存` }); }}>库存 {model.inventory}</span>
|
||||
<button type="button" className="text-[10px] bg-amber-50 text-amber-700 px-1.5 py-0.5 rounded font-bold" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Abnormal', source: 'asset', title: `${model.model} - 异动` }); }}>异动 {model.abnormal}</button>
|
||||
<span className="text-[10px] bg-green-50 text-green-600 px-1.5 py-0.5 rounded font-bold cursor-pointer active:bg-green-100" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: model.model, location: 'All', category: 'Operating', source: 'asset', title: `${model.model} - 在运营` }); }}>运营 {model.operating}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { ArrowRight, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
import { getWeeklyFlowRange, type DateRange } from '../../model';
|
||||
|
||||
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
const ymd = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
const parseDate = (value: string) => new Date(`${value}T12:00:00`);
|
||||
const shiftDay = (date: Date, offset: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset);
|
||||
const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1;
|
||||
|
||||
export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onChange: (range: DateRange) => void }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [selecting, setSelecting] = useState<'start' | 'end'>('start');
|
||||
const [month, setMonth] = useState(() => parseDate(value.start));
|
||||
const [hovered, setHovered] = useState('');
|
||||
const today = ymd(new Date());
|
||||
const count = draft.end ? dayCount(draft.start, draft.end) : 0;
|
||||
const valid = count > 0 && count <= 370;
|
||||
const monthStart = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const previewEnd = draft.end || hovered || draft.start;
|
||||
const rangeStart = draft.start < previewEnd ? draft.start : previewEnd;
|
||||
const rangeEnd = draft.start > previewEnd ? draft.start : previewEnd;
|
||||
const quickPicks = [
|
||||
{ label: '今天', range: { start: today, end: today } },
|
||||
{ label: '昨天', range: { start: ymd(shiftDay(new Date(), -1)), end: ymd(shiftDay(new Date(), -1)) } },
|
||||
{ label: '业务周', range: getWeeklyFlowRange() },
|
||||
{ label: '近 7 天', range: { start: ymd(shiftDay(new Date(), -6)), end: today } },
|
||||
{ label: '近 30 天', range: { start: ymd(shiftDay(new Date(), -29)), end: today } },
|
||||
];
|
||||
const open = () => {
|
||||
setDraft(value); setSelecting('start'); setMonth(parseDate(value.start)); setHovered('');
|
||||
dialog.current?.showModal();
|
||||
};
|
||||
const choose = (date: string) => {
|
||||
if (selecting === 'start') {
|
||||
setDraft({ start: date, end: '' }); setSelecting('end');
|
||||
} else {
|
||||
setDraft(date < draft.start ? { start: date, end: draft.start } : { start: draft.start, end: date });
|
||||
setSelecting('start');
|
||||
}
|
||||
setHovered('');
|
||||
};
|
||||
const calendar = (offset: number) => {
|
||||
const current = new Date(monthStart.getFullYear(), monthStart.getMonth() + offset, 1);
|
||||
const year = current.getFullYear(), m = current.getMonth();
|
||||
const padding = (current.getDay() + 6) % 7;
|
||||
const length = new Date(year, m + 1, 0).getDate();
|
||||
return <div className={offset ? 'hidden sm:block' : ''} key={offset}>
|
||||
<div className="mb-3 text-center text-sm font-bold text-slate-700">{year} 年 {m + 1} 月</div>
|
||||
<div className="grid grid-cols-7 text-center">
|
||||
{weekdays.map(day => <div key={day} className="py-2 text-[11px] font-medium text-slate-400">{day}</div>)}
|
||||
{Array.from({ length: padding }, (_, i) => <div key={`blank-${i}`} />)}
|
||||
{Array.from({ length }, (_, i) => {
|
||||
const date = ymd(new Date(year, m, i + 1));
|
||||
const endpoint = date === draft.start || date === draft.end;
|
||||
const inRange = date >= rangeStart && date <= rangeEnd;
|
||||
return <button key={date} type="button" aria-label={date} aria-pressed={endpoint}
|
||||
onClick={() => choose(date)} onMouseEnter={() => { if (!draft.end) setHovered(date); }}
|
||||
className={`relative my-0.5 h-10 text-xs font-semibold transition focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-blue-500 ${endpoint ? 'rounded-xl bg-blue-600 text-white shadow-sm' : inRange ? 'bg-blue-50 text-blue-700' : 'rounded-xl text-slate-600 hover:bg-slate-100'} ${date === today && !endpoint ? 'font-black text-blue-600' : ''}`}>
|
||||
{i + 1}{date === today && <span className={`absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${endpoint ? 'bg-white' : 'bg-blue-500'}`} />}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
return <>
|
||||
<button type="button" data-testid="asset-flow-date-range" aria-haspopup="dialog" onClick={open}
|
||||
className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[10px] font-medium text-slate-400">统计日期 · 运维签章时间</span>
|
||||
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
|
||||
</span>
|
||||
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} 天</span>
|
||||
<ChevronDown size={16} className="shrink-0 text-slate-400" />
|
||||
</button>
|
||||
<dialog ref={dialog} aria-labelledby="flow-date-title" onClick={e => { if (e.target === e.currentTarget) dialog.current?.close(); }}
|
||||
className="fixed inset-0 m-auto max-h-[90dvh] w-[calc(100%-24px)] max-w-[680px] overflow-y-auto rounded-3xl border-0 bg-white p-0 text-slate-700 shadow-2xl backdrop:bg-slate-950/30 backdrop:backdrop-blur-sm">
|
||||
<div className="p-5 sm:p-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div><h3 id="flow-date-title" className="text-base font-bold text-slate-900">选择统计日期</h3><p className="mt-1 text-xs text-slate-400">先选开始日期,再选结束日期</p></div>
|
||||
<button type="button" aria-label="关闭日期选择" onClick={() => dialog.current?.close()} className="rounded-full p-2 text-slate-400 hover:bg-slate-100"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{quickPicks.map(pick => <button key={pick.label} type="button" onClick={() => { setDraft(pick.range); setMonth(parseDate(pick.range.start)); setSelecting('start'); setHovered(''); }}
|
||||
className={`rounded-lg border px-3 py-2 text-xs font-medium transition ${draft.start === pick.range.start && draft.end === pick.range.end ? 'border-blue-200 bg-blue-50 text-blue-600' : 'border-slate-100 text-slate-500 hover:border-blue-200 hover:bg-blue-50'}`}>{pick.label}</button>)}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
{(['start', 'end'] as const).map(field => <button key={field} type="button" onClick={() => { setSelecting(field); setHovered(''); setMonth(parseDate(draft[field] || draft.start)); }}
|
||||
className={`rounded-xl border px-3 py-2.5 text-left transition ${selecting === field ? 'border-blue-400 bg-blue-50/60 ring-2 ring-blue-50' : 'border-slate-200 bg-slate-50/50'}`}>
|
||||
<span className="block text-[10px] text-slate-400">{field === 'start' ? '开始日期' : '结束日期'}</span><span className="mt-1 block text-sm font-bold">{draft[field] || '请选择结束日期'}</span>
|
||||
</button>)}
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between gap-2">
|
||||
<button type="button" aria-label="上个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronLeft size={18} /></button>
|
||||
<div className="flex gap-2">
|
||||
<select aria-label="年份" value={monthStart.getFullYear()} onChange={e => setMonth(new Date(Number(e.target.value), monthStart.getMonth(), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
|
||||
{Array.from({ length: Math.max(new Date().getFullYear() + 1, monthStart.getFullYear()) - Math.min(2016, monthStart.getFullYear()) + 1 }, (_, i) => Math.min(2016, monthStart.getFullYear()) + i).map(year => <option key={year} value={year}>{year} 年</option>)}
|
||||
</select>
|
||||
<select aria-label="月份" value={monthStart.getMonth()} onChange={e => setMonth(new Date(monthStart.getFullYear(), Number(e.target.value), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
|
||||
{Array.from({ length: 12 }, (_, i) => <option key={i} value={i}>{i + 1} 月</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" aria-label="下个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronRight size={18} /></button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-6 sm:grid-cols-2" onMouseLeave={() => setHovered('')}>{calendar(0)}{calendar(1)}</div>
|
||||
</div>
|
||||
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4 sm:px-6">
|
||||
<span role="status" className={`text-xs ${count > 370 ? 'text-red-500' : 'text-slate-400'}`}>{!draft.end ? '请选择结束日期' : count > 370 ? '最多选择 370 天' : `已选 ${count} 天(包含首尾)`}</span>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button type="button" onClick={() => dialog.current?.close()} className="rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-500 hover:bg-slate-100">取消</button>
|
||||
<button type="button" disabled={!valid} onClick={() => { onChange(draft); dialog.current?.close(); }} className="flex items-center gap-1 rounded-xl bg-blue-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"><Check size={14} />应用日期</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { FlowType } from '../../api';
|
||||
import { FLOW_META } from '../FlowDetailModal';
|
||||
import type { FlowStatisticsCardProps } from './types';
|
||||
import { FlowDateRangePicker } from './FlowDateRangePicker';
|
||||
|
||||
const FLOW_TYPES: FlowType[] = ['delivered', 'returned', 'replaced'];
|
||||
|
||||
@@ -23,7 +24,7 @@ export function FlowStatisticsCard({
|
||||
<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>
|
||||
<span>交还车统计(车次)</span>
|
||||
{flowLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,37 +40,8 @@ export function FlowStatisticsCard({
|
||||
</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>
|
||||
<p className="mt-1 text-[11px] text-slate-400">按运维签章时间统计;交车包含替换交车,合计=交车+还车,同车多笔业务分别计次。</p>
|
||||
<FlowDateRangePicker value={flowRange} onChange={setFlowRange} />
|
||||
<div className="mt-2 rounded-2xl border border-slate-100 bg-slate-50/70 px-2 py-2.5">
|
||||
<div className="grid grid-cols-4 items-center text-center">
|
||||
<div className="px-2">
|
||||
@@ -77,12 +49,16 @@ export function FlowStatisticsCard({
|
||||
<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">
|
||||
<button key={type} type="button"
|
||||
data-testid={`asset-flow-total-${type}`}
|
||||
disabled={flowLoading || !flowStats?.totals[type]}
|
||||
onClick={() => flowStats && setSelectedFlow({ start: flowStats.start, end: flowStats.end, type })}
|
||||
className="border-l border-slate-200/70 px-2 transition hover:bg-slate-100 focus-visible:outline-blue-500 disabled:cursor-not-allowed disabled:opacity-35">
|
||||
<div className={`text-lg font-black leading-none ${type === 'delivered' ? 'text-blue-600' : type === 'returned' ? 'text-orange-600' : 'text-violet-600'}`}>
|
||||
{flowStats?.totals[type] ?? 0}
|
||||
</div>
|
||||
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{FLOW_META[type].label}</div>
|
||||
</div>
|
||||
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{type === 'replaced' ? '其中:替换交车' : FLOW_META[type].label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,7 +90,7 @@ export function FlowStatisticsCard({
|
||||
<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 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">
|
||||
@@ -133,7 +109,7 @@ export function FlowStatisticsCard({
|
||||
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>
|
||||
<div className={`mt-1 text-[10px] font-black ${type === 'delivered' ? 'text-blue-500' : type === 'returned' ? 'text-orange-500' : 'text-violet-500'}`}>{type === 'replaced' ? '其中:替换交车' : FLOW_META[type].label}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Activity, PlusCircle, Truck, Warehouse } from 'lucide-react';
|
||||
import { Activity, TriangleAlert, PlusCircle, Truck, Warehouse } from 'lucide-react';
|
||||
import type { SummaryMetricsProps } from './types';
|
||||
|
||||
export function SummaryMetrics({
|
||||
@@ -11,7 +11,7 @@ export function SummaryMetrics({
|
||||
return (
|
||||
<>
|
||||
{/* Header Summary - Ultra Compact */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-2">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 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: '资产概览' })}>
|
||||
@@ -49,11 +49,21 @@ export function SummaryMetrics({
|
||||
<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>
|
||||
|
||||
<button type="button" data-testid="asset-abnormal-card"
|
||||
className="rounded-2xl border border-amber-100 bg-white p-3 shadow-sm flex items-center gap-2 text-left transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Abnormal', source: 'asset', title: '异动车辆' })}>
|
||||
<div className="w-8 h-8 rounded-xl bg-amber-50 flex items-center justify-center text-amber-600"><TriangleAlert size={14} /></div>
|
||||
<div>
|
||||
<div className="text-[9px] text-amber-600 font-bold leading-none mb-0.5">异动</div>
|
||||
<div className="text-base font-bold text-amber-600 leading-none">{SUMMARY.inventory.abnormal}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 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: '待交车' })}>
|
||||
@@ -70,7 +80,7 @@ export function SummaryMetrics({
|
||||
<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="grid min-w-0 flex-1 grid-cols-4 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>
|
||||
@@ -79,6 +89,10 @@ export function SummaryMetrics({
|
||||
<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">{(SUMMARY.totalAssets > 0 ? SUMMARY.inventory.abnormal / SUMMARY.totalAssets * 100 : 0).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>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { FlowDetailItem, FlowStatsResponse, FlowType } from '../../api';
|
||||
import type {
|
||||
DateRange,
|
||||
FlowSelection,
|
||||
InventoryFilters,
|
||||
VehicleModalSelection,
|
||||
} from '../../model';
|
||||
@@ -24,7 +25,7 @@ export interface FlowStatisticsCardProps {
|
||||
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
|
||||
flowDailyExpanded: boolean;
|
||||
setFlowDailyExpanded: Dispatch<SetStateAction<boolean>>;
|
||||
setSelectedFlow: Dispatch<SetStateAction<{ date: string; type: FlowType } | null>>;
|
||||
setSelectedFlow: Dispatch<SetStateAction<FlowSelection | null>>;
|
||||
}
|
||||
|
||||
export interface AssetSummarySectionProps {
|
||||
|
||||
@@ -296,6 +296,20 @@ test('流转明细按日期和类型同时筛选,客户饼图按区域汇总',
|
||||
['1'],
|
||||
);
|
||||
|
||||
flowStats.details.push(
|
||||
{ ...flowStats.details[0], id: '3', type: 'replaced', typeLabel: '替换交车' },
|
||||
{ ...flowStats.details[0], id: '4', type: 'replaced', typeLabel: '替换交车', date: '2026-08-13' },
|
||||
);
|
||||
assert.deepEqual(selectFlowDetails(flowStats, { date: '2026-08-12', type: 'delivered' }).map(x => x.id), ['1', '3']);
|
||||
assert.deepEqual(selectFlowDetails(flowStats, { date: '2026-08-12', type: 'replaced' }).map(x => x.id), ['3']);
|
||||
assert.deepEqual(selectFlowDetails(flowStats, { date: '2026-08-12', type: 'returned' }).map(x => x.id), ['2']);
|
||||
|
||||
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-12', end: '2026-08-13', type: 'delivered' }).map(x => x.id), ['1', '3', '4']);
|
||||
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-12', end: '2026-08-13', type: 'returned' }).map(x => x.id), ['2']);
|
||||
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-12', end: '2026-08-13', type: 'replaced' }).map(x => x.id), ['3', '4']);
|
||||
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-13', end: '2026-08-13', type: 'delivered' }).map(x => x.id), ['4']);
|
||||
assert.deepEqual(selectFlowDetails(flowStats, { start: '2026-08-14', end: '2026-08-15', type: 'delivered' }), []);
|
||||
|
||||
const customers = [
|
||||
customer({ region: '广东', total: 2 }),
|
||||
customer({ customer: '客户乙', region: '浙江', total: 5 }),
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface ModalVehicleFilters {
|
||||
|
||||
export type VehicleModalCategory =
|
||||
| 'Inventory'
|
||||
| 'Abnormal'
|
||||
| 'Pending'
|
||||
| 'Delivered'
|
||||
| 'Returned'
|
||||
@@ -77,7 +78,7 @@ export interface VehicleListRequestParams {
|
||||
batch?: string;
|
||||
model?: string;
|
||||
location?: string;
|
||||
category?: 'Inventory' | 'Operating' | 'Pending';
|
||||
category?: 'Inventory' | 'Abnormal' | 'Operating' | 'Pending';
|
||||
vehicleType?: string;
|
||||
manager?: string;
|
||||
customer?: string;
|
||||
@@ -97,10 +98,9 @@ export type VehicleModalRequest =
|
||||
}
|
||||
| { kind: 'vehicles'; params: VehicleListRequestParams };
|
||||
|
||||
export interface FlowSelection {
|
||||
date: string;
|
||||
type: FlowType;
|
||||
}
|
||||
export type FlowSelection =
|
||||
| { date: string; type: FlowType }
|
||||
| { start: string; end: string; type: FlowType };
|
||||
|
||||
export function formatLocalDateTime(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
@@ -172,6 +172,7 @@ export function buildVehicleModalRequest(
|
||||
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 === 'Abnormal') params.category = 'Abnormal';
|
||||
if (selection.category === 'Inventory') params.category = 'Inventory';
|
||||
if (selection.category === 'Operating') params.category = 'Operating';
|
||||
if (selection.category === 'Pending') params.category = 'Pending';
|
||||
@@ -391,7 +392,10 @@ export function selectFlowDetails(
|
||||
) {
|
||||
if (!flowStats || !selection) return [];
|
||||
return flowStats.details.filter((detail) => (
|
||||
detail.date === selection.date && detail.type === selection.type
|
||||
('date' in selection
|
||||
? detail.date === selection.date
|
||||
: detail.date >= selection.start && detail.date <= selection.end) && (detail.type === selection.type ||
|
||||
(selection.type === 'delivered' && detail.type === 'replaced'))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface BatchSummary {
|
||||
batch: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
@@ -36,6 +37,7 @@ export interface ModelSummary {
|
||||
model: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
@@ -49,6 +51,7 @@ export interface TypeSummary {
|
||||
type: string;
|
||||
totalAssets: number;
|
||||
totalInventory: number;
|
||||
totalAbnormal: number;
|
||||
totalOperating: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
@@ -62,6 +65,7 @@ export interface BatchGroup {
|
||||
batch: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
@@ -73,6 +77,7 @@ export interface BatchGroup {
|
||||
type: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
@@ -153,6 +158,7 @@ export interface RegionTypeBreakdown {
|
||||
total: number;
|
||||
operating: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
pending: number;
|
||||
customers: string[];
|
||||
}
|
||||
|
||||
@@ -1,52 +1,79 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BadgeCheck, CarFront, FileText, ReceiptText, WalletCards } from 'lucide-react';
|
||||
import { fetchEtcOverview, type EtcOverviewResponse } from './api';
|
||||
import { motion } from 'motion/react';
|
||||
import { Construction, Hammer } from 'lucide-react';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
function formatYuan(value: number) {
|
||||
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`;
|
||||
}
|
||||
const ETC_HINTS = [
|
||||
'ETC 通行费数据正在与发卡方系统打通…',
|
||||
'工人 GG 正在搭脚手架,敬请期待 ~',
|
||||
'马上能看到每月通行费明细啦',
|
||||
'想看哪个维度的 ETC?反馈一下嘛',
|
||||
'上线时机:等数据接通的那一天',
|
||||
];
|
||||
|
||||
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-3">
|
||||
<EmptyState title="暂无 ETC 通行台账" description="当前账单和通行明细均未入库,接入后会自动展示统计数据。" />
|
||||
<RotatingFooterHint hints={['ETC 台账状态已按数据库实时核对', '待通行明细入库后自动生成统计']} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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 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 />
|
||||
这些数据都在路上啦
|
||||
</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 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>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
<RotatingFooterHint />
|
||||
</motion.div>
|
||||
|
||||
<RotatingFooterHint hints={ETC_HINTS} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BatteryCharging, CalendarDays, ChevronRight, TrendingUp, Wallet } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Wallet } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchElectricMonthly } from './api';
|
||||
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
||||
@@ -28,7 +28,6 @@ export default function ElectricDaily() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
setMonths(null);
|
||||
const query = pick === 'custom'
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||
: { range: pick };
|
||||
@@ -39,11 +38,7 @@ export default function ElectricDaily() {
|
||||
// 默认展开最新一个月
|
||||
if (m.length > 0) setOpenMonths(new Set([m[0].month]));
|
||||
})
|
||||
.catch(e => {
|
||||
if (cancelled) return;
|
||||
setMonths(null);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
|
||||
|
||||
@@ -64,6 +59,7 @@ export default function ElectricDaily() {
|
||||
} = useMemo(() => summarizeElectricMonths(months), [months]);
|
||||
const scopeLabel = getRangeModeLabel(pick);
|
||||
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
||||
|
||||
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||
setPick(nextPick);
|
||||
@@ -76,23 +72,6 @@ export default function ElectricDaily() {
|
||||
setDateRange(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<DailyRangeControls
|
||||
pick={pick}
|
||||
dateRange={dateRange}
|
||||
customer={customer}
|
||||
onQuickPick={applyQuickPick}
|
||||
onCustomPick={() => setPick('custom')}
|
||||
onDateRangeChange={updateDateRange}
|
||||
onCustomerChange={setCustomer}
|
||||
/>
|
||||
<ErrorState message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<DailyRangeControls
|
||||
@@ -110,15 +89,37 @@ export default function ElectricDaily() {
|
||||
<MetricTile
|
||||
icon={Wallet}
|
||||
label="充电费用"
|
||||
value={`¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||||
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前范围未记录费用'}
|
||||
value={hasFeeDetail ? `¥${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>
|
||||
@@ -184,6 +185,7 @@ 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,32 +34,28 @@ export default function ElectricOverview() {
|
||||
const trendData = data.trend;
|
||||
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
|
||||
const trendMonthLabel = trendData[0]?.date.slice(0, 7);
|
||||
const now = new Date();
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
const dataIsCurrentMonth = trendMonthLabel === currentMonth;
|
||||
const chartTitle = trendMonthLabel ? `${trendMonthLabel} 每日充电` : '每日充电';
|
||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
|
||||
? `${trendMonthLabel} 每日充电`
|
||||
: '本月每日充电';
|
||||
const activeDays = trendData.filter(item => item.kwh > 0).length;
|
||||
const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0;
|
||||
const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 0;
|
||||
const peakDay = trendData.reduce<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null);
|
||||
const latestDay = trendData.at(-1) ?? null;
|
||||
const displayedMonthKwh = dataIsCurrentMonth ? k.monthKwh : trendData.reduce((sum, item) => sum + item.kwh, 0);
|
||||
const displayedMonthFee = dataIsCurrentMonth ? k.monthFee : trendData.reduce((sum, item) => sum + item.fee, 0);
|
||||
const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0;
|
||||
const monthPrice = displayedMonthKwh > 0 ? displayedMonthFee / displayedMonthKwh : 0;
|
||||
const dataAsOf = data.latestChargeTime ?? '暂无充电记录';
|
||||
const monthPrice = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<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 className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
|
||||
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
||||
</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={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'} />
|
||||
<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'} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
@@ -75,8 +71,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 ? (displayedMonthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
|
||||
<div className="mt-1 text-[11px] font-bold text-slate-500">{dataIsCurrentMonth ? '本月' : '最新月'}费用 / 累计费用</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>
|
||||
</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>
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
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,304 +1,278 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { fetchHydrogenDaily, fetchHydrogenDailyDetail, type HydrogenVerifyScope } from './api';
|
||||
import type { CustomerType, DateQuickPick, HydrogenDailyDetailResponse, HydrogenDailyRow } from './types';
|
||||
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 {
|
||||
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';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
export interface HydrogenDailyProps {
|
||||
scope?: HydrogenDailyBoardScope;
|
||||
onRangeTextChange?: (rangeText: string) => void;
|
||||
}
|
||||
|
||||
interface DailyDatasets {
|
||||
lingniu: HydrogenDailyRow[] | null;
|
||||
external: HydrogenDailyRow[] | null;
|
||||
}
|
||||
|
||||
export interface DailyDetailState {
|
||||
loading: boolean;
|
||||
data: HydrogenDailyDetailResponse | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function formatUpdatedAt(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
export default function HydrogenDaily({ scope = 'global', onRangeTextChange }: HydrogenDailyProps) {
|
||||
const [vehicleScope, setVehicleScope] = useState<HydrogenDailyVehicleScope>('all');
|
||||
// Kept explicit even though this view has no verification toggle yet: drill
|
||||
// requests always declare their data scope instead of silently defaulting.
|
||||
const verifyScope: HydrogenVerifyScope = 'all';
|
||||
export default function HydrogenDaily() {
|
||||
const [pick, setPick] = useState<RangeMode>('last15');
|
||||
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [highlightedDate, setHighlightedDate] = useState<string | null>(null);
|
||||
const [datasets, setDatasets] = useState<DailyDatasets>({ lingniu: null, external: null });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
|
||||
const [details, setDetails] = useState<Record<string, DailyDetailState>>({});
|
||||
|
||||
const effectiveRange = useMemo(
|
||||
() => normalizeRange(dateRange.start, dateRange.end),
|
||||
[dateRange.start, dateRange.end],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onRangeTextChange?.(`${effectiveRange.start} 至 ${effectiveRange.end}`);
|
||||
}, [effectiveRange.start, effectiveRange.end, onRangeTextChange]);
|
||||
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const query = pick === 'custom'
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||
: { range: pick };
|
||||
|
||||
Promise.all([
|
||||
fetchHydrogenDaily(query, 'lingniu'),
|
||||
fetchHydrogenDaily(query, 'external'),
|
||||
])
|
||||
.then(([lingniu, external]) => {
|
||||
if (cancelled) return;
|
||||
setDatasets({ lingniu, external });
|
||||
setUpdatedAt(formatUpdatedAt(new Date()));
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
fetchHydrogenDaily(query, customer)
|
||||
.then(r => { if (!cancelled) setRows(r); })
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [pick, effectiveRange.start, effectiveRange.end, refreshKey]);
|
||||
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
|
||||
|
||||
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 {
|
||||
trendData,
|
||||
totalKg,
|
||||
activeDays,
|
||||
stationCount,
|
||||
avgKg,
|
||||
peakDay,
|
||||
lowDay,
|
||||
zeroDays,
|
||||
} = useMemo(() => summarizeHydrogenRows(rows), [rows]);
|
||||
const scopeLabel = getRangeModeLabel(pick);
|
||||
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||
|
||||
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);
|
||||
}
|
||||
const toggle = (date: string) => setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(date) ? next.delete(date) : next.add(date);
|
||||
return next;
|
||||
});
|
||||
|
||||
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||
setPick(nextPick);
|
||||
setDateRange(getQuickRange(nextPick));
|
||||
};
|
||||
|
||||
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' });
|
||||
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
||||
if (!value) return;
|
||||
setPick('custom');
|
||||
setDateRange(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
if (loading && !scopedRows) {
|
||||
return <LoadingState label="正在加载氢气按日看板数据..." />;
|
||||
}
|
||||
|
||||
if (error && !scopedRows) {
|
||||
return <ErrorState message={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<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));
|
||||
}}
|
||||
customer={customer}
|
||||
onQuickPick={applyQuickPick}
|
||||
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)}
|
||||
onDateRangeChange={updateDateRange}
|
||||
onCustomerChange={setCustomer}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<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 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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRefreshKey((k) => k + 1)}
|
||||
className="inline-flex items-center gap-1 font-bold text-amber-900 hover:underline"
|
||||
|
||||
{/* 外部车辆:新系统数据还没准备好 */}
|
||||
{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"
|
||||
>
|
||||
<RefreshCw size={12} /> 重试
|
||||
</button>
|
||||
<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>
|
||||
) : null}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{scope === 'station' && selectedStation ? (
|
||||
<StationDailyOverview
|
||||
station={selectedStation}
|
||||
totalKg={summary.totalKg}
|
||||
totalFee={summary.totalFee}
|
||||
averagePrice={summary.avgPrice}
|
||||
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
||||
{!(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}
|
||||
/>
|
||||
) : 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}
|
||||
<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)}`}
|
||||
/>
|
||||
|
||||
<DailyTrendChart
|
||||
rows={trend}
|
||||
averageKg={summary.avgKg}
|
||||
peakDay={summary.peakDay}
|
||||
lowDay={summary.lowDay}
|
||||
zeroDays={summary.zeroDays}
|
||||
selectedDate={highlightedDate}
|
||||
onSelectDate={handleSelectDate}
|
||||
<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)' }}
|
||||
/>
|
||||
|
||||
{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="可尝试切换统计区间、车辆范围或站点。"
|
||||
{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}`}>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden bg-slate-50/50"
|
||||
>
|
||||
{r.stations.map(s => (
|
||||
<div
|
||||
key={s.name}
|
||||
className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 pl-6 md:pl-9 border-t border-slate-100 first:border-t-0 items-start"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
|
||||
{s.name}
|
||||
</div>
|
||||
{s.pricePerKg > 0 && (
|
||||
<div className="md:hidden mt-1">
|
||||
<span className="inline-flex items-center text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded font-bold whitespace-nowrap">
|
||||
单价 {s.pricePerKg} 元/Kg
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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,9 +1,33 @@
|
||||
import { EnergyBiBoardApp } from '../../vendor/lnbi-8113-exact/prototypes/energy-h2-bi-board/EnergyBiBoardApp';
|
||||
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'];
|
||||
|
||||
/**
|
||||
* The accepted hydrogen fee BI surface. It is shared with the independent
|
||||
* acceptance route so the menu entry and /energy/hydrogen-board cannot drift.
|
||||
*/
|
||||
export default function HydrogenModule() {
|
||||
return <EnergyBiBoardApp />;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,282 +1,106 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
fetchHydrogenOverview,
|
||||
fetchHydrogenOverviewDetail,
|
||||
type HydrogenOverviewResponse,
|
||||
type HydrogenVehicleScope,
|
||||
type HydrogenVerifyScope,
|
||||
} from './api';
|
||||
import type { HydrogenBoardScope } from './HydrogenBoardChrome';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||
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';
|
||||
import { deriveOverviewMetrics, formatYuan as fmtYuan } from './hydrogen-overview/model';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
export default function HydrogenOverview() {
|
||||
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [year, setYear] = useState<number | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [lastRefreshAt, setLastRefreshAt] = useState<number>(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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);
|
||||
const refreshSeq = useRef(0);
|
||||
|
||||
const load = useCallback(async (selectedYear: number | null, force: boolean) => {
|
||||
const seq = ++refreshSeq.current;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const result = await fetchHydrogenOverview({
|
||||
year: activeYear,
|
||||
vehicleScope,
|
||||
verifyScope,
|
||||
stationId: effectiveStationId,
|
||||
force,
|
||||
});
|
||||
setData(result);
|
||||
const d = await fetchHydrogenOverview(selectedYear ?? undefined, force);
|
||||
if (seq !== refreshSeq.current) return; // outdated
|
||||
setData(d);
|
||||
setError(null);
|
||||
setLastRefreshAt(Date.now());
|
||||
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));
|
||||
} catch (e) {
|
||||
if (seq !== refreshSeq.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
if (seq === refreshSeq.current) setRefreshing(false);
|
||||
}
|
||||
}, [activeYear, currentYear, effectiveStationId, onRangeTextChange, vehicleScope, verifyScope]);
|
||||
}, []);
|
||||
|
||||
// 初始加载 + 年份切换:用 force=false 命中热缓存
|
||||
useEffect(() => { void load(year, false); }, [year, load]);
|
||||
|
||||
// 客户端兜底自动刷新:每 60s 静默拉一次(命中后端热缓存,几乎零成本)
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, [load]);
|
||||
const t = setInterval(() => { void load(year, false); }, 60_000);
|
||||
return () => clearInterval(t);
|
||||
}, [year, load]);
|
||||
|
||||
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 (error && !data) {
|
||||
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
||||
}
|
||||
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) {
|
||||
if (!data) {
|
||||
return <HydrogenOverviewSkeleton />;
|
||||
}
|
||||
|
||||
if (error && !data) {
|
||||
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
|
||||
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"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</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: '元' };
|
||||
const { kpi, top5, regions, monthly, customers, stations, availableYears, year: activeYear } = data;
|
||||
const {
|
||||
monthAvgKg,
|
||||
bestMonth,
|
||||
latestMonth,
|
||||
monthMomentum,
|
||||
top5Share,
|
||||
profitYield,
|
||||
stationAvgKg,
|
||||
monthlyDual,
|
||||
} = deriveOverviewMetrics(data);
|
||||
const yearProfitFmt = fmtYuan(kpi.yearProfit);
|
||||
const yearRevenueFmt = fmtYuan(kpi.yearRevenue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 relative">
|
||||
<OverviewHeader
|
||||
activeYear={activeYear}
|
||||
availableYears={DEFAULT_AVAILABLE_YEARS}
|
||||
vehicleScope={vehicleScope}
|
||||
verifyScope={verifyScope}
|
||||
latestLedgerTime={data?.latestLedgerTime ?? null}
|
||||
availableYears={availableYears}
|
||||
lastRefreshAt={lastRefreshAt}
|
||||
refreshing={refreshing}
|
||||
onSelectYear={(y) => setActiveYear(y)}
|
||||
onVehicleScopeChange={(vs) => setVehicleScope(vs)}
|
||||
onVerifyScopeChange={setVerifyScope}
|
||||
onRefresh={() => void load(true)}
|
||||
onSelectYear={setYear}
|
||||
onRefresh={() => void load(year, true)}
|
||||
/>
|
||||
|
||||
{data && derived ? (
|
||||
<section className="ehb-host" aria-label="经营总览">
|
||||
<KpiSection
|
||||
kpi={data.kpi}
|
||||
scope={scope}
|
||||
scopeLabel={selectedStationName}
|
||||
onDrillRequest={handleDrillRequest}
|
||||
/>
|
||||
<KpiSection kpi={kpi} />
|
||||
|
||||
<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}
|
||||
monthAvgKg={monthAvgKg}
|
||||
bestMonth={bestMonth}
|
||||
latestMonth={latestMonth}
|
||||
monthMomentum={monthMomentum}
|
||||
top5Share={top5Share}
|
||||
profitYield={profitYield}
|
||||
stationAvgKg={stationAvgKg}
|
||||
stationCount={stations.length}
|
||||
yearProfitValue={yearProfitFmt.value}
|
||||
yearProfitUnit={yearProfitFmt.unit}
|
||||
yearRevenueValue={yearRevenueFmt.value}
|
||||
yearRevenueUnit={yearRevenueFmt.unit}
|
||||
stations={data.stations}
|
||||
onSelectStation={(stId) => {
|
||||
setInternalStationId(stId);
|
||||
onSelectStation?.(stId);
|
||||
}}
|
||||
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}
|
||||
/>
|
||||
</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)}
|
||||
/>
|
||||
)}
|
||||
<MonthlyCharts activeYear={activeYear} monthly={monthly} monthlyDual={monthlyDual} />
|
||||
<DistributionCharts top5={top5} regions={regions} yearKg={kpi.yearKg} />
|
||||
<StationSummaryTable stations={stations} />
|
||||
<CustomerSummaryTable customers={customers} />
|
||||
|
||||
<RotatingFooterHint />
|
||||
<RefreshOverlay refreshing={refreshing} hasData={Boolean(data)} />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,562 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Fuel,
|
||||
RefreshCw,
|
||||
Wallet,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { fetchHydrogenStationBoard } from './api';
|
||||
import type {
|
||||
HydrogenStationBoardCustomerMonth,
|
||||
HydrogenStationBoardResponse,
|
||||
HydrogenStationBoardStation,
|
||||
HydrogenStationBoardSummaryDailyRow,
|
||||
} from './types';
|
||||
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from './components/SortableColumnHeader';
|
||||
import './styles/energy-bi-board.css';
|
||||
import './hydrogen-bi-v2/prototype-station-daily.css';
|
||||
|
||||
function formatYmd(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function defaultRange() {
|
||||
const end = new Date();
|
||||
end.setHours(0, 0, 0, 0);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 9);
|
||||
return { start: formatYmd(start), end: formatYmd(end) };
|
||||
}
|
||||
|
||||
function formatNumber(value: number, digits = 2): string {
|
||||
return value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function regionLabel(station: HydrogenStationBoardStation): string {
|
||||
const parts = [station.province, station.city].filter(part => part && part !== '未归属');
|
||||
return [...new Set(parts)].join(' · ') || '区域待补充';
|
||||
}
|
||||
|
||||
function resetPageScroll() {
|
||||
document.documentElement.scrollTop = 0;
|
||||
document.body.scrollTop = 0;
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function onlyStationsWithHydrogenRecords(result: HydrogenStationBoardResponse): HydrogenStationBoardResponse {
|
||||
const stations = result.stations.filter(station => station.recordCount > 0);
|
||||
return {
|
||||
...result,
|
||||
stations,
|
||||
summary: {
|
||||
...result.summary,
|
||||
stationCount: stations.length,
|
||||
activeStationCount: stations.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function HydrogenStationBoard({ embedded = false }: { embedded?: boolean }) {
|
||||
const [dateRange, setDateRange] = useState(defaultRange);
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
const [data, setData] = useState<HydrogenStationBoardResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (force = false) => {
|
||||
setLoading(true);
|
||||
// A new request invalidates the previous range immediately. Never leave
|
||||
// stale figures visible while the current API request is pending or failed.
|
||||
setData(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchHydrogenStationBoard({
|
||||
startDate: dateRange.start,
|
||||
endDate: dateRange.end,
|
||||
stationId: selectedStationId,
|
||||
force,
|
||||
});
|
||||
setData(onlyStationsWithHydrogenRecords(result));
|
||||
setError(null);
|
||||
} catch (reason) {
|
||||
setData(null);
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dateRange.end, dateRange.start, selectedStationId]);
|
||||
|
||||
useEffect(() => { void load(false); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
// 站点列表较长,切换列表/详情时统一从页面顶部开始展示。
|
||||
resetPageScroll();
|
||||
const frame = window.requestAnimationFrame(resetPageScroll);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [selectedStationId]);
|
||||
|
||||
const selectedStation = data?.stations.find(station => station.id === selectedStationId) ?? null;
|
||||
|
||||
if (error && !data) {
|
||||
return (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-5 text-sm text-red-700">
|
||||
<div>单站经营数据暂时不可用。</div>
|
||||
<button type="button" onClick={() => void load(true)} className="mt-3 rounded-lg bg-white px-3 py-2 text-xs font-bold shadow-sm">重新加载</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={embedded ? 'sd-embedded' : 'relative flex flex-col gap-3'}>
|
||||
{!(selectedStation && data?.selected) ? <StationBoardHeader
|
||||
embedded={embedded}
|
||||
selectedStation={selectedStation}
|
||||
range={dateRange}
|
||||
latestLedgerTime={data?.summary.latestLedgerTime ?? null}
|
||||
loading={loading}
|
||||
onBack={() => {
|
||||
setSelectedStationId(null);
|
||||
window.requestAnimationFrame(resetPageScroll);
|
||||
}}
|
||||
onRangeChange={setDateRange}
|
||||
onRefresh={() => void load(true)}
|
||||
onExport={() => data && exportStationEvidence(data, selectedStation)}
|
||||
/> : null}
|
||||
|
||||
{data ? selectedStation && data.selected ? (
|
||||
<StationDetail
|
||||
station={selectedStation}
|
||||
data={data}
|
||||
latestLedgerTime={data.summary.latestLedgerTime}
|
||||
loading={loading}
|
||||
onBack={() => setSelectedStationId(null)}
|
||||
onRangeChange={setDateRange}
|
||||
onRefresh={() => void load(true)}
|
||||
onExport={() => exportStationEvidence(data, selectedStation)}
|
||||
/>
|
||||
) : (
|
||||
<StationOverview
|
||||
data={data}
|
||||
onSelectStation={stationId => {
|
||||
setSelectedStationId(stationId);
|
||||
window.requestAnimationFrame(resetPageScroll);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<StationBoardSkeleton />
|
||||
)}
|
||||
|
||||
{loading && data ? (
|
||||
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-white/45 backdrop-blur-[1px]">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-xs font-bold text-slate-600 shadow-xl">
|
||||
<RefreshCw size={15} className="animate-spin text-blue-600" /> 正在刷新单站数据
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StationBoardHeader({
|
||||
embedded,
|
||||
selectedStation,
|
||||
range,
|
||||
latestLedgerTime,
|
||||
loading,
|
||||
onBack,
|
||||
onRangeChange,
|
||||
onRefresh,
|
||||
onExport,
|
||||
}: {
|
||||
embedded: boolean;
|
||||
selectedStation: HydrogenStationBoardStation | null;
|
||||
range: { start: string; end: string };
|
||||
latestLedgerTime: string | null;
|
||||
loading: boolean;
|
||||
onBack: () => void;
|
||||
onRangeChange: (range: { start: string; end: string }) => void;
|
||||
onRefresh: () => void;
|
||||
onExport: () => void;
|
||||
}) {
|
||||
if (embedded && !selectedStation) {
|
||||
return <header className="sd-topbar sd-topbar--embedded"><p className="sd-topbar__updated">最后更新时间 {latestLedgerTime ?? '暂无账本时间'}</p><div className="sd-topbar__tools"><StationRangePicker range={range} onChange={onRangeChange} /><button type="button" onClick={onRefresh} disabled={loading} className="sd-btn sd-btn--ghost"><RefreshCw size={14} className={loading ? 'animate-spin' : ''} /> 刷新</button></div></header>;
|
||||
}
|
||||
return (
|
||||
<header className="rounded-[12px] border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{selectedStation ? (
|
||||
<button type="button" onClick={onBack} className="inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 px-3 text-xs font-bold text-slate-600 hover:bg-slate-50">
|
||||
<ArrowLeft size={15} /> 返回
|
||||
</button>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
{selectedStation ? <h1 className="truncate text-[18px] font-black text-slate-950">{selectedStation.name}</h1> : null}
|
||||
{selectedStation ? <p className="mt-0.5 text-[11px] font-medium text-slate-500">{regionLabel(selectedStation)} · {range.start} 至 {range.end}</p> : null}
|
||||
<p className={`${selectedStation ? 'mt-1' : ''} text-[11px] font-medium text-slate-400`}>最后更新时间 {latestLedgerTime ?? '暂无账本时间'}</p>
|
||||
</div>
|
||||
<label className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 text-[11px] font-bold text-slate-600">
|
||||
<Calendar size={14} className="text-blue-600" />
|
||||
<span className="hidden sm:inline">查询日期</span>
|
||||
<input aria-label="开始日期" type="date" value={range.start} onChange={event => onRangeChange({ ...range, start: event.target.value })} className="w-[110px] bg-transparent outline-none" />
|
||||
<span>至</span>
|
||||
<input aria-label="结束日期" type="date" value={range.end} onChange={event => onRangeChange({ ...range, end: event.target.value })} className="w-[110px] bg-transparent outline-none" />
|
||||
</label>
|
||||
<button type="button" onClick={onRefresh} disabled={loading} className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-slate-200 px-3 text-xs font-bold text-slate-600 hover:bg-slate-50 disabled:opacity-60">
|
||||
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} /> 刷新
|
||||
</button>
|
||||
{selectedStation ? (
|
||||
<button type="button" onClick={onExport} className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-blue-600 px-3 text-xs font-bold text-white shadow-sm hover:bg-blue-700">
|
||||
<Download size={14} /> 导出取证
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function StationRangePicker({ range, onChange }: { range: { start: string; end: string }; onChange: (range: { start: string; end: string }) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeBoundary, setActiveBoundary] = useState<'start' | 'end'>('start');
|
||||
const [draft, setDraft] = useState(range);
|
||||
const initial = new Date(`${range.start}T00:00:00`);
|
||||
const [displayMonth, setDisplayMonth] = useState({ year: initial.getFullYear(), month: initial.getMonth() + 1 });
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const closeWhenOutside = (event: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', closeWhenOutside);
|
||||
return () => document.removeEventListener('mousedown', closeWhenOutside);
|
||||
}, []);
|
||||
const anchor = new Date(`${range.end}T00:00:00`);
|
||||
const apply = (next: { start: string; end: string }, close = false) => {
|
||||
const normalized = next.start <= next.end ? next : { start: next.end, end: next.start };
|
||||
setDraft(normalized);
|
||||
if (close) { onChange(normalized); setOpen(false); }
|
||||
};
|
||||
const today = formatYmd(anchor);
|
||||
const startOfWeek = new Date(anchor);
|
||||
startOfWeek.setDate(anchor.getDate() - ((anchor.getDay() + 6) % 7));
|
||||
const shortcuts = {
|
||||
today: { start: today, end: today },
|
||||
week: { start: formatYmd(startOfWeek), end: formatYmd(new Date(startOfWeek.getFullYear(), startOfWeek.getMonth(), startOfWeek.getDate() + 6)) },
|
||||
month: { start: `${anchor.getFullYear()}-${String(anchor.getMonth() + 1).padStart(2, '0')}-01`, end: formatYmd(new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0)) },
|
||||
};
|
||||
const daysInMonth = new Date(displayMonth.year, displayMonth.month, 0).getDate();
|
||||
const leadingDays = new Date(displayMonth.year, displayMonth.month - 1, 1).getDay();
|
||||
const selectDay = (day: number) => {
|
||||
const value = `${displayMonth.year}-${String(displayMonth.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
if (activeBoundary === 'start') {
|
||||
const next = { start: value, end: value > draft.end ? value : draft.end };
|
||||
setDraft(next); setActiveBoundary('end'); return;
|
||||
}
|
||||
const next = value < draft.start ? { start: value, end: draft.start } : { start: draft.start, end: value };
|
||||
setDraft(next); setActiveBoundary('start');
|
||||
};
|
||||
return <div className="sd-date sd-date--right" ref={rootRef}>
|
||||
<button type="button" className={`sd-date__trigger ${open ? 'is-open' : ''}`} aria-label="查询日期" aria-haspopup="dialog" aria-expanded={open} onClick={() => {
|
||||
if (!open) { setDraft(range); setActiveBoundary('start'); setDisplayMonth({ year: initial.getFullYear(), month: initial.getMonth() + 1 }); }
|
||||
setOpen(value => !value);
|
||||
}}><span className="sd-date__label">查询日期</span><span className="sd-date__value">{range.start} 至 {range.end}</span><ChevronDown size={15} aria-hidden className="sd-date__icon" /></button>
|
||||
{open ? <div className="sd-date__popover sd-date__popover--range" role="dialog" aria-label="查询日期">
|
||||
<div className="sd-date__shortcuts"><button type="button" onClick={() => apply(shortcuts.today, true)}>本日</button><button type="button" onClick={() => apply(shortcuts.week, true)}>本周</button><button type="button" onClick={() => apply(shortcuts.month, true)}>本月</button></div>
|
||||
<div className="sd-date__range-tabs"><button type="button" className={activeBoundary === 'start' ? 'is-on' : ''} onClick={() => setActiveBoundary('start')}>开始 {draft.start}</button><button type="button" className={activeBoundary === 'end' ? 'is-on' : ''} onClick={() => setActiveBoundary('end')}>结束 {draft.end}</button></div>
|
||||
<div className="sd-date__header"><button type="button" className="sd-date__nav" aria-label="上一月" onClick={() => setDisplayMonth(value => value.month === 1 ? { year: value.year - 1, month: 12 } : { ...value, month: value.month - 1 })}><ChevronLeft size={16} /></button><div className="sd-date__title">{displayMonth.year}年{String(displayMonth.month).padStart(2, '0')}月</div><button type="button" className="sd-date__nav" aria-label="下一月" onClick={() => setDisplayMonth(value => value.month === 12 ? { year: value.year + 1, month: 1 } : { ...value, month: value.month + 1 })}><ChevronRight size={16} /></button></div>
|
||||
<div className="sd-date__week">{['日','一','二','三','四','五','六'].map(day => <span key={day}>{day}</span>)}</div><div className="sd-date__grid">{Array.from({ length: leadingDays }, (_, index) => <span key={`empty-${index}`} className="sd-date__day is-empty" />)}{Array.from({ length: daysInMonth }, (_, index) => { const day = index + 1; const value = `${displayMonth.year}-${String(displayMonth.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const classes = ['sd-date__day', value === draft.start || value === draft.end ? 'is-selected' : '', value >= draft.start && value <= draft.end ? 'is-in-range' : ''].filter(Boolean).join(' '); return <button key={day} type="button" className={classes} onClick={() => selectDay(day)}>{day}</button>; })}</div>
|
||||
<div className="sd-date__footer sd-date__footer--range"><button type="button" className="sd-date__today" onClick={() => apply(shortcuts.today, true)}>本日</button><button type="button" className="sd-btn sd-btn--primary sd-date__apply" onClick={() => apply(draft, true)}>确定</button></div>
|
||||
</div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function StationOverview({ data, onSelectStation }: { data: HydrogenStationBoardResponse; onSelectStation: (id: number) => void }) {
|
||||
const [displayMode, setDisplayMode] = useState<'all' | 'single'>('all');
|
||||
const [displayStationId, setDisplayStationId] = useState(data.stations[0]?.id ?? 0);
|
||||
const [dailyDrill, setDailyDrill] = useState<'kg' | 'fee' | 'payment' | null>(null);
|
||||
const [partnersExpanded, setPartnersExpanded] = useState(false);
|
||||
const { selfManagedStations, partnerStations } = useMemo(() => {
|
||||
const selfManaged = data.stations
|
||||
.filter(isSelfManagedStation)
|
||||
.sort((left, right) => selfManagedStationRank(left) - selfManagedStationRank(right));
|
||||
const selfManagedIds = new Set(selfManaged.map(station => station.id));
|
||||
return {
|
||||
selfManagedStations: selfManaged,
|
||||
partnerStations: data.stations
|
||||
.filter(station => station.kg > 0 && !selfManagedIds.has(station.id))
|
||||
.sort((left, right) => right.kg - left.kg || left.name.localeCompare(right.name, 'zh-CN')),
|
||||
};
|
||||
}, [data.stations]);
|
||||
const visiblePartnerStations = partnersExpanded ? partnerStations : partnerStations.slice(0, 10);
|
||||
const selectedStation = data.stations.filter(station => station.id === displayStationId);
|
||||
// Top5 的条形、图例与钻取入口必须共用同一份按加氢量降序的结果。
|
||||
const shareStations = data.stations
|
||||
.filter(station => station.kg > 0)
|
||||
.sort((left, right) => right.kg - left.kg)
|
||||
.slice(0, 5);
|
||||
const shareTrackTotal = shareStations.reduce((sum, station) => sum + station.kg, 0);
|
||||
return (
|
||||
<>
|
||||
<section aria-label="全站核心指标" className="sd-hero-kpis">
|
||||
<div className="sd-hero-kpi"><span className="sd-hero-kpi__label">加氢站</span><strong className="sd-hero-kpi__value">{data.summary.stationCount}<span>站</span></strong><span className="sd-hero-kpi__sub">统计站点数 · 活跃 {data.summary.activeStationCount} 站</span></div>
|
||||
<StationSummaryMetric label="统计加氢总量" value={formatNumber(data.summary.totalKg)} unit="Kg" helper={`${data.summary.recordCount} 车次 · 点按查看日明细`} onClick={() => setDailyDrill('kg')} />
|
||||
<StationSummaryMetric label="统计加氢量" value={formatNumber(data.summary.totalKg)} unit="Kg" helper={`¥${formatNumber(data.summary.totalFee)} · ${data.summary.daily.length} 天`} onClick={() => setDailyDrill('fee')} />
|
||||
<StationSummaryMetric label="统计现结金额" value={formatNumber(data.summary.paymentAmount)} unit="元" helper={`${data.summary.daily.filter(item => item.paymentAmount > 0).length} 天有进账`} onClick={() => setDailyDrill('payment')} />
|
||||
</section>
|
||||
<section className="sd-share-panel" aria-label="加氢量占比Top5">
|
||||
<div className="sd-share-panel__head"><h2 className="sd-share-panel__title">加氢量占比Top5 <span className="sd-share-panel__range">{data.range.start} 至 {data.range.end}</span></h2><span className="sd-share-panel__total">合计 <strong>{formatNumber(data.summary.totalKg)} Kg</strong></span></div>
|
||||
<div className="sd-share-track">{shareStations.map((station, index) => <button key={station.id} type="button" className={`sd-share-seg sd-share-seg--${index}`} style={{ width: `${shareTrackTotal > 0 ? (station.kg / shareTrackTotal) * 100 : 0}%` }} onClick={() => onSelectStation(station.id)}><span className="sd-share-seg__pct">{(station.share * 100).toFixed(1)}%</span><span className="sd-share-seg__name">{station.name}</span></button>)}</div>
|
||||
<ul className="sd-share-legend">{shareStations.map((station, index) => <li key={station.id}><button type="button" className="sd-share-legend__btn" onClick={() => onSelectStation(station.id)}><span className={`sd-share-legend__swatch sd-share-seg--${index}`} /><span className="sd-share-legend__rank" aria-label={`第 ${index + 1} 名`}>{index + 1}</span><span className="sd-share-legend__name">{station.name}</span><span className="sd-share-legend__val">{formatNumber(station.kg)} Kg · {(station.share * 100).toFixed(1)}%</span></button></li>)}</ul>
|
||||
</section>
|
||||
|
||||
<section aria-label="各站经营概况">
|
||||
<div className="sd-section-head"><h2 className="sd-section-title">各站概况</h2><div className="sd-board-mode" role="tablist" aria-label="各站展示方式"><button type="button" role="tab" aria-selected={displayMode === 'all'} onClick={() => setDisplayMode('all')} className={displayMode === 'all' ? 'is-on' : ''}>全部</button><button type="button" role="tab" aria-selected={displayMode === 'single'} onClick={() => setDisplayMode('single')} className={displayMode === 'single' ? 'is-on' : ''}>单站</button>{displayMode === 'single' ? <select aria-label="选择站点" value={displayStationId} onChange={event => setDisplayStationId(Number(event.target.value))} className="sd-board-station-select">{data.stations.map(station => <option key={station.id} value={station.id}>{station.name}</option>)}</select> : null}</div></div>
|
||||
{displayMode === 'all' ? <div className="sd-station-groups">
|
||||
<StationGroup title="自营站" count={selfManagedStations.length} stations={selfManagedStations} onSelectStation={onSelectStation} emptyText="自营站数据待接入" />
|
||||
<StationGroup title="合作站" count={partnerStations.length} stations={visiblePartnerStations} onSelectStation={onSelectStation} emptyText="本区间暂无合作站加氢量" />
|
||||
{partnerStations.length > 10 ? <button type="button" className="sd-more-btn" onClick={() => setPartnersExpanded(value => !value)}>{partnersExpanded ? '收起合作站' : `更多合作站(还有 ${partnerStations.length - 10} 站)`} <ChevronDown size={14} aria-hidden className={partnersExpanded ? 'rotate-180' : ''} /></button> : null}
|
||||
</div> : <div className="sd-station-single-card"><StationRows stations={selectedStation} onSelectStation={onSelectStation} /></div>}
|
||||
</section>
|
||||
<StationSummaryDailyDialog
|
||||
mode={dailyDrill}
|
||||
range={data.range}
|
||||
rows={data.summary.daily}
|
||||
onClose={() => setDailyDrill(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function isSelfManagedStation(station: HydrogenStationBoardStation): boolean {
|
||||
return station.name.includes('佛山南海羚牛') || station.name.includes('东鹏大道');
|
||||
}
|
||||
|
||||
function selfManagedStationRank(station: HydrogenStationBoardStation): number {
|
||||
return station.name.includes('佛山南海羚牛') ? 0 : 1;
|
||||
}
|
||||
|
||||
function StationGroup({ title, count, stations, onSelectStation, emptyText }: {
|
||||
title: string;
|
||||
count: number;
|
||||
stations: HydrogenStationBoardStation[];
|
||||
onSelectStation: (id: number) => void;
|
||||
emptyText: string;
|
||||
}) {
|
||||
return <section className="sd-station-group" aria-label={title}>
|
||||
<div className="sd-station-group__head"><h3>{title}</h3><span>{count} 站</span></div>
|
||||
{stations.length ? <div className="sd-station-single-card"><StationRows stations={stations} onSelectStation={onSelectStation} /></div> : <p className="sd-station-group__empty">{emptyText}</p>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function StationRows({ stations, onSelectStation }: { stations: HydrogenStationBoardStation[]; onSelectStation: (id: number) => void }) {
|
||||
return <>{stations.map(station => <button key={station.id} type="button" onClick={() => onSelectStation(station.id)} className="sd-station-row"><div className="sd-station-row__main"><span className="sd-station-card__icon"><Fuel size={17} /></span><div><div className="sd-station-card__name">{station.name}</div><div className="sd-station-card__region">{regionLabel(station)}</div></div>{station.kg > 0 ? <MiniSpark values={station.dailyKg.map(item => item.kg)} /> : <div className="sd-spark sd-spark--empty" />}<div className="sd-station-row__metrics"><StationRowMetric label="区间加氢" value={formatNumber(station.kg)} unit="Kg" /><StationRowMetric label="区间金额" value={formatNumber(station.fee)} unit="元" /><StationRowMetric label="现结进账" value={formatNumber(station.paymentAmount)} unit="元" /><div><span className="sd-station-card__m-label">加氢量占比</span><span className="sd-station-card__share">{(station.share * 100).toFixed(1)}%</span></div></div><span className="sd-station-card__go"><ChevronRight size={17} /></span></div></button>)}</>;
|
||||
}
|
||||
|
||||
function StationSummaryMetric({ label, value, unit, helper, onClick }: { label: string; value: string; unit?: string; helper: string; onClick: () => void }) {
|
||||
return <button type="button" onClick={onClick} className="sd-hero-kpi sd-hero-kpi--click"><span className="sd-hero-kpi__label">{label}</span><strong className="sd-hero-kpi__value">{value}{unit ? <span>{unit}</span> : null}</strong><span className="sd-hero-kpi__sub">{helper}</span></button>;
|
||||
}
|
||||
|
||||
function StationSummaryDailyDialog({
|
||||
mode,
|
||||
range,
|
||||
rows,
|
||||
onClose,
|
||||
}: {
|
||||
mode: 'kg' | 'fee' | 'payment' | null;
|
||||
range: { start: string; end: string };
|
||||
rows: HydrogenStationBoardSummaryDailyRow[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [sortKey, setSortKey] = useState<'date' | 'recordCount' | 'kg' | 'fee' | 'paymentCount' | 'paymentAmount'>('date');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const sortedRows = useMemo(() => sortBy(rows, sortKey, sortDirection, (row, key) => row[key]), [rows, sortDirection, sortKey]);
|
||||
const changeSort = (nextKey: typeof sortKey) => {
|
||||
const next = toggleSort(sortKey, sortDirection, nextKey);
|
||||
setSortKey(next.key);
|
||||
setSortDirection(next.direction);
|
||||
};
|
||||
if (!mode) return null;
|
||||
const title = mode === 'kg'
|
||||
? '统计加氢总量 · 日明细'
|
||||
: mode === 'fee'
|
||||
? '统计加氢金额 · 单日数据'
|
||||
: '统计现结金额 · 单日数据';
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-slate-950/60 p-3 backdrop-blur-[4px]" role="presentation" onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
|
||||
<section className="flex max-h-[82vh] w-full max-w-[720px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl" role="dialog" aria-modal="true" aria-labelledby="station-summary-drill-title">
|
||||
<header className="flex items-center justify-between gap-3 border-b border-slate-100 px-4 py-3">
|
||||
<div>
|
||||
<h3 id="station-summary-drill-title" className="text-[15px] font-black text-slate-900">{title}</h3>
|
||||
<p className="mt-0.5 text-[11px] font-medium text-slate-400">{range.start} 至 {range.end}</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200" aria-label="关闭"><X size={16} /></button>
|
||||
</header>
|
||||
<div className="overflow-auto">
|
||||
{mode === 'payment' ? (
|
||||
<table className="w-full min-w-[420px] text-[12px]">
|
||||
<thead className="sticky top-0 bg-slate-50 text-slate-500"><tr><th className="px-4 py-2.5 text-left"><SortableColumnHeader label="日期" sortKey="date" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="现结笔数" sortKey="paymentCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="现结金额(元)" sortKey="paymentAmount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th></tr></thead>
|
||||
<tbody>{sortedRows.map(row => <tr key={row.date} className="border-t border-slate-100"><td className="px-4 py-2.5 font-mono font-semibold text-slate-700">{row.date}</td><td className="px-4 py-2.5 text-right tabular-nums text-slate-600">{row.paymentCount}</td><td className="px-4 py-2.5 text-right font-semibold tabular-nums text-slate-800">{formatNumber(row.paymentAmount)}</td></tr>)}</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<table className="w-full min-w-[620px] text-[12px]">
|
||||
<thead className="sticky top-0 bg-slate-50 text-slate-500"><tr><th className="px-4 py-2.5 text-left"><SortableColumnHeader label="日期" sortKey="date" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="加氢车次" sortKey="recordCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="加氢量(Kg)" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="加氢金额(元)" sortKey="fee" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th></tr></thead>
|
||||
<tbody>{sortedRows.map(row => <tr key={row.date} className="border-t border-slate-100"><td className="px-4 py-2.5 font-mono font-semibold text-slate-700">{row.date}</td><td className="px-4 py-2.5 text-right tabular-nums text-slate-600">{row.recordCount}</td><td className="px-4 py-2.5 text-right font-semibold tabular-nums text-slate-800">{formatNumber(row.kg)}</td><td className="px-4 py-2.5 text-right font-semibold tabular-nums text-slate-800">{formatNumber(row.fee)}</td></tr>)}</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StationRowMetric({ label, value, unit }: { label: string; value: string; unit: string }) {
|
||||
return <div><span className="sd-station-card__m-label">{label}</span><strong>{value}<span>{unit}</span></strong></div>;
|
||||
}
|
||||
|
||||
function MiniSpark({ values }: { values: number[] }) {
|
||||
const max = Math.max(...values, 0);
|
||||
return <div className="sd-spark" aria-label="站点每日加氢趋势">{values.map((value, index) => <span key={index} className="sd-spark__col"><span className="sd-spark__bar" style={{ height: `${max > 0 ? Math.max(6, Math.round((value / max) * 100)) : 0}%` }} /></span>)}</div>;
|
||||
}
|
||||
|
||||
function StationDetail({
|
||||
station,
|
||||
data,
|
||||
latestLedgerTime,
|
||||
loading,
|
||||
onBack,
|
||||
onRangeChange,
|
||||
onRefresh,
|
||||
onExport,
|
||||
}: {
|
||||
station: HydrogenStationBoardStation;
|
||||
data: HydrogenStationBoardResponse;
|
||||
latestLedgerTime: string | null;
|
||||
loading: boolean;
|
||||
onBack: () => void;
|
||||
onRangeChange: (range: { start: string; end: string }) => void;
|
||||
onRefresh: () => void;
|
||||
onExport: () => void;
|
||||
}) {
|
||||
const detail = data.selected!;
|
||||
const [hoveredDate, setHoveredDate] = useState<string | null>(null);
|
||||
const [dailySummaryExpanded, setDailySummaryExpanded] = useState(false);
|
||||
const lastDaily = detail.daily.at(-1);
|
||||
const visibleDailySummary = dailySummaryExpanded
|
||||
? detail.daily
|
||||
: detail.daily.slice(-30);
|
||||
const monthKey = data.range.end.slice(0, 7);
|
||||
const monthRows = detail.customerMonths.filter(item => item.month === monthKey);
|
||||
const monthKg = monthRows.reduce((sum, row) => sum + row.kg, 0);
|
||||
const intervalPayments = detail.daily.reduce((sum, row) => sum + row.paymentAmount, 0);
|
||||
const trendMax = Math.max(...detail.daily.map(row => row.kg), 0);
|
||||
const hovered = detail.daily.find(row => row.date === hoveredDate) ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="sd-detail-top">
|
||||
<div className="sd-detail-top__lead"><button type="button" className="sd-btn sd-btn--ghost" onClick={onBack}><ArrowLeft size={16} /> 返回</button><div><h1 className="sd-detail-top__title">{station.name}</h1><p className="sd-detail-top__meta">{regionLabel(station)} · {data.range.start} 至 {data.range.end}</p><p className="sd-detail-top__updated">最后更新时间 {latestLedgerTime ?? '暂无账本时间'}</p></div></div>
|
||||
<div className="sd-detail-top__tools"><StationRangePicker range={data.range} onChange={onRangeChange} /><button type="button" className="sd-btn sd-btn--ghost" onClick={onRefresh} disabled={loading}><RefreshCw size={14} className={loading ? 'animate-spin' : ''} /> 刷新</button><button type="button" className="sd-btn sd-btn--primary" onClick={onExport}><Download size={14} /> 导出取证</button></div>
|
||||
</section>
|
||||
<section className="sd-hero-kpis sd-hero-kpis--detail">
|
||||
<DetailMetric label="当日加氢" value={formatNumber(lastDaily?.kg ?? 0)} unit="Kg" helper={`¥${formatNumber(lastDaily?.fee ?? 0)} · ${lastDaily?.recordCount ?? 0}车次`} />
|
||||
<DetailMetric label={`近 ${detail.daily.length} 日加氢`} value={formatNumber(station.kg)} unit="Kg" helper={`¥${formatNumber(station.fee)}`} />
|
||||
<DetailMetric label="本月加氢" value={formatNumber(monthKg)} unit="Kg" helper={monthKey} />
|
||||
<DetailMetric label={`近 ${detail.daily.length} 日现结`} value={formatNumber(intervalPayments)} unit="元" helper={`${detail.daily.filter(row => row.paymentAmount > 0).length}天有进账`} />
|
||||
</section>
|
||||
<section className="sd-panel sd-panel--block">
|
||||
<h2 className="sd-panel__title">加氢站每日加氢量汇总({data.range.start} 至 {data.range.end})</h2>
|
||||
<div className="sd-table-scroll"><table className="sd-bi-table sd-bi-table--fill"><thead><tr><th>日期 ↓</th><th className="is-num">加氢量(Kg) ↕</th><th className="is-num">较昨日 ↕</th><th className="is-num">单价 ↕</th><th className="is-num">成本金额(元) ↕</th><th className="is-num">车次 ↕</th></tr></thead><tbody>
|
||||
<tr className="is-total"><td>查询区间合计</td><td className="is-num">{formatNumber(detail.daily.reduce((sum, row) => sum + row.kg, 0))}</td><td className="is-num">—</td><td className="is-num">—</td><td className="is-num">{formatNumber(detail.daily.reduce((sum, row) => sum + row.fee, 0))}</td><td className="is-num">{detail.daily.reduce((sum, row) => sum + row.recordCount, 0)}</td></tr>
|
||||
{[...visibleDailySummary].reverse().map(row => <tr key={row.date}><td className="is-mono">{row.date}</td><td className="is-num">{formatNumber(row.kg)}</td><td className={`is-num ${row.changeKg > 0 ? 'is-stock-up' : row.changeKg < 0 ? 'is-stock-down' : 'is-stock-flat'}`}>{row.changeKg > 0 ? '+' : ''}{formatNumber(row.changeKg)}{row.changeKg !== 0 ? <span className="sd-delta">{row.changeKg > 0 ? '▲' : '▼'}</span> : null}</td><td className="is-num">{formatNumber(row.avgPrice)}</td><td className="is-num">{formatNumber(row.fee)}</td><td className="is-num">{row.recordCount}</td></tr>)}
|
||||
{detail.daily.length > 30 ? <tr><td colSpan={6} className="sd-table-more"><button type="button" onClick={() => setDailySummaryExpanded(value => !value)}>{dailySummaryExpanded ? '收起日期明细' : `更多(还有 ${detail.daily.length - 30} 天)`}</button></td></tr> : null}
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
<section className="sd-panel sd-panel--block"><div className="sd-panel__head-row"><h2 className="sd-panel__title">区间加氢量趋势</h2><span className="sd-trend-legend-chip"><span className="sd-trend-legend-dot" />{station.name}</span></div><div className="sd-trend">{hovered ? <div className="sd-trend-tip"><div className="sd-trend-tip__date">{hovered.date}</div><div className="sd-trend-tip__row"><span className="sd-trend-tip__name">加氢量</span><strong>{formatNumber(hovered.kg)} Kg</strong></div><div className="sd-trend-tip__sub">{hovered.recordCount}车次 · ¥{formatNumber(hovered.fee)}</div></div> : null}<div className="sd-trend--fill">{detail.daily.map(row => <div key={row.date} className={`sd-trend__col ${hoveredDate === row.date ? 'is-hover' : ''}`} onMouseEnter={() => setHoveredDate(row.date)} onMouseLeave={() => setHoveredDate(null)}><span className="sd-trend__val">{formatNumber(row.kg, 0)}</span><div className="sd-trend__bar-wrap"><span className="sd-trend__bar" style={{ height: `${trendMax > 0 ? Math.max(3, Math.round((row.kg / trendMax) * 100)) : 0}%` }} /></div><span className="sd-trend__date">{row.date.slice(5)}</span></div>)}</div></div></section>
|
||||
|
||||
<CustomerMonthMatrix title="客户月加氢量汇总(Kg)" valueKey="kg" rows={detail.customerMonths} />
|
||||
<CustomerMonthMatrix title="客户月加氢费汇总(元)" valueKey="fee" rows={detail.customerMonths} />
|
||||
<StationCashPanels />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailMetric({ label, value, unit, helper }: { label: string; value: string; unit: string; helper: string }) {
|
||||
return <div className="sd-hero-kpi sd-hero-kpi--accent"><span className="sd-hero-kpi__label">{label}</span><strong className="sd-hero-kpi__value">{value}<span className="sd-unit">{unit}</span></strong><span className="sd-hero-kpi__sub">{helper}</span></div>;
|
||||
}
|
||||
|
||||
function CustomerMonthMatrix({ title, valueKey, rows }: { title: string; valueKey: 'kg' | 'fee'; rows: HydrogenStationBoardCustomerMonth[] }) {
|
||||
const months = useMemo(() => [...new Set(rows.map(row => row.month))].sort(), [rows]);
|
||||
const customers = useMemo(() => [...new Set(rows.map(row => row.customerName))].sort((left, right) => left.localeCompare(right, 'zh-CN')), [rows]);
|
||||
const [selectedCustomers, setSelectedCustomers] = useState<string[]>([]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const values = useMemo(() => new Map(rows.map(row => [`${row.customerName}\u0000${row.month}`, row[valueKey]])), [rows, valueKey]);
|
||||
const totals = useMemo(() => new Map(months.map(month => [month, rows.filter(row => row.month === month).reduce((sum,row)=>sum+row[valueKey],0)])), [months, rows, valueKey]);
|
||||
const visibleCustomers = selectedCustomers.length === 0 ? customers : customers.filter(customer => selectedCustomers.includes(customer));
|
||||
const shownCustomers = expanded ? visibleCustomers : visibleCustomers.slice(0, 10);
|
||||
const trendClass = (current: number, previous: number | undefined) => previous == null || current === previous ? '' : current > previous ? 'is-stock-up' : 'is-stock-down';
|
||||
const trendMark = (current: number, previous: number | undefined) => previous == null || current === previous ? '' : current > previous ? '▲' : '▼';
|
||||
return (
|
||||
<section className="sd-panel sd-panel--block">
|
||||
<div className="sd-panel__head-row"><h2 className="sd-panel__title">{title}</h2><CustomerMultiSelect options={customers} value={selectedCustomers} onChange={setSelectedCustomers} /></div>
|
||||
<div className="sd-table-scroll sd-table-scroll--matrix"><table className={`sd-bi-table sd-bi-table--matrix ${valueKey === 'fee' ? 'is-amount' : ''}`}><thead><tr><th>客户</th>{months.map(month => <th key={month} className="is-num">{month.replace('-', '年')}月</th>)}</tr></thead><tbody>
|
||||
<tr className="is-total"><td>合计</td>{months.map((month, index) => { const current = totals.get(month) ?? 0; const previous = index > 0 ? totals.get(months[index - 1]) : undefined; return <td key={month} className={`is-num ${trendClass(current, previous)}`}>{formatNumber(current)}{trendMark(current, previous)}</td>; })}</tr>
|
||||
{shownCustomers.map(customer => <tr key={customer}><td title={customer}>{customer}</td>{months.map((month, index) => { const current = values.get(`${customer}\u0000${month}`) ?? 0; const previous = index > 0 ? values.get(`${customer}\u0000${months[index - 1]}`) : undefined; return <td key={month} className={`is-num ${trendClass(current, previous)}`}>{formatNumber(current)}{trendMark(current, previous)}</td>; })}</tr>)}
|
||||
</tbody></table></div>{visibleCustomers.length > 10 ? <button type="button" className="sd-more-btn" onClick={() => setExpanded(value => !value)}>{expanded ? '收起' : `更多(还有 ${visibleCustomers.length - 10} 条)`} <ChevronDown size={14} aria-hidden className={expanded ? 'rotate-180' : ''} /></button> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StationCashPanels() {
|
||||
return <section className="sd-dual sd-dual--cash">
|
||||
<section className="sd-panel sd-panel--grow">
|
||||
<div className="sd-panel__head-row"><h2 className="sd-panel__title">客户氢费收支汇总</h2></div>
|
||||
<div className="sd-table-scroll sd-table-scroll--cash-lines"><table className="sd-bi-table sd-bi-table--ledger"><thead><tr><th>客户</th><th className="is-num">充值/现金结算</th><th className="is-num">扣预付</th><th className="is-num">现结</th><th className="is-num">余额</th><th>备注</th></tr></thead><tbody><tr className="sd-pending-row"><td colSpan={6}>数据写入中,敬请期待。</td></tr></tbody></table></div>
|
||||
</section>
|
||||
<section className="sd-panel sd-panel--grow">
|
||||
<div className="sd-panel__head-row"><h2 className="sd-panel__title">氢费充值/现结进账明细</h2><span className="sd-panel__meta">数据写入中</span></div>
|
||||
<div className="sd-table-scroll sd-table-scroll--cash-lines"><table className="sd-bi-table sd-bi-table--ledger"><thead><tr><th>充值日期</th><th>客户</th><th>付款方式</th><th className="is-num">金额(元)</th></tr></thead><tbody><tr className="sd-pending-row"><td colSpan={4}>数据写入中,敬请期待。</td></tr></tbody></table></div>
|
||||
</section>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function CustomerMultiSelect({ options, value, onChange }: { options: string[]; value: string[]; onChange: (value: string[]) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const allSelected = value.length === 0 || value.length === options.length;
|
||||
const selectedLabel = allSelected ? '全部客户' : value.length <= 2 ? value.join('、') : `已选 ${value.length} 家`;
|
||||
const visibleOptions = search.trim() ? options.filter(option => option.includes(search.trim())) : options;
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (event: MouseEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); };
|
||||
document.addEventListener('mousedown', close);
|
||||
return () => document.removeEventListener('mousedown', close);
|
||||
}, [open]);
|
||||
const toggle = (customer: string) => {
|
||||
if (allSelected) { onChange([customer]); return; }
|
||||
if (value.includes(customer)) { const next = value.filter(item => item !== customer); onChange(next.length === 0 ? [] : next); return; }
|
||||
const next = [...value, customer];
|
||||
onChange(next.length === options.length ? [] : next);
|
||||
};
|
||||
return <div className={`sd-msel ${open ? 'is-open' : ''}`} ref={rootRef}><button type="button" className="sd-msel__trigger" aria-haspopup="listbox" aria-expanded={open} onClick={() => setOpen(current => !current)}><span className="sd-msel__label">客户</span><span className="sd-msel__value" title={selectedLabel}>{selectedLabel}</span><ChevronDown size={14} aria-hidden className="sd-msel__chev" /></button>{open ? <div className="sd-msel__panel" role="listbox" aria-multiselectable="true"><div className="sd-msel__search"><input type="search" value={search} onChange={event => setSearch(event.target.value)} placeholder="搜索客户" aria-label="搜索客户" /></div><div className="sd-msel__actions"><button type="button" onClick={() => onChange([])}>全选</button><button type="button" onClick={() => { onChange([]); setOpen(false); }}>重置</button></div><ul className="sd-msel__list">{visibleOptions.length === 0 ? <li className="sd-msel__empty">无匹配客户</li> : visibleOptions.map(option => { const on = allSelected || value.includes(option); return <li key={option}><button type="button" className={`sd-msel__opt ${on ? 'is-on' : ''}`} role="option" aria-selected={on} onClick={() => toggle(option)}><span className="sd-msel__check" aria-hidden>{on ? <Check size={12} /> : null}</span><span className="sd-msel__name">{option}</span></button></li>; })}</ul></div> : null}</div>;
|
||||
}
|
||||
|
||||
function exportStationEvidence(data: HydrogenStationBoardResponse, station: HydrogenStationBoardStation | null) {
|
||||
if (!station || !data.selected) return;
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const daily = [['日期','加氢量(Kg)','较昨日(Kg)','平均单价','加氢金额(元)','车次','现结金额(元)','现结笔数'], ...data.selected.daily.map(row => [row.date,row.kg,row.changeKg,row.avgPrice,row.fee,row.recordCount,row.paymentAmount,row.paymentCount])];
|
||||
const customers = [['月份','客户','加氢量(Kg)','加氢费(元)','车次'], ...data.selected.customerMonths.map(row => [row.month,row.customerName,row.kg,row.fee,row.recordCount])];
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(daily), '每日汇总');
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(customers), '客户月汇总');
|
||||
XLSX.writeFile(workbook, `${station.name}_${data.range.start}_${data.range.end}_取证.xlsx`);
|
||||
}
|
||||
|
||||
function StationBoardSkeleton() {
|
||||
return <div className="grid gap-3"><div className="h-28 animate-pulse rounded-xl bg-slate-200/70" /><div className="h-72 animate-pulse rounded-xl bg-slate-200/70" /></div>;
|
||||
}
|
||||
@@ -1,34 +1,12 @@
|
||||
import HydrogenOverview from './HydrogenOverview';
|
||||
import HydrogenDaily from './HydrogenDaily';
|
||||
import HydrogenSettlement from './HydrogenSettlement';
|
||||
import type { HydrogenBoardScope } from './HydrogenBoardChrome';
|
||||
|
||||
export type HydrogenSubTab = 'daily' | 'overview' | 'settlement';
|
||||
export type HydrogenSubTab = 'daily' | 'overview';
|
||||
|
||||
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,
|
||||
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} />;
|
||||
export default function HydrogenView({ sub }: Props) {
|
||||
return sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily />;
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
+3
-146
@@ -4,10 +4,6 @@ import type {
|
||||
HydrogenCustomerRow, HydrogenStationFull,
|
||||
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
|
||||
CustomerType, DateQuickPick,
|
||||
HydrogenStationBoardResponse,
|
||||
HydrogenDailyDetailResponse,
|
||||
HydrogenOverviewDetailGroupBy,
|
||||
HydrogenOverviewDetailResponse,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api/energy';
|
||||
@@ -21,74 +17,20 @@ export interface HydrogenOverviewResponse {
|
||||
stations: HydrogenStationFull[];
|
||||
availableYears: number[];
|
||||
year: number;
|
||||
latestLedgerTime: string | null;
|
||||
filter: {
|
||||
stationId: number | null;
|
||||
vehicleScope: HydrogenVehicleScope;
|
||||
verifyScope: HydrogenVerifyScope;
|
||||
};
|
||||
}
|
||||
|
||||
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> {
|
||||
export function fetchHydrogenOverview(year?: number, force = false): Promise<HydrogenOverviewResponse> {
|
||||
const params = new URLSearchParams();
|
||||
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');
|
||||
if (year) params.set('year', String(year));
|
||||
if (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[]> {
|
||||
@@ -96,40 +38,12 @@ 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> {
|
||||
@@ -143,60 +57,3 @@ 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}` : ''}`);
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -1,172 +1,92 @@
|
||||
import { Calendar, Fuel, RefreshCw, Truck } from 'lucide-react';
|
||||
import { Truck } from 'lucide-react';
|
||||
import { SurfaceCard } from '../../../components/ui/surface';
|
||||
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 => (
|
||||
<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={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'
|
||||
key={opt.id}
|
||||
onClick={() => onQuickPick(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'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCustomPick}
|
||||
className={`h-7 rounded px-3 text-[12px] font-medium transition-colors ${
|
||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||
pick === 'custom'
|
||||
? 'bg-white font-semibold text-blue-600 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-800'
|
||||
? '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>
|
||||
|
||||
<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>
|
||||
<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={event => onDateRangeChange('start', event.target.value)}
|
||||
onInput={event => onDateRangeChange('start', event.currentTarget.value)}
|
||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||
/>
|
||||
</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={event => onDateRangeChange('end', event.target.value)}
|
||||
onInput={event => onDateRangeChange('end', event.currentTarget.value)}
|
||||
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
|
||||
/>
|
||||
</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">
|
||||
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||
{(['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'
|
||||
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
|
||||
customer === option ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Truck size={13} />
|
||||
<Truck size={14} />
|
||||
{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>
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Calendar, ChevronRight, Fuel, TrendingUp, Truck, Wallet, X, Zap } from "lucide-react";
|
||||
import { fetchH2BiDaily, fetchH2BiDrill, fetchH2BiMeta, fetchH2BiOverview } from "./api";
|
||||
import { downloadExcelAoa } from "./prototype-download";
|
||||
import { finiteNumber, formatNumber as number, formatScaled } from "./display-format";
|
||||
import type { H2BiDailyResponse, H2BiDrillResponse, H2BiMetaResponse, H2BiOverviewResponse, H2BiQuery, H2BiVehicleScope, H2BiVerifyScope } from "./types";
|
||||
import "./energy-operations-board.css";
|
||||
|
||||
const tons = (kg: unknown) => formatScaled(kg, 1000);
|
||||
const wan = (yuan: unknown) => formatScaled(yuan, 10000);
|
||||
const stamp = (value: string | null) => value ? value.replace("T", " ").slice(0, 19) : "—";
|
||||
|
||||
export const monthlyChange = (monthly: H2BiOverviewResponse["monthly"]) => {
|
||||
const valid = monthly
|
||||
.filter((item) => finiteNumber(item.totalKg) !== null)
|
||||
.sort((a, b) => a.month.localeCompare(b.month))
|
||||
.slice(-2);
|
||||
const previous = finiteNumber(valid[0]?.totalKg);
|
||||
const current = finiteNumber(valid[1]?.totalKg);
|
||||
if (valid.length < 2 || previous === null || current === null || previous === 0) return { value: "—", detail: "暂不可用" };
|
||||
const change = (current - previous) / previous * 100;
|
||||
return {
|
||||
value: `${change > 0 ? "+" : ""}${number(change, 1)}%`,
|
||||
detail: `${Number(valid[1].month.slice(-2))}月较${Number(valid[0].month.slice(-2))}月`,
|
||||
};
|
||||
};
|
||||
|
||||
type View = "overview" | "daily";
|
||||
type Scope = "global" | "station";
|
||||
type DrillLevel = "station" | "customer" | "vehicle" | "record";
|
||||
type Drill = { title: string; level: DrillLevel; stationId?: string; stationName?: string; customerId?: number; customerName?: string; plateNo?: string } | null;
|
||||
|
||||
export default function EnergyOperationsBoard() {
|
||||
const [meta, setMeta] = useState<H2BiMetaResponse | null>(null);
|
||||
const [overview, setOverview] = useState<H2BiOverviewResponse | null>(null);
|
||||
const [daily, setDaily] = useState<H2BiDailyResponse | null>(null);
|
||||
const [scope, setScope] = useState<Scope>("global");
|
||||
const [view, setView] = useState<View>("overview");
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [stationId, setStationId] = useState<string>("");
|
||||
const [vehicleScope, setVehicleScope] = useState<H2BiVehicleScope>("all");
|
||||
const [verifyScope, setVerifyScope] = useState<H2BiVerifyScope>("all");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [drill, setDrill] = useState<Drill>(null);
|
||||
const [drillData, setDrillData] = useState<H2BiDrillResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchH2BiMeta().then((result) => {
|
||||
setMeta(result);
|
||||
if (result.years.length && !result.years.some((item) => item.value === year)) setYear(result.years[0].value);
|
||||
}).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "筛选项加载失败"));
|
||||
}, []);
|
||||
|
||||
const query = useMemo<H2BiQuery>(() => ({
|
||||
year,
|
||||
stationId: scope === "station" && stationId ? stationId : null,
|
||||
vehicleScope,
|
||||
verifyScope,
|
||||
}), [scope, stationId, vehicleScope, verifyScope, year]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
Promise.all([fetchH2BiOverview(query), fetchH2BiDaily(query)])
|
||||
.then(([nextOverview, nextDaily]) => {
|
||||
if (!active) return;
|
||||
setOverview(nextOverview);
|
||||
setDaily(nextDaily);
|
||||
})
|
||||
.catch((reason: unknown) => active && setError(reason instanceof Error ? reason.message : "能源数据加载失败"))
|
||||
.finally(() => active && setLoading(false));
|
||||
return () => { active = false; };
|
||||
}, [query, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!drill) { setDrillData(null); return; }
|
||||
fetchH2BiDrill({ ...query, stationId: drill.stationId ?? query.stationId, customerId: drill.customerId, plateNo: drill.plateNo, groupBy: drill.level, page: 1, pageSize: 100 })
|
||||
.then(setDrillData)
|
||||
.catch(() => setDrillData(null));
|
||||
}, [drill, query]);
|
||||
|
||||
const kpi = overview?.kpis;
|
||||
const kpis = [
|
||||
{ label: "累计加氢量", value: tons(kpi?.totalKg), unit: "T", icon: Fuel, sub: [["我司承担", `${tons(kpi?.companyBearingKg)} T`], ["客户承担", `${tons(kpi?.customerBearingKg)} T`], ["其他", `${tons(kpi?.otherBearingKg)} T`]], drill: { title: "累计加氢量", level: "station" as const }, featured: true },
|
||||
{ label: "累计加氢费", value: wan(kpi?.totalCost), unit: "万", prefix: "¥", icon: Wallet, sub: [["我司承担", `¥${wan(kpi?.companyCost)} 万`], ["客户承担", `¥${wan(kpi?.customerCost)} 万`], ["其他", `¥${wan(kpi?.otherCost)} 万`]], drill: { title: "累计加氢费", level: "station" as const } },
|
||||
{ label: "加氢利润", value: wan(kpi?.customerGrossProfit), unit: "万", prefix: "¥", icon: TrendingUp, sub: `对客 ¥${wan(kpi?.customerRevenue)}万 · 成本 ¥${wan(kpi?.customerCost)}万`, drill: { title: "加氢利润", level: "station" as const } },
|
||||
{ label: "本月加氢量", value: tons(kpi?.monthKg), unit: "T", icon: Truck, sub: `加氢费 ¥${wan(kpi?.monthCost)} 万 · 占累计 ${number(kpi?.monthShareOfRange)}%`, drill: { title: "本月加氢量", level: "station" as const }, featured: true },
|
||||
{ label: "今日加氢量", value: number(kpi?.todayKg), unit: "Kg", icon: Zap, sub: `加氢费 ¥${number(kpi?.todayCost)} · 占本月 ${number(kpi?.todayShareOfMonth)}%`, drill: { title: "今日加氢量", level: "station" as const } },
|
||||
];
|
||||
const displayMonthly = useMemo(() => {
|
||||
const source = new Map((overview?.monthly ?? []).map((item) => [item.month, item]));
|
||||
const finalMonth = overview?.range.endDate?.startsWith(String(year))
|
||||
? Number(overview.range.endDate.slice(5, 7))
|
||||
: 12;
|
||||
return Array.from({ length: Math.max(finalMonth, 1) }, (_, index) => {
|
||||
const month = `${year}-${String(index + 1).padStart(2, "0")}`;
|
||||
const item = source.get(month);
|
||||
return {
|
||||
month,
|
||||
totalKg: finiteNumber(item?.totalKg) ?? 0,
|
||||
lingniuKg: finiteNumber(item?.lingniuKg) ?? 0,
|
||||
externalKg: finiteNumber(item?.externalKg) ?? 0,
|
||||
customerRevenue: finiteNumber(item?.customerRevenue) ?? 0,
|
||||
cost: finiteNumber(item?.cost) ?? 0,
|
||||
};
|
||||
});
|
||||
}, [overview, year]);
|
||||
const maxMonth = Math.max(...displayMonthly.map((item) => item.totalKg), 1);
|
||||
const maxDay = Math.max(...(daily?.trend.map((item) => finiteNumber(item.kg) ?? 0) ?? []), 1);
|
||||
const topTotal = overview?.stations.reduce((sum, item) => sum + (finiteNumber(item.kg) ?? 0), 0) ?? 0;
|
||||
const topFive = overview?.stations.slice().sort((a, b) => b.kg - a.kg).slice(0, 5) ?? [];
|
||||
const topShare = topTotal ? number(topFive.reduce((sum, item) => sum + item.kg, 0) / topTotal * 100, 1) : "—";
|
||||
const safeTotalKg = finiteNumber(kpi?.totalKg);
|
||||
const safeProfit = finiteNumber(kpi?.customerGrossProfit);
|
||||
const unitProfit = safeTotalKg && safeProfit !== null ? number(safeProfit / safeTotalKg) : "—";
|
||||
const maxFinance = Math.max(...displayMonthly.flatMap((item) => [item.customerRevenue, item.cost]), 1);
|
||||
const regionTotal = overview?.regions.reduce((sum, item) => sum + (finiteNumber(item.kg) ?? 0), 0) ?? 0;
|
||||
const bearerTotal = finiteNumber(kpi?.totalKg) ?? 0;
|
||||
const bearerPct = (value: unknown) => bearerTotal ? (finiteNumber(value) ?? 0) / bearerTotal * 100 : 0;
|
||||
const monthChange = monthlyChange(overview?.monthly ?? []);
|
||||
|
||||
const exportOverview = () => overview && downloadExcelAoa([
|
||||
["加氢站", "加氢量(Kg)", "成本(元)", "对客金额(元)", "流水笔数"],
|
||||
...overview.stations.map((row) => [row.name, row.kg, row.cost, row.customerRevenue, row.recordCount]),
|
||||
], `氢能经营看板_${year}.xlsx`, "氢能经营看板");
|
||||
|
||||
return (
|
||||
<main className="eob" data-component="energy-operations-board-v1">
|
||||
<header className="eob-hero">
|
||||
<div className="eob-brand"><span><Fuel size={21} /></span><div><div className="eob-title-line"><h1>氢能经营看板</h1><b>实时运营</b></div><p>统计时间范围:{overview?.range.startDate ?? "—"} 至 {overview?.range.endDate ?? "—"}</p></div></div>
|
||||
<div className="eob-hero-nav">
|
||||
<div className="eob-scope" aria-label="看板范围">
|
||||
<button className={scope === "global" ? "is-active" : ""} onClick={() => setScope("global")}>全局网络</button>
|
||||
<button className={scope === "station" ? "is-active" : ""} onClick={() => setScope("station")}>单站视角</button>
|
||||
</div>
|
||||
<div className="eob-tabs"><button className={view === "overview" ? "is-active" : ""} onClick={() => setView("overview")}>经营总览</button><button className={view === "daily" ? "is-active" : ""} onClick={() => setView("daily")}>日期</button></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="eob-filters" aria-label="筛选条件">
|
||||
<div className="eob-filter-left">
|
||||
<label className="eob-year"><Calendar size={14}/><select aria-label="年份" value={year} onChange={(e) => setYear(Number(e.target.value))}>{(meta?.years ?? []).map((item) => <option value={item.value} key={item.value}>{item.value} 年度</option>)}</select></label>
|
||||
<div className="eob-vehicle-tabs" aria-label="车辆范围"><button className={vehicleScope === "all" ? "is-active" : ""} onClick={() => setVehicleScope("all")}>全部车辆</button><button className={vehicleScope === "lingniu" ? "is-active" : ""} onClick={() => setVehicleScope("lingniu")}><i/>羚牛车辆</button><button className={vehicleScope === "external" ? "is-active" : ""} onClick={() => setVehicleScope("external")}><i/>外部车辆</button></div>
|
||||
{scope === "station" && <label>站点<select value={stationId} onChange={(e) => setStationId(e.target.value)}><option value="">请选择站点</option>{(meta?.stations ?? []).map((item) => <option value={String(item.id)} key={String(item.id)}>{item.name}</option>)}</select></label>}
|
||||
</div>
|
||||
<div className="eob-filter-right">
|
||||
<select aria-label="核对状态" value={verifyScope} onChange={(e) => setVerifyScope(e.target.value as H2BiVerifyScope)}><option value="all">全量订单</option><option value="verified">仅已核对</option></select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && <div className="eob-state is-error">{error}<button onClick={() => setRefreshKey((value) => value + 1)}>重新加载</button></div>}
|
||||
{loading && <div className="eob-state">正在加载真实能源数据…</div>}
|
||||
|
||||
{!error && !loading && view === "overview" && <>
|
||||
<section className="eob-mobile-overview" aria-label="累计经营概览"><header><h2>累计经营概览</h2><span>{year} 年累计</span></header><div className="eob-mobile-totals"><button onClick={() => setDrill({ title: "累计加氢量", level: "station" })}><span>累计加氢量</span><strong>{tons(kpi?.totalKg)}<small>T</small></strong></button><button onClick={() => setDrill({ title: "累计成本金额", level: "station" })}><span>累计成本金额</span><strong>¥{wan(kpi?.totalCost)}<small>万</small></strong></button></div><div className="eob-bearer-bar"><i style={{width:`${bearerPct(kpi?.companyBearingKg)}%`}}/><i style={{width:`${bearerPct(kpi?.customerBearingKg)}%`}}/><i style={{width:`${bearerPct(kpi?.otherBearingKg)}%`}}/></div><div className="eob-bearers"><span>我司<strong>{tons(kpi?.companyBearingKg)}T</strong><small>{number(bearerPct(kpi?.companyBearingKg),1)}%</small></span><span>客户<strong>{tons(kpi?.customerBearingKg)}T</strong><small>{number(bearerPct(kpi?.customerBearingKg),1)}%</small></span><span>其他<strong>{tons(kpi?.otherBearingKg)}T</strong><small>{number(bearerPct(kpi?.otherBearingKg),1)}%</small></span></div></section>
|
||||
<button className="eob-mobile-profit" onClick={() => setDrill({ title: "加氢利润", level: "station" })}><span><TrendingUp size={22}/></span><div><small>加氢利润</small><strong>¥{wan(kpi?.customerGrossProfit)}<i>万</i></strong></div><dl><div><dt>收入</dt><dd>¥{wan(kpi?.customerRevenue)}万</dd></div><div><dt>成本</dt><dd>¥{wan(kpi?.customerCost)}万</dd></div></dl></button>
|
||||
<section className="eob-mobile-period"><button onClick={() => setDrill({ title: "本月加氢", level: "station" })}><span>本月加氢</span><strong>{tons(kpi?.monthKg)}<small>T</small></strong><p>费用 ¥{wan(kpi?.monthCost)}万</p></button><button onClick={() => setDrill({ title: "本日加氢", level: "station" })}><span>本日加氢</span><strong>{number(kpi?.todayKg)}<small>Kg</small></strong><p>费用 ¥{number(kpi?.todayCost)}</p></button></section>
|
||||
<section className="eob-kpis" aria-label="五项经营指标">{kpis.map(({ icon: Icon, ...item }) => <button key={item.label} className={`eob-kpi ${item.featured ? "is-featured" : ""}`} onClick={() => setDrill(item.drill)}><span className="eob-kpi-head"><span>{item.label}<small>⌕ 查看明细</small></span><i><Icon size={17} /></i></span><strong>{item.value === "—" ? <b>—</b> : <>{item.prefix}<b>{item.value}</b><small>{item.unit}</small></>}</strong>{Array.isArray(item.sub) ? <div className="eob-kpi-breakdown">{item.sub.map(([label,value])=><span key={label}><small>{label}</small><b>{value}</b></span>)}</div> : <p>{item.sub}</p>}</button>)}</section>
|
||||
<section className="eob-diagnosis-desktop" aria-label="经营诊断"><b>经营诊断</b><article><span>月度环比</span><strong>{monthChange.value}</strong><small>{monthChange.detail}</small></article><article><span>单公斤毛利</span><strong>{unitProfit === "—" ? "—" : `¥${unitProfit}/kg`}</strong><small>{unitProfit === "—" ? "暂不可用" : "按累计加氢量计算"}</small></article><article><span>头部站点占比</span><strong>{topShare === "—" ? "—" : `${topShare}%`}</strong><small>{topShare === "—" ? "暂不可用" : "前5站占总量"}</small></article><article><span>待核对订单</span><strong>—</strong><small>暂不可用</small></article></section>
|
||||
<details className="eob-diagnosis" open={false}><summary>经营诊断 <span>展开查看</span></summary><div><article><span>月度环比</span><strong>{monthChange.value}</strong><small>{monthChange.detail}</small></article><article><span>单公斤毛利</span><strong>{unitProfit === "—" ? "—" : `¥${unitProfit}/kg`}</strong><small>{unitProfit === "—" ? "暂不可用" : "按累计加氢量计算"}</small></article><article><span>头部站点占比</span><strong>{topShare === "—" ? "—" : `${topShare}%`}</strong><small>{topShare === "—" ? "暂不可用" : "前5站占总量"}</small></article><article><span>待核对订单</span><strong>—</strong><small>暂不可用</small></article></div></details>
|
||||
<section className="eob-charts">
|
||||
<article className="eob-panel"><header><h2>{year} 年月度加氢量</h2><span><i className="is-blue" />羚牛车辆 <i className="is-light-blue" />外部车辆 统计范围:{overview?.range.startDate} 至 {overview?.range.endDate} · 单位 Kg</span></header><div className="eob-bars">{displayMonthly.map((item) => <div key={item.month}><span>{number(item.totalKg / 1000, 1)}k</span><b style={{ height: `${item.totalKg ? Math.max(item.totalKg / maxMonth * 100, 2) : 0}%` }}><i className="is-light-blue" style={{ height: `${item.totalKg ? item.externalKg / item.totalKg * 100 : 0}%` }} /><i className="is-blue" /></b><small>{Number(item.month.slice(-2))}月</small></div>)}</div></article>
|
||||
<article className="eob-panel eob-finance"><header><h2>{year} 年月度收支对比</h2><span><i className="is-cyan" />客户收入 <i className="is-purple" />成本支出 统计范围:{overview?.range.startDate} 至 {overview?.range.endDate} · 单位 元</span></header><div className="eob-finance-bars">{displayMonthly.map((item) => <div key={item.month}><span><i className="is-purple" style={{height:`${item.cost ? Math.max(item.cost/maxFinance*100,2) : 0}%`}}/><i className="is-cyan" style={{height:`${item.customerRevenue ? Math.max(item.customerRevenue/maxFinance*100,2) : 0}%`}}/></span><small>{Number(item.month.slice(-2))}月</small></div>)}</div></article>
|
||||
<article className="eob-panel"><header><h2>加氢站加氢量 Top5</h2><button onClick={() => setDrill({ title: "加氢站排名", level: "station" })}>查看明细</button></header><ol className="eob-ranking">{topFive.map((item, index) => <li key={String(item.id)}><b>{index + 1}</b><span>{item.name}</span><i><em style={{ width: `${finiteNumber(topFive[0]?.kg) ? (finiteNumber(item.kg) ?? 0) / (finiteNumber(topFive[0]?.kg) ?? 1) * 100 : 0}%` }} /></i><strong>{number(item.kg, 0)}</strong></li>)}</ol></article>
|
||||
<article className="eob-panel eob-regions"><header><h2>各区域加氢占比</h2><strong>合计 {tons(regionTotal)} T</strong></header><div>{overview?.regions.map((item,index)=><article key={item.region}><b>{index+1}</b><span>{item.region || "未归属"}</span><i><em style={{width:`${regionTotal ? (finiteNumber(item.kg) ?? 0)/regionTotal*100 : 0}%`}}/></i><strong>{number(finiteNumber(item.share) ?? (regionTotal ? (finiteNumber(item.kg) ?? 0)/regionTotal*100 : null),1)}%</strong></article>)}{!overview?.regions.length&&<p>暂无区域数据</p>}</div></article>
|
||||
</section>
|
||||
</>}
|
||||
|
||||
{!error && !loading && view === "daily" && <section className="eob-panel eob-daily"><header><h2>每日加氢趋势</h2><span>{daily?.range.startDate ?? "—"} 至 {daily?.range.endDate ?? "—"}</span></header><div className="eob-bars">{daily?.trend.map((item) => <div key={item.date}><span>{number(item.kg, 0)}</span><b style={{ height: `${Math.max((finiteNumber(item.kg) ?? 0) / maxDay * 100, 2)}%` }}><i className="is-blue" /></b><small>{item.date.slice(5)}</small></div>)}</div><div className="eob-daily-table">{daily?.days.map((item) => <button key={item.date} onClick={() => setDrill({ title: `${item.date} 明细`, level: "station" })}><span>{item.date}</span><strong>{number(item.kg)} Kg</strong><small>¥{number(item.cost)} · {number(item.recordCount, 0)} 笔</small></button>)}</div></section>}
|
||||
|
||||
{drill && <div className="eob-modal" role="dialog" aria-modal="true" data-drill-level={drill.level}><section><header><div><h2>{drill.title}</h2><p>站点 → 客户 → 车辆 → 订单</p></div><button onClick={() => setDrill(null)} aria-label="关闭"><X /></button></header><nav className="eob-drill-crumbs"><button onClick={() => setDrill({ title: drill.title, level: "station" })}>站点</button>{drill.stationName && <><ChevronRight size={14}/><button onClick={() => setDrill({ ...drill, level: "customer", customerId: undefined, customerName: undefined, plateNo: undefined })}>{drill.stationName}</button></>}{drill.customerName && <><ChevronRight size={14}/><button onClick={() => setDrill({ ...drill, level: "vehicle", plateNo: undefined })}>{drill.customerName}</button></>}{drill.plateNo && <><ChevronRight size={14}/><span>{drill.plateNo}</span></>}</nav><div className="eob-modal-body">{!drillData ? <div className="eob-state">正在加载明细…</div> : drill.level === "record" ? <table><thead><tr><th>订单</th><th>加氢量</th><th>成本</th><th>状态</th></tr></thead><tbody>{drillData.records.map((row, index) => <tr key={String(row.id ?? index)}><td>{String(row.orderNo ?? row.id ?? "—")}</td><td>{number(row.kg)} Kg</td><td>¥{number(row.cost)}</td><td>{String(row.verifyStatus ?? row.status ?? "—")}</td></tr>)}{drillData.records.length === 0 && <tr><td colSpan={4}>暂无可用订单</td></tr>}</tbody></table> : <table><thead><tr><th>{drill.level === "station" ? "站点" : drill.level === "customer" ? "客户" : "车辆"}</th><th>加氢量</th><th>成本</th><th>流水</th></tr></thead><tbody>{drillData.groups.map((row) => <tr className="eob-drill-row" key={row.id} onClick={() => setDrill(drill.level === "station" ? { ...drill, level: "customer", stationId: row.id, stationName: row.name } : drill.level === "customer" ? { ...drill, level: "vehicle", customerId: Number(row.id), customerName: row.name } : { ...drill, level: "record", plateNo: row.name })}><td>{row.name}<ChevronRight size={15}/></td><td>{number(row.kg)} Kg</td><td>¥{number(row.cost)}</td><td>{number(row.recordCount, 0)}</td></tr>)}{drillData.groups.length === 0 && <tr><td colSpan={4}>暂无可用数据</td></tr>}</tbody></table>}</div></section></div>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,182 +0,0 @@
|
||||
import { fetchJson } from '../../../auth/api-client';
|
||||
import type {
|
||||
H2BiDailyResponse,
|
||||
H2BiDailyTreeQuery,
|
||||
H2BiDailyTreeResponse,
|
||||
H2BiDrillReadOptions,
|
||||
H2BiFullDrillOptions,
|
||||
H2BiFullDrillResponse,
|
||||
H2BiDrillQuery,
|
||||
H2BiDrillResponse,
|
||||
H2BiMetaResponse,
|
||||
H2BiOverviewResponse,
|
||||
H2BiQuery,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api/energy/h2/v2';
|
||||
|
||||
function queryString(query: Record<string, unknown>) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null && value !== '') params.set(key, String(value));
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function request<T>(path: string, query: object = {}, options?: RequestInit) {
|
||||
const qs = queryString(query as Record<string, unknown>);
|
||||
return fetchJson<T>(`${BASE}/${path}${qs ? `?${qs}` : ''}`, options);
|
||||
}
|
||||
|
||||
export function fetchH2BiMeta() {
|
||||
return request<H2BiMetaResponse>('meta');
|
||||
}
|
||||
|
||||
export function fetchH2BiOverview(query: H2BiQuery) {
|
||||
return request<H2BiOverviewResponse>('overview', query);
|
||||
}
|
||||
|
||||
export function fetchH2BiDaily(query: H2BiQuery) {
|
||||
return request<H2BiDailyResponse>('daily', query).catch(async () => {
|
||||
// The date-group drill is backed by the same read-only ledger and has a
|
||||
// simpler query plan. Keep the date view usable when the aggregate daily
|
||||
// endpoint times out/fails, without substituting mock data.
|
||||
const drill = await request<H2BiDrillResponse>('drill', {
|
||||
...query,
|
||||
groupBy: 'date',
|
||||
pageSize: 400,
|
||||
});
|
||||
const startDate = query.startDate ?? `${query.year}-01-01`;
|
||||
const endDate = query.endDate ?? new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date());
|
||||
const byDate = new Map(drill.groups.map((row) => [row.name, row]));
|
||||
const days = [] as H2BiDailyResponse['trend'];
|
||||
for (let cursor = new Date(`${startDate}T00:00:00Z`); cursor <= new Date(`${endDate}T00:00:00Z`); cursor.setUTCDate(cursor.getUTCDate() + 1)) {
|
||||
const date = cursor.toISOString().slice(0, 10);
|
||||
const row = byDate.get(date);
|
||||
days.push({
|
||||
date,
|
||||
kg: Number(row?.kg ?? 0),
|
||||
lingniuKg: Number(row?.lingniuKg ?? 0),
|
||||
externalKg: Number(row?.externalKg ?? 0),
|
||||
cost: Number(row?.cost ?? 0),
|
||||
recordCount: Number(row?.recordCount ?? 0),
|
||||
});
|
||||
}
|
||||
const totalKg = days.reduce((sum, row) => sum + row.kg, 0);
|
||||
const totalCost = days.reduce((sum, row) => sum + row.cost, 0);
|
||||
return {
|
||||
range: { startDate, endDate },
|
||||
watermark: { ledgerAt: null, paymentAt: null },
|
||||
filters: query,
|
||||
kpis: {
|
||||
totalKg,
|
||||
totalCost,
|
||||
averageDailyKg: totalKg / Math.max(1, days.length),
|
||||
stationCount: Number(drill.summary.stationCount ?? 0),
|
||||
activeDays: days.filter((row) => row.kg > 0).length,
|
||||
},
|
||||
trend: days,
|
||||
days: [...days].reverse(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchH2BiDailyTree(date: string, query: H2BiDailyTreeQuery) {
|
||||
return request<H2BiDailyTreeResponse>('daily-tree', { date, ...query });
|
||||
}
|
||||
|
||||
export async function fetchH2BiDrill(
|
||||
query: H2BiDrillQuery,
|
||||
options?: H2BiDrillReadOptions,
|
||||
) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = Math.min(Math.max(1, query.pageSize ?? 100), 200);
|
||||
const response = await request<H2BiDrillResponse>(
|
||||
'drill',
|
||||
{ ...query, page, pageSize },
|
||||
options,
|
||||
);
|
||||
const itemCount = query.groupBy === 'record'
|
||||
? response.records.length
|
||||
: response.groups.length;
|
||||
// Older servers only set hasMore for record pages. A full page is therefore
|
||||
// also treated as potentially incomplete; the final empty/short page proves
|
||||
// completion without silently dropping grouped rows.
|
||||
return {
|
||||
...response,
|
||||
page: {
|
||||
...response.page,
|
||||
page,
|
||||
pageSize,
|
||||
itemCount,
|
||||
hasMore: Boolean(response.page?.hasMore) || itemCount === pageSize,
|
||||
},
|
||||
} satisfies H2BiDrillResponse;
|
||||
}
|
||||
|
||||
function abortError() {
|
||||
const error = new Error('已取消全量读取');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every page only after a caller explicitly asks for a complete result.
|
||||
* It never returns a partial collection: server errors, cancellation, and the
|
||||
* page guard all reject before an export can be created.
|
||||
*/
|
||||
export async function fetchAllH2BiDrill(
|
||||
query: Omit<H2BiDrillQuery, 'page' | 'pageSize'>,
|
||||
options: H2BiFullDrillOptions = {},
|
||||
): Promise<H2BiFullDrillResponse> {
|
||||
const pageSize = Math.min(Math.max(1, options.pageSize ?? 200), 200);
|
||||
const maxPages = Math.max(1, options.maxPages ?? 250);
|
||||
const maxRows = Math.max(1, options.maxRows ?? 50_000);
|
||||
const groups: H2BiDrillResponse['groups'] = [];
|
||||
const records: H2BiDrillResponse['records'] = [];
|
||||
let first: H2BiDrillResponse | null = null;
|
||||
|
||||
for (let page = 1; page <= maxPages; page += 1) {
|
||||
if (options.signal?.aborted) throw abortError();
|
||||
const result = await fetchH2BiDrill(
|
||||
{ ...query, page, pageSize },
|
||||
{ signal: options.signal },
|
||||
);
|
||||
if (!first) first = result;
|
||||
groups.push(...result.groups);
|
||||
records.push(...result.records);
|
||||
const itemCount = query.groupBy === 'record' ? records.length : groups.length;
|
||||
if (itemCount > maxRows)
|
||||
throw new Error(`全量读取超过 ${maxRows} 条保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
|
||||
if (!result.page.hasMore) {
|
||||
return {
|
||||
...first,
|
||||
groups,
|
||||
records,
|
||||
page: {
|
||||
...result.page,
|
||||
page: 1,
|
||||
pageSize,
|
||||
itemCount,
|
||||
hasMore: false,
|
||||
pagesRead: page,
|
||||
complete: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new Error(`全量读取超过 ${maxPages} 页保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
|
||||
}
|
||||
|
||||
/** Reusable contract for station-detail consumers that need every raw record. */
|
||||
export function fetchAllH2BiDrillRecords(
|
||||
query: Omit<H2BiDrillQuery, 'groupBy' | 'page' | 'pageSize'>,
|
||||
options?: H2BiFullDrillOptions,
|
||||
) {
|
||||
return fetchAllH2BiDrill({ ...query, groupBy: 'record' }, options);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { bearingLabels } from "./bearing-labels";
|
||||
|
||||
test("records display their ledger bearing type", () => {
|
||||
assert.deepEqual(bearingLabels({ settlementType: 1 }).map(x => x.label), ["客户承担"]);
|
||||
assert.deepEqual(bearingLabels({ settlementType: "2" }).map(x => x.label), ["我司承担"]);
|
||||
assert.deepEqual(bearingLabels({ settlementType: 3 }).map(x => x.label), ["客户自行结算"]);
|
||||
});
|
||||
test("groups display every distinct bearing type, including unknown", () => {
|
||||
assert.deepEqual(bearingLabels({ settlementTypes: "1,2,3,unknown,1" }).map(x => x.label),
|
||||
["客户承担", "我司承担", "客户自行结算", "未明确"]);
|
||||
});
|
||||
test("missing and unrecognized values do not imply an actual payer", () => {
|
||||
for (const settlementType of [null, undefined, "", 4, "all"]) {
|
||||
assert.deepEqual(bearingLabels({ settlementType }).map(x => x.label), ["未明确"]);
|
||||
}
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
const labels: Record<string, { label: string; className: string }> = {
|
||||
"1": { label: "客户承担", className: "is-cust" },
|
||||
"2": { label: "我司承担", className: "is-lingniu" },
|
||||
"3": { label: "客户自行结算", className: "is-other" },
|
||||
};
|
||||
|
||||
// Use ledger settlement types, never the selected filter or monetary amounts.
|
||||
export function bearingLabels(row: { settlementTypes?: unknown; settlementType?: unknown }) {
|
||||
const types = String(row.settlementTypes ?? row.settlementType ?? "")
|
||||
.split(",").map((value) => value.trim());
|
||||
const results = types.map((value) => labels[value] ?? { label: "未明确", className: "is-other" });
|
||||
return [...new Map(results.map((item) => [item.label, item])).values()];
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
|
||||
|
||||
export function DailyTreeButton({ open, label, children, onClick }: {
|
||||
open: boolean; label: string; children: ReactNode; onClick: () => void;
|
||||
}) {
|
||||
const Icon = open ? ChevronDown : ChevronRight;
|
||||
return <button type="button" className="ehb-daily-disclosure" aria-expanded={open}
|
||||
aria-label={`${open ? "收起" : "展开"}${label}`} onClick={onClick}>
|
||||
<Icon size={15} aria-hidden="true" /><span>{children}</span>
|
||||
</button>;
|
||||
}
|
||||
|
||||
export function DailyBranchState({ columns, error, empty, onRetry }: {
|
||||
columns: number; error?: string; empty?: boolean; onRetry: () => void;
|
||||
}) {
|
||||
return <tr className="ehb-daily-branch-state"><td colSpan={columns}>
|
||||
<div role={error ? "alert" : "status"}>
|
||||
{error || (empty ? "当前范围暂无明细" : "正在加载明细…")}
|
||||
{error ? <button type="button" onClick={onRetry}><RefreshCw size={14} aria-hidden="true" />重试</button> : null}
|
||||
</div>
|
||||
</td></tr>;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { H2BiDailyResponse } from "./types";
|
||||
|
||||
export function formatDailyChange(value: unknown): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "环比 —";
|
||||
return `环比 ${value > 0 ? "+" : ""}${value.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
/** Export the entire selected range, independently of which branches were opened. */
|
||||
export function dailySummaryRows(daily: H2BiDailyResponse): Array<Array<string | number>> {
|
||||
return [
|
||||
["日期", "加氢站数", "加氢量(Kg)", "成本(元)"],
|
||||
["区间合计", daily.kpis.stationCount, daily.kpis.totalKg, daily.kpis.totalCost],
|
||||
...daily.days.map(day => [day.date, day.stationCount ?? "—", day.kg, day.cost]),
|
||||
];
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { DailyBranchState, DailyTreeButton } from "./daily-detail-controls";
|
||||
import { dailySummaryRows, formatDailyChange } from "./daily-detail-format";
|
||||
import type { H2BiDailyResponse } from "./types";
|
||||
|
||||
test("每日环比区分真实零值、缺失值和非数值", () => {
|
||||
for (const value of [null, undefined, NaN, Infinity, "0"]) assert.equal(formatDailyChange(value), "环比 —");
|
||||
assert.equal(formatDailyChange(0), "环比 0.00%");
|
||||
assert.equal(formatDailyChange(-12.5), "环比 -12.50%");
|
||||
assert.equal(formatDailyChange(12.5), "环比 +12.50%");
|
||||
});
|
||||
|
||||
test("日期汇总导出包含全部日期,不依赖已展开明细并保留真零", () => {
|
||||
const daily = {
|
||||
kpis: { stationCount: 2, totalKg: 15.25, totalCost: 450 },
|
||||
days: [
|
||||
{ date: "2026-09-03", stationCount: 2, kg: 15.25, cost: 450 },
|
||||
{ date: "2026-09-02", stationCount: 0, kg: 0, cost: 0 },
|
||||
],
|
||||
} as H2BiDailyResponse;
|
||||
const rows = dailySummaryRows(daily);
|
||||
assert.equal(rows.length, 4);
|
||||
assert.deepEqual(rows[1], ["区间合计", 2, 15.25, 450]);
|
||||
assert.deepEqual(rows[3], ["2026-09-02", 0, 0, 0]);
|
||||
});
|
||||
|
||||
test("层级按钮包含键盘原生语义与明确展开状态", () => {
|
||||
for (const open of [false, true]) {
|
||||
const html = renderToStaticMarkup(createElement(DailyTreeButton, {
|
||||
open, label: "测试站客户明细", children: "测试站", onClick() {},
|
||||
}));
|
||||
assert.match(html, /<button type="button"/);
|
||||
assert.ok(html.includes(`aria-expanded="${open}"`));
|
||||
assert.ok(html.includes(`${open ? "收起" : "展开"}测试站客户明细`));
|
||||
}
|
||||
});
|
||||
|
||||
test("展开状态区分加载、空数据、失败及重试入口", () => {
|
||||
const render = (props: Partial<Parameters<typeof DailyBranchState>[0]>) => renderToStaticMarkup(createElement(DailyBranchState, {
|
||||
columns: 3, onRetry() {}, ...props,
|
||||
}));
|
||||
assert.match(render({}), /正在加载明细/);
|
||||
assert.match(render({ empty: true }), /当前范围暂无明细/);
|
||||
const error = render({ error: "站点明细加载失败,请重试" });
|
||||
assert.match(error, /role="alert"/);
|
||||
assert.match(error, /<button type="button"/);
|
||||
assert.match(error, /colSpan="3"/i);
|
||||
assert.doesNotMatch(error, /正在加载明细/);
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { devMockResponse } from "./dev-mock-api";
|
||||
|
||||
test("开发 mock 覆盖健康检查与氢能 v2 只读接口", () => {
|
||||
for (const path of [
|
||||
"/api/health",
|
||||
"/api/energy/h2/v2/meta",
|
||||
"/api/energy/h2/v2/overview",
|
||||
"/api/energy/h2/v2/daily",
|
||||
"/api/energy/h2/v2/daily-tree",
|
||||
"/api/energy/h2/v2/drill",
|
||||
]) assert.ok(devMockResponse(path), path);
|
||||
|
||||
assert.equal(devMockResponse("/api/unknown"), undefined);
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
const station = {
|
||||
id: "101",
|
||||
name: "本地验收加氢站",
|
||||
province: "浙江省",
|
||||
city: "嘉兴市",
|
||||
kg: 12345.67,
|
||||
lingniuKg: 10000,
|
||||
externalKg: 2345.67,
|
||||
cost: 23456.78,
|
||||
revenue: 34567.89,
|
||||
customerRevenue: 34567.89,
|
||||
customerCost: 18000,
|
||||
companyCost: 4000,
|
||||
otherCost: 1456.78,
|
||||
recordCount: 42,
|
||||
customerCount: 1,
|
||||
share: 100,
|
||||
};
|
||||
|
||||
const customer = {
|
||||
id: 1,
|
||||
name: "本地验收客户",
|
||||
kg: 12345.67,
|
||||
customerBearingKg: 10000,
|
||||
companyBearingKg: 2000,
|
||||
otherBearingKg: 345.67,
|
||||
bearer: "both",
|
||||
cost: 23456.78,
|
||||
revenue: 34567.89,
|
||||
customerRevenue: 34567.89,
|
||||
customerCost: 18000,
|
||||
companyCost: 4000,
|
||||
otherCost: 1456.78,
|
||||
recordCount: 42,
|
||||
};
|
||||
|
||||
const range = { startDate: "2026-01-01", endDate: "2026-08-31" };
|
||||
const watermark = { ledgerAt: "2026-08-31 16:00:00", paymentAt: null };
|
||||
const kpis = {
|
||||
totalKg: 12345.67,
|
||||
totalCost: 23456.78,
|
||||
customerBearingKg: 10000,
|
||||
companyBearingKg: 2000,
|
||||
otherBearingKg: 345.67,
|
||||
customerRevenue: 34567.89,
|
||||
customerCost: 18000,
|
||||
companyCost: 4000,
|
||||
otherCost: 1456.78,
|
||||
totalRevenue: 34567.89,
|
||||
customerGrossProfit: 16567.89,
|
||||
monthKg: 3456.78,
|
||||
monthCost: 6789.01,
|
||||
todayKg: 123.45,
|
||||
todayCost: 234.56,
|
||||
monthShareOfRange: 28,
|
||||
todayShareOfMonth: 3.57,
|
||||
recordCount: 42,
|
||||
stationCount: 1,
|
||||
};
|
||||
|
||||
const overview = {
|
||||
range,
|
||||
watermark,
|
||||
filters: {},
|
||||
kpis,
|
||||
monthly: [{
|
||||
month: "2026-08",
|
||||
totalKg: 12345.67,
|
||||
lingniuKg: 10000,
|
||||
externalKg: 2345.67,
|
||||
cost: 23456.78,
|
||||
customerCost: 18000,
|
||||
companyCost: 4000,
|
||||
otherCost: 1456.78,
|
||||
revenue: 34567.89,
|
||||
customerRevenue: 34567.89,
|
||||
customerGrossProfit: 16567.89,
|
||||
}],
|
||||
topStations: [station],
|
||||
regions: [{ region: "嘉兴市", kg: 12345.67, share: 100 }],
|
||||
stations: [station],
|
||||
customers: [customer],
|
||||
};
|
||||
|
||||
const dailyPoint = {
|
||||
date: "2026-08-31",
|
||||
kg: 123.45,
|
||||
lingniuKg: 100,
|
||||
externalKg: 23.45,
|
||||
cost: 234.56,
|
||||
recordCount: 2,
|
||||
stationCount: 1,
|
||||
};
|
||||
|
||||
export function devMockResponse(pathname: string) {
|
||||
if (pathname === "/api/health") return { status: "ok", source: "dev-mock" };
|
||||
if (pathname.endsWith("/meta")) return {
|
||||
years: [{ value: 2026, startDate: range.startDate, endDate: range.endDate }],
|
||||
stations: [{ id: station.id, name: station.name }],
|
||||
watermark,
|
||||
};
|
||||
if (pathname.endsWith("/overview")) return overview;
|
||||
if (pathname.endsWith("/daily-tree")) return {
|
||||
date: dailyPoint.date,
|
||||
stations: [{
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
kg: dailyPoint.kg,
|
||||
cost: dailyPoint.cost,
|
||||
recordCount: 2,
|
||||
customers: [{ id: customer.id, name: customer.name, kg: dailyPoint.kg, cost: dailyPoint.cost, recordCount: 2 }],
|
||||
}],
|
||||
};
|
||||
if (pathname.endsWith("/daily")) return {
|
||||
range,
|
||||
watermark,
|
||||
filters: {},
|
||||
kpis: { totalKg: 12345.67, totalCost: 23456.78, averageDailyKg: 823.04, stationCount: 1, activeDays: 15 },
|
||||
trend: [dailyPoint],
|
||||
days: [dailyPoint],
|
||||
};
|
||||
if (pathname.endsWith("/drill")) return {
|
||||
groupBy: "record",
|
||||
amountScope: "all",
|
||||
filters: {},
|
||||
summary: { recordCount: 1, kg: 123.45, cost: 4000, revenue: 4320 },
|
||||
groups: [{ ...station, stationCount: 1, customerCount: 1 }],
|
||||
records: [{
|
||||
id: 1,
|
||||
time: "2026-08-31 12:00:00",
|
||||
orderNo: "DEV-001",
|
||||
stationId: station.id,
|
||||
stationName: station.name,
|
||||
customerId: customer.id,
|
||||
customerName: customer.name,
|
||||
plateNo: "浙FDEV01",
|
||||
source: "dev-mock",
|
||||
verifyStatus: "verified",
|
||||
vehicleId: 1,
|
||||
kg: 123.45,
|
||||
unitPrice: 35,
|
||||
cost: 4000,
|
||||
revenue: 4320,
|
||||
}],
|
||||
page: { page: 1, pageSize: 100, hasMore: false },
|
||||
};
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sendJson(response: ServerResponse, body: unknown) {
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
export function energyDevMockApi(): Plugin {
|
||||
return {
|
||||
name: "energy-dev-mock-api",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request: IncomingMessage, response: ServerResponse, next) => {
|
||||
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
|
||||
if (pathname === "/favicon.ico") {
|
||||
response.statusCode = 204;
|
||||
return response.end();
|
||||
}
|
||||
const body = devMockResponse(pathname);
|
||||
if (body === undefined) return next();
|
||||
sendJson(response, body);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { finiteNumber, formatNumber, formatScaled } from "./display-format";
|
||||
|
||||
test("能源看板格式化边界区分真实零值与不可用值", () => {
|
||||
for (const value of [null, undefined, NaN, Infinity, -Infinity, "0"]) {
|
||||
assert.equal(finiteNumber(value), null);
|
||||
assert.equal(formatNumber(value), "—");
|
||||
assert.equal(formatScaled(value, 1000), "—");
|
||||
}
|
||||
assert.equal(formatNumber(0), "0.00");
|
||||
assert.equal(formatScaled(0, 1000), "0.00");
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
export const finiteNumber = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
|
||||
export const formatNumber = (value: unknown, digits = 2): string => {
|
||||
const safe = finiteNumber(value);
|
||||
return safe === null
|
||||
? "—"
|
||||
: safe.toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
});
|
||||
};
|
||||
|
||||
export const formatScaled = (value: unknown, divisor: number, digits = 2) => {
|
||||
const safe = finiteNumber(value);
|
||||
return safe === null ? "—" : formatNumber(safe / divisor, digits);
|
||||
};
|
||||
@@ -1,143 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { fetchAllH2BiDrill, fetchAllH2BiDrillRecords } from "./api";
|
||||
import type { H2BiDrillResponse } from "./types";
|
||||
|
||||
const query = {
|
||||
year: 2026,
|
||||
vehicleScope: "all" as const,
|
||||
verifyScope: "all" as const,
|
||||
};
|
||||
|
||||
function response(overrides: Partial<H2BiDrillResponse> = {}): H2BiDrillResponse {
|
||||
return {
|
||||
groupBy: "station",
|
||||
amountScope: "all",
|
||||
filters: {},
|
||||
summary: {},
|
||||
groups: [],
|
||||
records: [],
|
||||
page: { page: 1, pageSize: 2, hasMore: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function withFetch(
|
||||
handler: (url: URL) => H2BiDrillResponse | Promise<H2BiDrillResponse>,
|
||||
run: () => Promise<void>,
|
||||
) {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = new URL(String(input), "http://ln-bi.local");
|
||||
return new Response(JSON.stringify(await handler(url)), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
}
|
||||
|
||||
test("完整分组读取跨页,并以短页而非旧服务 hasMore 字段确认结束", async () => {
|
||||
const pages: number[] = [];
|
||||
await withFetch((url) => {
|
||||
const page = Number(url.searchParams.get("page"));
|
||||
pages.push(page);
|
||||
return response({
|
||||
groups: page === 1
|
||||
? ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 }))
|
||||
: [{ id: "3", name: "丙", province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 }],
|
||||
});
|
||||
}, async () => {
|
||||
const result = await fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2 });
|
||||
assert.deepEqual(result.groups.map((row) => row.name), ["甲", "乙", "丙"]);
|
||||
assert.equal(result.page.complete, true);
|
||||
assert.equal(result.page.pagesRead, 2);
|
||||
});
|
||||
assert.deepEqual(pages, [1, 2]);
|
||||
});
|
||||
|
||||
test("完整记录读取跨页后保留全部订单", async () => {
|
||||
await withFetch((url) => {
|
||||
const page = Number(url.searchParams.get("page"));
|
||||
return response({
|
||||
groupBy: "record",
|
||||
records: page === 1
|
||||
? [{ id: "a" }, { id: "b" }]
|
||||
: [{ id: "c" }],
|
||||
});
|
||||
}, async () => {
|
||||
const result = await fetchAllH2BiDrillRecords(query, { pageSize: 2 });
|
||||
assert.deepEqual(result.records.map((row) => row.id), ["a", "b", "c"]);
|
||||
assert.equal(result.page.hasMore, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("全量读取在中途请求失败时拒绝,不返回部分结果", async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const page = new URL(String(input), "http://ln-bi.local").searchParams.get("page");
|
||||
if (page === "2") return new Response("failed", { status: 502, statusText: "Bad Gateway" });
|
||||
return new Response(JSON.stringify(response({
|
||||
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
|
||||
})), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2 }),
|
||||
/API error: 502/,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("空结果是完整结果而不是加载失败", async () => {
|
||||
await withFetch(() => response(), async () => {
|
||||
const result = await fetchAllH2BiDrill({ ...query, groupBy: "station" });
|
||||
assert.deepEqual(result.groups, []);
|
||||
assert.equal(result.page.pagesRead, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("取消的全量读取不会发起请求或生成部分数据", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { signal: controller.signal }),
|
||||
(error: Error) => error.name === "AbortError",
|
||||
);
|
||||
});
|
||||
|
||||
test("全量读取在第一页完成后也会响应取消,不会请求下一页", async () => {
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
await withFetch(() => {
|
||||
calls += 1;
|
||||
controller.abort();
|
||||
return response({
|
||||
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
|
||||
});
|
||||
}, async () => {
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { signal: controller.signal, pageSize: 2 }),
|
||||
(error: Error) => error.name === "AbortError",
|
||||
);
|
||||
});
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("maxRows 和 maxPages 均拒绝不完整的全量读取", async () => {
|
||||
await withFetch(() => response({
|
||||
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
|
||||
}), async () => {
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2, maxRows: 1 }),
|
||||
/超过 1 条保护上限/,
|
||||
);
|
||||
await assert.rejects(
|
||||
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2, maxPages: 1 }),
|
||||
/超过 1 页保护上限/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,688 +0,0 @@
|
||||
@import './drill-workspace.css';
|
||||
.ehb-drill-modal--unified .ehb-modal-body { padding: 18px; background: #f6f8fb; }
|
||||
.ehb-drill-modal--unified .ehb-drill-root-tabs { display: flex; justify-content: flex-end; margin: 0 0 12px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-bar {
|
||||
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0;
|
||||
padding: 0; overflow: hidden; border: 1px solid #dfe7f0; border-radius: 8px; background: #fff;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item { min-width: 0; padding: 12px 16px; border-right: 1px solid #dfe7f0; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item:last-child { border-right: 0; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-label { color: #64748b; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-val { color: #172238; font-family: var(--bi-font-mono); font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-modal-filter-row {
|
||||
gap: 8px; padding: 10px 12px; margin: 12px 0; border: 1px solid #dfe7f0;
|
||||
border-radius: 8px; background: #fff;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-filter-group { width: 100%; gap: 8px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-hint-text { flex: 1 1 280px; min-width: 220px; color: #7b8aa1; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table-wrap { border-color: #d7e1ed; border-radius: 8px; background: #fff; }
|
||||
|
||||
/* Reserve space for pagination instead of clipping it below a fixed-height table. */
|
||||
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) > .ehb-modal-table-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 100px;
|
||||
}
|
||||
.ehb-drill-page-controls {
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.ehb-drill-page-controls .ehb-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 72px;
|
||||
width: 72px !important;
|
||||
min-height: 40px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-table th {
|
||||
height: 44px; padding: 10px 12px; border-bottom-color: #d7e1ed;
|
||||
background: #f0f3f8; color: #52627a;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-table > tbody > tr > td {
|
||||
height: 48px; padding-block: 10px; border-bottom-color: #e5ebf2; color: #334155;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--station > td { background: #edf3fa; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--customer > td { background: #f5f7fa; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--vehicle > td { background: #fafbfd; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row:hover > td { background: #e8f0fb; }
|
||||
.ehb-drill-modal--unified .ehb-tree-toggle {
|
||||
display: inline-grid; width: 24px; min-width: 24px; height: 24px; place-items: center;
|
||||
padding: 0; border: 0; border-radius: 5px; background: transparent; color: #2f6bff;
|
||||
font-weight: 800; cursor: pointer;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-toggle:hover,
|
||||
.ehb-drill-modal--unified .ehb-tree-toggle:focus-visible { background: #eaf1ff; outline: none; }
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link {
|
||||
margin-left: auto; padding: 0; border: 0; background: transparent; color: #2f6bff;
|
||||
font: inherit; font-size: 12px; font-weight: 700; white-space: nowrap; cursor: pointer;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link:hover,
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link:focus-visible { color: #174ebc; text-decoration: underline; outline: none; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table td[style*="text-align: right"] {
|
||||
color: #334155; font-family: var(--bi-font-mono); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-volume { color: #2f6bff !important; font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-income { color: #2c8a78 !important; font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-cost { color: #e28a24 !important; font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-profit { color: #2f6bff !important; font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-external { color: #7ea5ec !important; font-weight: 750; }
|
||||
.ehb-drill-modal--unified .ehb-summary-volume,
|
||||
.ehb-drill-modal--unified .ehb-summary-profit { color: #2f6bff; }
|
||||
.ehb-drill-modal--unified .ehb-summary-income { color: #2c8a78; }
|
||||
.ehb-drill-modal--unified .ehb-summary-cost { color: #e28a24; }
|
||||
.ehb-drill-modal--unified .ehb-flat-drill-table tbody tr:nth-child(even) td { background: #fafbfd; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--date td { background: #fff; }
|
||||
.ehb-drill-modal--unified .ehb-day-change.is-up { color: #18a67a; font-weight: 700; }
|
||||
.ehb-drill-modal--unified .ehb-day-change.is-down { color: #e26464; font-weight: 700; }
|
||||
.ehb-drill-modal--unified .ehb-bearer-tag.is-cust { color: #b86606; border: 1px solid #f2d26c; background: #fffbea; }
|
||||
.ehb-drill-modal--unified .ehb-bearer-tag.is-lingniu { color: #2f6bff; border: 1px solid #bdd0fb; background: #eef4ff; }
|
||||
.ehb-drill-modal--unified .ehb-bearer-tag.is-other { color: #64748b; border: 1px solid #cbd5e1; background: #f8fafc; }
|
||||
.ehb-drill-modal--unified .ehb-tag--source-api,
|
||||
.ehb-drill-modal--unified .ehb-tag--source-station,
|
||||
.ehb-drill-modal--unified .ehb-tag--source-lingniu {
|
||||
border: 1px solid #cbd9e8; background: #eef4f9; color: #526b88;
|
||||
}
|
||||
.ehb-drill-local-error {
|
||||
display: flex;
|
||||
min-height: 180px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
color: #475569;
|
||||
text-align: center;
|
||||
}
|
||||
.ehb-drill-local-error strong { color: #b42318; font-size: 16px; }
|
||||
.ehb-drill-local-error span { max-width: 520px; line-height: 1.6; }
|
||||
.ehb-drill-loading {
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
animation: ehb-drill-fade-in .18s ease-out both;
|
||||
}
|
||||
.ehb-drill-loading__progress {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
width: 38%;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, transparent, #2f6bff 45%, #67b5ff, transparent);
|
||||
animation: ehb-drill-progress 1.15s ease-in-out infinite;
|
||||
}
|
||||
.ehb-drill-loading__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 22px 24px 18px;
|
||||
border-bottom: 1px solid #e7edf5;
|
||||
color: #1e293b;
|
||||
}
|
||||
.ehb-drill-loading__label > span:last-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.ehb-drill-loading__label strong { font-size: 14px; }
|
||||
.ehb-drill-loading__label small { color: #7b8aa0; font-size: 12px; }
|
||||
.ehb-drill-loading__spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: 0 0 20px;
|
||||
border: 2px solid #dbe7fb;
|
||||
border-top-color: #2f6bff;
|
||||
border-radius: 50%;
|
||||
animation: ehb-drill-spin .72s linear infinite;
|
||||
}
|
||||
.ehb-drill-loading__rows { padding: 4px 18px 18px; }
|
||||
.ehb-drill-loading__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 2.2fr) repeat(3, minmax(90px, 1fr));
|
||||
gap: 28px;
|
||||
align-items: center;
|
||||
min-width: 680px;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
.ehb-drill-loading__row i {
|
||||
display: block;
|
||||
height: 11px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(100deg, #edf2f8 20%, #f8fafc 42%, #e7eef8 64%);
|
||||
background-size: 220% 100%;
|
||||
animation: ehb-drill-shimmer 1.25s ease-in-out infinite;
|
||||
}
|
||||
.ehb-drill-loading__row i:nth-child(2) { width: 72%; }
|
||||
.ehb-drill-loading__row i:nth-child(3) { width: 58%; }
|
||||
.ehb-drill-loading__row i:nth-child(4) { width: 82%; }
|
||||
.ehb-modal-table-wrap.is-ready > .ehb-modal-table {
|
||||
animation: ehb-drill-content-in .24s ease-out both;
|
||||
}
|
||||
@keyframes ehb-drill-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes ehb-drill-progress {
|
||||
0% { transform: translateX(-110%); opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
80% { opacity: 1; }
|
||||
100% { transform: translateX(370%); opacity: 0; }
|
||||
}
|
||||
@keyframes ehb-drill-shimmer { to { background-position: -220% 0; } }
|
||||
@keyframes ehb-drill-fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes ehb-drill-content-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ehb-drill-loading,
|
||||
.ehb-drill-loading__progress,
|
||||
.ehb-drill-loading__spinner,
|
||||
.ehb-drill-loading__row i,
|
||||
.ehb-modal-table-wrap.is-ready > .ehb-modal-table { animation: none; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item:nth-child(2) { border-right: 0; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item:nth-child(-n + 2) { border-bottom: 1px solid #dfe7f0; }
|
||||
}
|
||||
.ehb-real-drill-filter-summary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.ehb-drill-modal--unified .ehb-modal-head {
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-head__title-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-head__title {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-head__sub {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-head__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-head__actions .mobile-list-fullscreen-trigger {
|
||||
position: static;
|
||||
min-width: 76px;
|
||||
height: 32px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item {
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
|
||||
.ehb-drill-modal--unified .ehb-modal-table td:first-child {
|
||||
width: 235px;
|
||||
min-width: 235px;
|
||||
}
|
||||
|
||||
/* 宽表左右移动时首列保留完整的站点/客户名称、层级三角和同省标识。 */
|
||||
.ehb-drill-modal--unified .ehb-modal-table-wrap { overflow: auto !important; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table { min-width: max-content; }
|
||||
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
|
||||
.ehb-drill-modal--unified .ehb-modal-table td:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
background: #fff;
|
||||
box-shadow: 6px 0 10px -10px #0f172a;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-table thead th:first-child {
|
||||
z-index: 5;
|
||||
background: #f0f3f8;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--station > td:first-child { background: #edf3fa; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--customer > td:first-child { background: #f5f7fa; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row--vehicle > td:first-child { background: #fafbfd; }
|
||||
|
||||
.ehb-real-drill-filter-summary {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #dbe4f1;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-summary > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #2f6bff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-summary strong {
|
||||
overflow: hidden;
|
||||
color: #1e293b;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-summary > svg {
|
||||
transition: transform .18s ease;
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-summary > svg.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-panel:not(.is-open) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ehb-real-drill-filter-panel.is-open {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-hint-text {
|
||||
width: 100%;
|
||||
overflow: visible !important;
|
||||
white-space: normal !important;
|
||||
text-overflow: clip !important;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-search-input,
|
||||
.ehb-drill-modal--unified .ehb-modal-search-input input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏横向空间充足时,把摘要和筛选压成一行,优先留高度给下钻表格。 */
|
||||
@media (max-height: 500px) and (orientation: landscape) {
|
||||
.ehb-drill-modal--unified .ehb-modal-head { padding-block: 6px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-head__sub { display: inline; margin-left: 8px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-body { padding: 6px 8px 8px; }
|
||||
.ehb-drill-modal--unified .ehb-drill-root-tabs { margin-bottom: 6px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-bar {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item {
|
||||
padding: 6px 10px;
|
||||
border-right: 1px solid #dfe7f0;
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item:last-child { border-right: 0; }
|
||||
.ehb-drill-modal--unified .ehb-modal-filter-row { margin-bottom: 6px; padding: 6px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-hint-text { display: none; }
|
||||
.ehb-drill-modal--unified .ehb-drill-loading { min-height: 150px; }
|
||||
}
|
||||
|
||||
/* “横屏查看”是页面布局模式:不依赖手机方向锁定。 */
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
|
||||
flex: 0 0 auto;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 7px 8px 8px;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-bar {
|
||||
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-item {
|
||||
padding: 6px 9px;
|
||||
border-right: 1px solid #dfe7f0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-filter-row {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-table-wrap {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-hint-text,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-filter-summary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 下钻路径是导航,不是装饰:允许直接回到任一上级。 */
|
||||
.ehb-drill-breadcrumbs {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 9px 18px;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid #dfe7f0;
|
||||
background: #fff;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.ehb-drill-breadcrumbs::-webkit-scrollbar { display: none; }
|
||||
.ehb-drill-breadcrumb { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 5px; }
|
||||
.ehb-drill-breadcrumb i { color: #9aabc0; font-style: normal; }
|
||||
.ehb-drill-breadcrumb button {
|
||||
max-width: 210px;
|
||||
padding: 4px 7px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #3564a5;
|
||||
font: 650 12px/1.3 var(--bi-font);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ehb-drill-breadcrumb button:not(:disabled):hover { background: #edf4ff; color: #1f5fe0; }
|
||||
.ehb-drill-breadcrumb button[aria-current="page"] {
|
||||
background: #edf3fc;
|
||||
color: #24344e;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.ehb-drill-modal--unified {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-modal-head { flex: 0 0 auto !important; }
|
||||
.ehb-drill-modal--unified .ehb-modal-close-btn { display: none !important; }
|
||||
.ehb-drill-modal--unified .ehb-modal-back-btn {
|
||||
min-width: 108px !important;
|
||||
min-height: 42px !important;
|
||||
justify-content: center;
|
||||
border-color: rgb(255 255 255 / 28%) !important;
|
||||
background: rgb(255 255 255 / 10%) !important;
|
||||
color: #fff !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
.ehb-drill-breadcrumbs {
|
||||
padding: 8px 10px;
|
||||
box-shadow: 0 3px 10px rgb(32 55 89 / 6%);
|
||||
}
|
||||
.ehb-drill-breadcrumb button { max-width: 150px; min-height: 30px; font-size: 11px; }
|
||||
.ehb-drill-modal--unified .ehb-modal-body {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
flex: 1 1 auto !important;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link { font-size: 10px !important; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row { min-height: 58px; }
|
||||
.ehb-drill-modal--unified .ehb-drill-group-row > td:first-child { min-width: 220px !important; }
|
||||
.ehb-drill-modal--unified .ehb-tree-node-title { display: flex; align-items: center; gap: 4px; }
|
||||
.ehb-drill-modal--unified .ehb-tree-node-name {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 240px;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-node-name strong { min-width: 0; overflow-wrap: anywhere; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-row > td { padding: 0 !important; background: #f8fbff; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-content { padding: 8px 14px 10px 38px; border-bottom: 1px solid #dbe7f5; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-content ul { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 5px 10px; margin: 0; padding: 0; list-style: none; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-content li { display: flex; justify-content: space-between; gap: 8px; padding: 5px 7px; color: #31578c; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-all { margin: 8px 7px 0; padding: 3px 0; border: 0; background: transparent; color: #2563eb; font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-all:hover { text-decoration: underline; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-content small { color: #64748b; white-space: nowrap; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) and (orientation: landscape) {
|
||||
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
|
||||
.ehb-drill-modal--unified .ehb-modal-table td:first-child { width: 300px; min-width: 300px; }
|
||||
}
|
||||
|
||||
/* 竖屏宽表保持原方向;表格容器负责横向滚动,绝不旋转整个页面。 */
|
||||
@media (max-width: 767px) and (orientation: portrait) {
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
padding: 0 !important;
|
||||
overflow: auto !important;
|
||||
background: #f4f7fb !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table-wrap {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 240px !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table {
|
||||
min-width: 960px !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table th,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table td {
|
||||
min-width: 112px !important;
|
||||
height: 38px !important;
|
||||
min-height: 38px !important;
|
||||
padding: 6px 8px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table th:first-child,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table td:first-child {
|
||||
width: 190px !important;
|
||||
min-width: 190px !important;
|
||||
max-width: 190px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback::after {
|
||||
right: 8px !important;
|
||||
bottom: 8px !important;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback * {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 原生横屏与旋转兼容统一进入专注阅读状态。 */
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-breadcrumbs,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-root-tabs,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-bar,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-filter-summary,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-filter-row,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-usage-guide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
|
||||
min-height: 42px !important;
|
||||
height: 42px !important;
|
||||
padding: 4px 142px 4px 8px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__sub {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 专注阅读仍是可导航的下钻页面;必须能逐层返回,不能要求先退出宽表。 */
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn {
|
||||
display: inline-flex !important;
|
||||
width: 44px !important;
|
||||
min-width: 44px !important;
|
||||
min-height: 44px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
|
||||
padding: 2px 88px 2px 8px !important;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn > span {
|
||||
display: none;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title-group {
|
||||
display: grid !important;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title-group > div {
|
||||
min-width: 0;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title {
|
||||
max-width: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-primary-actions,
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger {
|
||||
width: 80px !important;
|
||||
min-width: 80px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* 让三角、名称和轻量下钻提示在同一行网格内分配空间;长名称仅在名称格内换行。 */
|
||||
.ehb-drill-modal--unified .ehb-tree-node-title {
|
||||
display: grid !important;
|
||||
grid-template-columns: 28px minmax(0, 1fr) 24px;
|
||||
align-items: start;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-toggle { width: 28px; min-width: 28px; height: 28px; }
|
||||
.ehb-drill-modal--unified .ehb-tree-node-name {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
flex: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-node-name .ehb-tree-node-sub { margin-left: 4px; }
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link {
|
||||
display: inline-grid !important;
|
||||
width: 24px !important;
|
||||
min-width: 24px !important;
|
||||
height: 28px !important;
|
||||
place-items: center;
|
||||
margin: 0 !important;
|
||||
overflow: hidden;
|
||||
color: transparent !important;
|
||||
font-size: 0 !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-tree-drill-link::after { content: "›" !important; color: #2563eb; font-size: 20px; line-height: 1; }
|
||||
|
||||
/* 普通弹层的筛选和宽表入口各占一个网格列;退出宽表后不保留绝对定位层。 */
|
||||
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .ehb-real-drill-primary-actions {
|
||||
position: static !important;
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 1fr) 112px;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .mobile-list-fullscreen-trigger.is-inline {
|
||||
position: static !important;
|
||||
inset: auto !important;
|
||||
width: 112px !important;
|
||||
min-width: 112px !important;
|
||||
min-height: 48px !important;
|
||||
}
|
||||
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .mobile-list-fullscreen-trigger.is-inline::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-primary-actions {
|
||||
position: absolute !important;
|
||||
z-index: 280 !important;
|
||||
top: 5px !important;
|
||||
right: 8px !important;
|
||||
display: block !important;
|
||||
width: 126px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger {
|
||||
position: static !important;
|
||||
display: inline-flex !important;
|
||||
width: 126px !important;
|
||||
min-width: 126px !important;
|
||||
height: 32px !important;
|
||||
min-height: 32px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-body {
|
||||
display: flex !important;
|
||||
min-height: 0 !important;
|
||||
flex: 1 1 auto !important;
|
||||
padding: 5px 6px 6px !important;
|
||||
gap: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-table-wrap {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
flex: 1 1 auto !important;
|
||||
margin: 0 !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
/* Shared drill workspace: navigation stays visible, data owns the remaining height. */
|
||||
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] > .ehb-modal-body {
|
||||
flex: 1 1 0 !important;
|
||||
min-height: 0 !important;
|
||||
padding: 4px 8px !important;
|
||||
gap: 3px !important;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] .ehb-modal-table {
|
||||
width: 100% !important;
|
||||
min-width: 1050px !important;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] .ehb-modal-table th,
|
||||
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] .ehb-modal-table td {
|
||||
padding: 7px 9px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] .ehb-drill-page-controls .ehb-btn {
|
||||
min-height: 32px !important;
|
||||
padding: 4px 10px !important;
|
||||
}
|
||||
.ehb-drill-modal--unified .ehb-inline-child-row > td { background: #f8fbff; border-bottom: 1px solid #e2eaf4; }
|
||||
.ehb-drill-modal--unified .ehb-inline-child-row > td:first-child { background: #f8fbff !important; }
|
||||
.ehb-inline-child-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.ehb-inline-child-name > span { overflow-wrap: anywhere; font-weight: 600; }
|
||||
.ehb-inline-child-name small { display: block; color: #64748b; }
|
||||
.ehb-inline-child-toggle { flex: 0 0 32px; width: 32px; height: 36px; border: 0; border-radius: 6px; color: #2563eb; background: #eaf2ff; cursor: pointer; }
|
||||
.ehb-drill-modal--unified .ehb-inline-child-status > td,
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-row > td { padding: 8px 16px !important; background: #f8fbff; }
|
||||
.ehb-drill-modal--unified .ehb-tree-expanded-all { border: 0; background: transparent; color: #2563eb; font-size: 12px; font-weight: 600; cursor: pointer; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title-group {
|
||||
min-width: 0;
|
||||
gap: 14px;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title-group > div { min-width: 0; }
|
||||
@media (max-width: 767px) {
|
||||
.ehb-modal-card.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) > .ehb-modal-head {
|
||||
min-height: 68px;
|
||||
padding: 10px 12px !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-back-btn {
|
||||
width: 44px !important;
|
||||
min-width: 44px !important;
|
||||
height: 44px !important;
|
||||
min-height: 44px !important;
|
||||
flex: 0 0 44px;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 10px;
|
||||
background: rgb(255 255 255 / 6%) !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-back-btn > span { display: none; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title {
|
||||
font-size: 16px !important;
|
||||
line-height: 1.4 !important;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__sub {
|
||||
margin-top: 3px;
|
||||
font-size: 11px !important;
|
||||
line-height: 1.5;
|
||||
color: #a7b6cc;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-table-wrap > table > thead > tr > th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: #f0f3f8;
|
||||
box-shadow: inset 0 -1px 0 #d7e1ed;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-table-wrap > table > thead > tr > th:first-child {
|
||||
z-index: 11;
|
||||
}
|
||||
.ehb-modal-overlay .ehb-modal-card.ehb-drill-modal--unified {
|
||||
width: 96vw !important;
|
||||
max-width: 1800px !important;
|
||||
height: 94dvh !important;
|
||||
max-height: 94dvh !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-head {
|
||||
padding: 10px 16px !important;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified > .ehb-drill-breadcrumbs { padding: 5px 16px; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-body {
|
||||
flex: 1 1 auto !important;
|
||||
min-height: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 6px !important;
|
||||
padding: 8px 16px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-body > * { flex: 0 0 auto; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-body > .ehb-modal-hint-text {
|
||||
flex: 0 0 auto !important;
|
||||
min-width: 0;
|
||||
margin: 0 !important;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-summary > span {
|
||||
display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-drill-root-tabs { margin: 0 !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-bar { margin: 0 !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-item { padding: 6px 12px !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-val { font-size: 18px !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-primary-actions {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-summary {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 40px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #d7e1ed;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
color: #334155;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-summary strong {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-panel:not(.is-open) { display: none !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-panel.is-open {
|
||||
display: flex !important;
|
||||
max-height: 30dvh;
|
||||
overflow: auto;
|
||||
margin: 0 !important;
|
||||
padding: 8px !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-drill-usage-guide { font-size: 12px; margin: 0; display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-body > .ehb-modal-table-wrap {
|
||||
flex: 1 1 0 !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
margin: 0 !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-drill-page-controls { margin-top: 0 !important; padding: 0; }
|
||||
@media (max-width: 767px), (max-height: 500px) {
|
||||
.ehb-modal-overlay .ehb-modal-card.ehb-drill-modal--unified {
|
||||
width: 100vw !important; height: 100dvh !important; max-height: 100dvh !important;
|
||||
border-radius: 0 !important; margin: 0 !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-head { padding: 6px 10px !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-body {
|
||||
padding: 6px 8px max(6px, env(safe-area-inset-bottom)) !important; gap: 4px !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-bar {
|
||||
display: flex !important; flex-wrap: nowrap !important; overflow-x: auto !important;
|
||||
}
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-item { flex: 0 0 auto; width: auto !important; min-width: 120px; padding: 5px 9px !important; }
|
||||
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-val { font-size: 16px !important; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
test("能源氢费 BI 入口与独立验收地址复用同一看板", () => {
|
||||
const entry = readFileSync(new URL("../HydrogenModule.tsx", import.meta.url), "utf8");
|
||||
const app = readFileSync(new URL("../../../App.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(entry, /vendor\/lnbi-8113-exact\/prototypes\/energy-h2-bi-board\/EnergyBiBoardApp/);
|
||||
assert.match(entry, /return <EnergyBiBoardApp \/>/);
|
||||
assert.match(app, /vendor\/lnbi-8113-exact\/prototypes\/energy-h2-bi-board\/EnergyBiBoardApp/);
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createServer } from "vite";
|
||||
import type { H2BiOverviewResponse } from "./types";
|
||||
|
||||
const point = (month: string, totalKg: number) => ({ month, totalKg }) as H2BiOverviewResponse["monthly"][number];
|
||||
|
||||
test("月度环比使用最近两个有效月份且不伪造不可用值", async () => {
|
||||
const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", optimizeDeps: { noDiscovery: true } });
|
||||
try {
|
||||
const { monthlyChange } = await vite.ssrLoadModule("/src/modules/energy/hydrogen-bi-v2/EnergyOperationsBoard.tsx");
|
||||
assert.deepEqual(monthlyChange([point("2026-06", 80), point("2026-08", 120), point("2026-07", 100)]), { value: "+20.0%", detail: "8月较7月" });
|
||||
assert.deepEqual(monthlyChange([point("2026-07", 0), point("2026-08", 120)]), { value: "—", detail: "暂不可用" });
|
||||
assert.deepEqual(monthlyChange([point("2026-08", 120)]), { value: "—", detail: "暂不可用" });
|
||||
assert.deepEqual(monthlyChange([point("2026-07", 100), point("2026-08", Number.NaN)]), { value: "—", detail: "暂不可用" });
|
||||
} finally {
|
||||
await vite.close();
|
||||
}
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,766 +0,0 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Download, RefreshCw, Truck } from "lucide-react";
|
||||
import { DailyTreeButton, DailyBranchState } from "./daily-detail-controls";
|
||||
import { dailySummaryRows, formatDailyChange } from "./daily-detail-format";
|
||||
import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiDrill } from "./api";
|
||||
import { downloadExcelAoa } from "./prototype-download";
|
||||
import "./real-daily-mobile.css";
|
||||
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,
|
||||
stationId = null,
|
||||
onRefresh,
|
||||
refreshToken = 0,
|
||||
onLoadingChange,
|
||||
}: {
|
||||
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";
|
||||
stationId?: string | number | null;
|
||||
onRefresh: () => void;
|
||||
refreshToken?: number;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
}) {
|
||||
const query = useMemo<H2BiQuery>(
|
||||
() => ({
|
||||
year: Number(startDate.slice(0, 4)),
|
||||
startDate,
|
||||
endDate,
|
||||
vehicleScope: toScope(fleetScope),
|
||||
verifyScope,
|
||||
stationId,
|
||||
}),
|
||||
[endDate, fleetScope, startDate, stationId, verifyScope],
|
||||
);
|
||||
const [daily, setDaily] = useState<H2BiDailyResponse | null>(null);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
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 [detailMode, setDetailMode] = useState<"key" | "full">("key");
|
||||
const [branchErrors, setBranchErrors] = useState<Record<string, string>>({});
|
||||
const requestGeneration = useRef(0);
|
||||
const pendingBranches = useRef(new Set<string>());
|
||||
const tableWrapRef = useRef<HTMLDivElement>(null);
|
||||
const dateRowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
requestGeneration.current += 1;
|
||||
pendingBranches.current.clear();
|
||||
setBranchErrors({});
|
||||
let finishTimer: number | undefined;
|
||||
const loadingStartedAt = Date.now();
|
||||
setIsLoading(true);
|
||||
onLoadingChange?.(true);
|
||||
// The selected range is a new data contract. Clear the former response so
|
||||
// an API error can never be mistaken for fresh data or business zeroes.
|
||||
setDaily(null);
|
||||
setError(null);
|
||||
setTrees({});
|
||||
setExpandedDate(null);
|
||||
setExpandedStation({});
|
||||
setExpandedCustomer({});
|
||||
setExpandedStationLists({});
|
||||
setExpandedCustomerLists({});
|
||||
setExpandedRecordLists({});
|
||||
setCustomerRecords({});
|
||||
void fetchH2BiDaily(query)
|
||||
.then((result) => alive && setDaily(result))
|
||||
.catch(
|
||||
(reason: unknown) => {
|
||||
if (!alive) return;
|
||||
setDaily(null);
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "按日数据加载失败",
|
||||
);
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
if (!alive) return;
|
||||
const remaining = Math.max(0, 500 - (Date.now() - loadingStartedAt));
|
||||
finishTimer = window.setTimeout(() => {
|
||||
if (!alive) return;
|
||||
setIsLoading(false);
|
||||
onLoadingChange?.(false);
|
||||
}, remaining);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
requestGeneration.current += 1;
|
||||
if (finishTimer !== undefined) window.clearTimeout(finishTimer);
|
||||
};
|
||||
}, [query, reloadToken, refreshToken, onLoadingChange]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setReloadToken((value) => value + 1);
|
||||
onRefresh();
|
||||
};
|
||||
const ensureDateTree = (date: string) => {
|
||||
if (trees[date] || pendingBranches.current.has(date)) return;
|
||||
const generation = requestGeneration.current;
|
||||
pendingBranches.current.add(date);
|
||||
setBranchErrors((items) => ({ ...items, [date]: "" }));
|
||||
void fetchH2BiDailyTree(date, {
|
||||
vehicleScope: query.vehicleScope,
|
||||
verifyScope,
|
||||
stationId,
|
||||
}).then((tree) => {
|
||||
if (generation === requestGeneration.current) setTrees((items) => ({ ...items, [date]: tree }));
|
||||
}).catch(() => {
|
||||
if (generation === requestGeneration.current) setBranchErrors((items) => ({ ...items, [date]: "站点明细加载失败,请重试" }));
|
||||
}).finally(() => {
|
||||
if (generation === requestGeneration.current) pendingBranches.current.delete(date);
|
||||
});
|
||||
};
|
||||
const openDate = (date: string, scrollIntoDate = false) => {
|
||||
setExpandedDate(date);
|
||||
ensureDateTree(date);
|
||||
if (!scrollIntoDate) return;
|
||||
setHighlightedDate(date);
|
||||
window.setTimeout(() => {
|
||||
dateRowRefs.current[date]?.scrollIntoView({
|
||||
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "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 loadCustomer = (
|
||||
date: string,
|
||||
stationId: string | number,
|
||||
customerId: number,
|
||||
) => {
|
||||
const key = `${date}:${stationId}:${customerId}`;
|
||||
if (customerRecords[key] || pendingBranches.current.has(key)) return;
|
||||
const generation = requestGeneration.current;
|
||||
pendingBranches.current.add(key);
|
||||
setBranchErrors((items) => ({ ...items, [key]: "" }));
|
||||
void fetchH2BiDrill({
|
||||
...query,
|
||||
date,
|
||||
stationId,
|
||||
customerId,
|
||||
groupBy: "record",
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
}).then((result) => {
|
||||
if (generation === requestGeneration.current) setCustomerRecords((items) => ({ ...items, [key]: result }));
|
||||
}).catch(() => {
|
||||
if (generation === requestGeneration.current) setBranchErrors((items) => ({ ...items, [key]: "车辆明细加载失败,请重试" }));
|
||||
}).finally(() => {
|
||||
if (generation === requestGeneration.current) pendingBranches.current.delete(key);
|
||||
});
|
||||
};
|
||||
const toggleCustomer = (date: string, station: string | number, customer: number) => {
|
||||
const key = `${date}:${station}:${customer}`;
|
||||
setExpandedCustomer((items) => ({ ...items, [key]: !items[key] }));
|
||||
loadCustomer(date, station, customer);
|
||||
};
|
||||
const exportRows = () => {
|
||||
if (!daily) return;
|
||||
downloadExcelAoa(
|
||||
dailySummaryRows(daily),
|
||||
`每日加氢日期汇总_${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">
|
||||
{isLoading && !daily ? (
|
||||
<div className="ehb-live-data-state is-loading" role="status" aria-live="polite">
|
||||
<span className="ehb-live-data-spinner" aria-hidden />
|
||||
<strong>正在读取真实日期统计</strong>
|
||||
<span>加载完成前不展示业务零值</span>
|
||||
</div>
|
||||
) : null}
|
||||
<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={handleRefresh}
|
||||
disabled={isLoading}
|
||||
aria-busy={isLoading}
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? "is-spinning" : ""} />
|
||||
{isLoading ? "加载中…" : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{error ? <div className="ehb-empty">{error}</div> : null}
|
||||
{daily ? <>
|
||||
<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>
|
||||
</> : null}
|
||||
<section className="ehb-daily-table-card ehb-real-daily-detail" data-detail-mode={detailMode}>
|
||||
<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}
|
||||
disabled={!daily || isLoading || !daily.days.length}
|
||||
title="导出所选区间的全部日期汇总,不含客户和车辆流水"
|
||||
>
|
||||
<Download size={14} />
|
||||
导出日期汇总
|
||||
</button>
|
||||
</div>
|
||||
<div className="ehb-daily-detail-toolbar">
|
||||
<div className="ehb-daily-view-switch" role="group" aria-label="明细显示方式">
|
||||
{([['key', '重点指标'], ['full', '完整表格']] as const).map(([mode, label]) =>
|
||||
<button type="button" key={mode} aria-pressed={detailMode === mode} onClick={() => {
|
||||
setDetailMode(mode);
|
||||
if (tableWrapRef.current) tableWrapRef.current.scrollLeft = 0;
|
||||
}}>{label}</button>)}
|
||||
</div>
|
||||
<label className="ehb-daily-date-jump">
|
||||
<span>定位日期</span>
|
||||
<select value={expandedDate ?? ""} disabled={!daily?.days.length} onChange={(event) => {
|
||||
if (event.target.value) openDate(event.target.value, true);
|
||||
}}>
|
||||
<option value="">选择日期</option>
|
||||
{(daily?.days ?? []).map(day => <option key={day.date} value={day.date}>{day.date}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="ehb-daily-collapse" disabled={!expandedDate} onClick={() => {
|
||||
setExpandedDate(null); setExpandedStation({}); setExpandedCustomer({});
|
||||
setExpandedStationLists({}); setExpandedCustomerLists({}); setExpandedRecordLists({});
|
||||
}}>全部收起</button>
|
||||
</div>
|
||||
<div
|
||||
className="ehb-h5-scroll-hint ehb-daily-table-scroll-hint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{detailMode === "key" ? "点击名称逐层查看:日期 → 站点 → 客户 → 车辆" : "首列已固定 · 左右滑动查看全部指标"}
|
||||
</div>
|
||||
{!daily ? <div className="ehb-daily-detail-empty" role={error ? "alert" : "status"}>
|
||||
{error ? <>明细暂时无法加载<button type="button" onClick={handleRefresh}>重新加载</button></> : "正在加载日期明细…"}
|
||||
</div> : !daily.days.length ? <div className="ehb-daily-detail-empty" role="status">所选日期和车辆范围内暂无加氢记录,请调整筛选条件。</div> :
|
||||
<div ref={tableWrapRef} className="ehb-table-wrap" role="region" aria-label="每日加氢明细,可左右滚动" tabIndex={0}>
|
||||
<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={{
|
||||
background: open ? "#f0f9ff" : undefined,
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<DailyTreeButton open={open} label={`${day.date}加氢站明细`} onClick={() => toggleDate(day.date)}>
|
||||
{day.date}{" "}
|
||||
<span className="ehb-title-sub">
|
||||
({day.stationCount ?? 0} 个加氢站)
|
||||
</span>
|
||||
</DailyTreeButton>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>{format(day.kg)}</td>
|
||||
<td>
|
||||
<strong className="ehb-daily-cost">{format(day.cost)}</strong>
|
||||
<small className="ehb-daily-change">{formatDailyChange((day as { chainPct?: number }).chainPct)}</small>
|
||||
</td>
|
||||
<td>暂无来源</td>
|
||||
</tr>
|
||||
{open && (!tree || tree.stations.length === 0) ? <DailyBranchState
|
||||
columns={detailMode === "key" ? 3 : 5} error={branchErrors[day.date]}
|
||||
empty={!!tree} onRetry={() => ensureDateTree(day.date)} /> : null}
|
||||
{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={{
|
||||
background: "#f8fafc",
|
||||
}}
|
||||
>
|
||||
<td className="ehb-tree-cell-l1">
|
||||
<DailyTreeButton open={stationOpen} label={`${station.name}客户明细`} onClick={() =>
|
||||
setExpandedStation(items => ({ ...items, [stationKey]: !items[stationKey] }))}>
|
||||
<small className="ehb-daily-level-label">加氢站</small>{station.name}
|
||||
</DailyTreeButton>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>{format(station.kg)}</td>
|
||||
<td>¥{format(station.cost)}</td>
|
||||
<td>暂无来源</td>
|
||||
</tr>
|
||||
{stationOpen && station.customers.length === 0 ? <DailyBranchState
|
||||
columns={detailMode === "key" ? 3 : 5} empty onRetry={() => {}} /> : null}
|
||||
{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>
|
||||
<td className="ehb-tree-cell-l2">
|
||||
<DailyTreeButton open={customerOpen} label={`${customer.name}车辆明细`}
|
||||
onClick={() => toggleCustomer(day.date, station.id, customer.id)}>
|
||||
<small className="ehb-daily-level-label">客户</small>{customer.name}{" "}
|
||||
<span className="ehb-title-sub">
|
||||
({customer.recordCount} 笔)
|
||||
</span>
|
||||
</DailyTreeButton>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>{format(customer.kg)}</td>
|
||||
<td>¥{format(customer.cost)}</td>
|
||||
<td>点击查看真实流水</td>
|
||||
</tr>
|
||||
{customerOpen && (!customerRecords[customerKey] || allRecords.length === 0) ? <DailyBranchState
|
||||
columns={detailMode === "key" ? 3 : 5} error={branchErrors[customerKey]}
|
||||
empty={!!customerRecords[customerKey]} onRetry={() => loadCustomer(day.date, station.id, customer.id)} /> : null}
|
||||
{customerOpen &&
|
||||
records.map((record) => (
|
||||
<tr key={String(record.id)}>
|
||||
<td className="ehb-tree-cell-l3">
|
||||
<small className="ehb-daily-level-label">车辆 · {String(record.time || "—").slice(11, 16)}</small>
|
||||
<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={detailMode === "key" ? 3 : 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={detailMode === "key" ? 3 : 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={detailMode === "key" ? 3 : 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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,73 +0,0 @@
|
||||
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
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
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
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* @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 />);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"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
@@ -1,64 +0,0 @@
|
||||
/** 能源 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: '调拨',
|
||||
};
|
||||
@@ -1,905 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
/* Scoped to the live daily table; keep the other boards and drill dialogs intact. */
|
||||
.ehb-real-daily-detail {
|
||||
min-width: 0;
|
||||
}
|
||||
.ehb-daily-detail-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.ehb-daily-view-switch {
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 9px;
|
||||
}
|
||||
.ehb-real-daily-detail button,
|
||||
.ehb-real-daily-detail select { font: inherit; }
|
||||
.ehb-daily-view-switch button,
|
||||
.ehb-daily-collapse,
|
||||
.ehb-daily-branch-state button,
|
||||
.ehb-daily-detail-empty button {
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ehb-daily-view-switch button[aria-pressed="true"] {
|
||||
background: #fff;
|
||||
color: #1d4ed8;
|
||||
box-shadow: 0 1px 4px #0f172a14;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ehb-daily-date-jump { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #64748b; }
|
||||
.ehb-daily-date-jump select { min-height: 40px; border: 1px solid #e2e8f0; border-radius: 7px; padding: 6px 8px; background: #fff; color: #334155; }
|
||||
.ehb-real-daily-detail button:disabled { opacity: .45; cursor: default; }
|
||||
.ehb-real-daily-detail :is(button, select, [tabindex]):focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
|
||||
.ehb-real-daily-detail .ehb-daily-disclosure {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ehb-daily-disclosure > svg { flex-shrink: 0; margin-top: 3px; color: #64748b; }
|
||||
.ehb-daily-disclosure > span { min-width: 0; }
|
||||
.ehb-daily-disclosure[aria-expanded="true"] { font-weight: 600; }
|
||||
.ehb-daily-disclosure[aria-expanded="true"] > svg { color: #2563eb; }
|
||||
.ehb-daily-level-label,
|
||||
.ehb-daily-change { display: block; font-size: 11px; font-weight: 400; color: #64748b; line-height: 1.6; }
|
||||
.ehb-daily-cost { font-weight: 500; }
|
||||
.ehb-daily-branch-state > td > div { display: flex; align-items: center; gap: 8px; }
|
||||
.ehb-daily-branch-state button { display: inline-flex; align-items: center; gap: 5px; color: #1d4ed8; background: #eff6ff; }
|
||||
.ehb-daily-detail-empty { padding: 24px 16px; text-align: center; color: #64748b; font-size: 13px; }
|
||||
.ehb-daily-detail-empty button { display: block; margin: 8px auto 0; color: #1d4ed8; }
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table { min-width: 0; }
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td):is(:nth-child(2), :nth-child(5)) { display: none; }
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td):first-child:not([colspan]) { width: 36%; }
|
||||
.ehb-real-daily-detail .ehb-table td:not(:first-child) { text-align: right; }
|
||||
.ehb-real-daily-detail .ehb-table th:not(:first-child) { text-align: right; }
|
||||
.ehb-real-daily-detail .ehb-table-wrap {
|
||||
isolation: isolate;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table {
|
||||
table-layout: fixed;
|
||||
min-width: 940px;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table tr {
|
||||
background-color: #fff;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table th:first-child,
|
||||
.ehb-real-daily-detail .ehb-table td:first-child:not([colspan]) {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
width: 260px;
|
||||
background-color: inherit;
|
||||
box-shadow: 1px 0 0 #e2e8f0, 5px 0 8px -6px #64748b;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
/* Give nested day → station → customer → vehicle rows a viewport-sized workspace. */
|
||||
.ehb-real-daily-detail.ehb-daily-table-card > .ehb-table-wrap {
|
||||
height: 80vh;
|
||||
height: 80dvh;
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ehb-real-daily-detail .ehb-table th:first-child {
|
||||
z-index: 4;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table th {
|
||||
z-index: 3;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table td:not(:first-child) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 767px), (max-width: 1024px) and (max-height: 500px) {
|
||||
.ehb-real-daily-detail.ehb-daily-table-card {
|
||||
padding: 12px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-daily-table-head {
|
||||
padding: 0 12px;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-daily-table-title {
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-daily-table-title .ehb-title-sub {
|
||||
display: none;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-export-btn {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
min-height: 44px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-daily-detail-toolbar { padding: 0 12px; gap: 8px; }
|
||||
.ehb-real-daily-detail .ehb-daily-view-switch { width: 100%; }
|
||||
.ehb-real-daily-detail .ehb-daily-view-switch button { flex: 1; min-height: 44px; }
|
||||
.ehb-real-daily-detail .ehb-daily-date-jump { flex: 1; }
|
||||
.ehb-real-daily-detail .ehb-daily-date-jump select { min-height: 44px; }
|
||||
.ehb-real-daily-detail .ehb-daily-collapse { min-height: 44px; padding: 6px; font-size: 12px; }
|
||||
.ehb-real-daily-detail .ehb-daily-table-scroll-hint {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table-wrap {
|
||||
max-height: 65dvh;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table {
|
||||
min-width: 760px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table th:first-child,
|
||||
.ehb-real-daily-detail .ehb-table td:first-child:not([colspan]) {
|
||||
width: 142px;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table td {
|
||||
height: 48px;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.5;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table { min-width: 0; width: 100%; }
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td) { padding: 10px 8px; }
|
||||
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table td:not(:first-child) { font-size: 12px; overflow-wrap: anywhere; }
|
||||
.ehb-real-daily-detail .ehb-table td:first-child:has(.ehb-daily-disclosure) { padding-top: 4px; padding-bottom: 4px; }
|
||||
.ehb-real-daily-detail .ehb-table th {
|
||||
padding: 10px 12px;
|
||||
white-space: normal;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table td:first-child .ehb-title-sub {
|
||||
display: block;
|
||||
margin-left: 0;
|
||||
}
|
||||
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l1 { padding-left: 16px; }
|
||||
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l2 { padding-left: 22px; }
|
||||
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l3 { padding-left: 28px; }
|
||||
.ehb-real-daily-detail .ehb-daily-record-tag {
|
||||
display: inline-block;
|
||||
margin: 2px 3px 0 0;
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
export type H2BiScope = "global" | "station";
|
||||
export type H2BiView = "overview" | "daily";
|
||||
export type H2BiVehicleScope = "all" | "lingniu" | "external";
|
||||
export type H2BiVerifyScope = "all" | "verified" | "unverified";
|
||||
/** 账本 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 {
|
||||
/** Distinct ledger settlement types for this group; never the UI filter. */
|
||||
settlementTypes?: string | null;
|
||||
[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;
|
||||
/** Number of rows returned on this page (groups or records). */
|
||||
itemCount?: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** Options shared by paged drill reads and explicit full exports. */
|
||||
export interface H2BiDrillReadOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* A deliberately bounded, complete drill read. This is for exports and
|
||||
* dedicated detail views only; normal drill tables should use one page.
|
||||
*/
|
||||
export interface H2BiFullDrillResponse extends H2BiDrillResponse {
|
||||
page: H2BiDrillResponse["page"] & {
|
||||
pagesRead: number;
|
||||
complete: true;
|
||||
};
|
||||
}
|
||||
|
||||
export interface H2BiFullDrillOptions extends H2BiDrillReadOptions {
|
||||
/** Protect the browser from an accidental unbounded export. Default: 250. */
|
||||
maxPages?: number;
|
||||
/** Reject instead of retaining an unexpectedly huge complete result. Default: 50000. */
|
||||
maxRows?: number;
|
||||
/** API page size, capped by the server at 200. Default: 200. */
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
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"
|
||||
>;
|
||||
@@ -1,375 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,8 @@ 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';
|
||||
@@ -28,43 +24,6 @@ test('快捷日期继续使用本地自然日并覆盖本周、本月和近15天
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
@@ -79,26 +38,23 @@ test('每日加氢统计保持排序、有效天、站点去重和峰谷口径',
|
||||
{
|
||||
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 }],
|
||||
stations: [{ name: 'A站', kg: 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 }],
|
||||
stations: [{ name: 'A站', kg: 100, 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 }],
|
||||
stations: [{ name: 'B站', kg: 300, pricePerKg: 25, chainPct: 2 }],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -106,10 +62,8 @@ test('每日加氢统计保持排序、有效天、站点去重和峰谷口径',
|
||||
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');
|
||||
@@ -120,41 +74,11 @@ 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);
|
||||
});
|
||||
|
||||
@@ -14,10 +14,9 @@ export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
|
||||
// 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。
|
||||
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 stationNames = new Set<string>();
|
||||
source.forEach(row => row.stations.forEach(station => stationNames.add(station.name)));
|
||||
const peakDay = trendData.reduce<HydrogenDailyRow | null>(
|
||||
(best, item) => (!best || item.totalKg > best.totalKg ? item : best),
|
||||
null,
|
||||
@@ -32,177 +31,11 @@ export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
|
||||
return {
|
||||
trendData,
|
||||
totalKg,
|
||||
totalFee,
|
||||
activeDays,
|
||||
stationCount: stationIds.size,
|
||||
stationCount: stationNames.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));
|
||||
}
|
||||
|
||||
@@ -10,16 +10,7 @@ import {
|
||||
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';
|
||||
import type { HydrogenRegionShare, HydrogenStationTop } from '../../types';
|
||||
|
||||
const REGION_COLORS = [
|
||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||
@@ -51,123 +42,70 @@ function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
||||
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}` : '';
|
||||
export function DistributionCharts({ top5, regions, yearKg }: DistributionChartsProps) {
|
||||
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 className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<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>
|
||||
<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);
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={180}
|
||||
dataKey="name"
|
||||
width={188}
|
||||
tick={<RankYAxisTick />}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<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`, '加氢量']}
|
||||
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 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>
|
||||
<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>
|
||||
|
||||
<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="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%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={visibleRegions}
|
||||
data={regions}
|
||||
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) => (
|
||||
{regions.map((_, i) => (
|
||||
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
@@ -180,22 +118,16 @@ export function DistributionCharts({ top5, regions, stations, yearKg, onSelectSt
|
||||
</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">
|
||||
{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>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
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';
|
||||
import { AlertTriangle, Building2, Gauge } from 'lucide-react';
|
||||
import type { HydrogenMonthlyPoint } from '../../types';
|
||||
import { formatKg as fmtKg } from '../model';
|
||||
|
||||
interface InsightCardsProps {
|
||||
monthAvgKg: number;
|
||||
@@ -15,16 +8,13 @@ interface InsightCardsProps {
|
||||
latestMonth: HydrogenMonthlyPoint | undefined;
|
||||
monthMomentum: number | null;
|
||||
top5Share: number;
|
||||
customerGrossMarginPct: number;
|
||||
profitYield: 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({
|
||||
@@ -33,144 +23,66 @@ export function InsightCards({
|
||||
latestMonth,
|
||||
monthMomentum,
|
||||
top5Share,
|
||||
customerGrossMarginPct,
|
||||
profitYield,
|
||||
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 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="ehb-insight__title">月度加氢异常波动</div>
|
||||
<div className="ehb-insight__value is-neg">
|
||||
<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 className="ehb-insight__desc">
|
||||
</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>
|
||||
|
||||
<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 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="ehb-insight__title">客户单毛利率</div>
|
||||
<div className={`ehb-insight__value ${customerGrossMarginPct >= 0 ? 'is-pos' : 'is-neg'}`}>
|
||||
{customerGrossMarginPct.toFixed(1)}%
|
||||
<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>
|
||||
<div className="ehb-insight__desc">
|
||||
客户单毛利 {yearProfitValue}{yearProfitUnit} · 客户收入 {yearRevenueValue}{yearRevenueUnit}
|
||||
{customerGrossMarginPct < 0 ? ' · 需关注客户价格与站点成本' : ' · 当前客户单保持正毛利'}
|
||||
<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">
|
||||
共 {stationCount} 站 · 单站年均 {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">
|
||||
时享获利 {yearProfitValue}{yearProfitUnit} · 客户收入 {yearRevenueValue}{yearRevenueUnit}
|
||||
{profitYield < 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,76 +1,43 @@
|
||||
import { Activity, Fuel, Search, Truck, Wallet, Zap } from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { CalendarDays, Fuel, Sparkles, TrendingUp, Wallet } from 'lucide-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';
|
||||
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
|
||||
|
||||
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;
|
||||
rows: { label: string; value: string; valueClass?: string }[];
|
||||
accentClass: string;
|
||||
iconBg: string;
|
||||
}
|
||||
|
||||
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;
|
||||
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
|
||||
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 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>
|
||||
<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>}
|
||||
<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 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);
|
||||
export function KpiSection({ kpi: k }: { kpi: HydrogenKpi }) {
|
||||
const yearKgFmt = fmtKg(k.yearKg);
|
||||
const yearFeeFmt = fmtYuan(k.yearFee);
|
||||
const yearProfitFmt = fmtYuan(k.yearProfit);
|
||||
@@ -83,22 +50,65 @@ export function KpiSection({ kpi: k, scope = 'global', scopeLabel, onDrillReques
|
||||
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));
|
||||
};
|
||||
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
|
||||
|
||||
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 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>
|
||||
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
LabelList,
|
||||
Cell,
|
||||
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';
|
||||
import { formatYuan as fmtYuan } from '../model';
|
||||
|
||||
type MonthlyChartPoint = HydrogenMonthlyPoint & { monthLabel: string };
|
||||
|
||||
@@ -25,36 +17,19 @@ 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}` : '';
|
||||
export function MonthlyCharts({ activeYear, monthly, monthlyDual }: MonthlyChartsProps) {
|
||||
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 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>
|
||||
<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">
|
||||
<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' }}
|
||||
@@ -69,29 +44,30 @@ export function MonthlyCharts({ activeYear, monthly, monthlyDual, scope = 'globa
|
||||
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 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>
|
||||
</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 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>
|
||||
<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">
|
||||
<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' }}
|
||||
@@ -114,14 +90,12 @@ export function MonthlyCharts({ activeYear, monthly, monthlyDual, scope = 'globa
|
||||
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} />
|
||||
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,174 +1,53 @@
|
||||
import { ChevronDown, RefreshCw, Truck } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { HydrogenVehicleScope } from '../../api';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
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;
|
||||
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,
|
||||
lastRefreshAt,
|
||||
refreshing,
|
||||
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">
|
||||
<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 (
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-pill-btn ${verifyScope === 'all' ? 'is-active' : ''}`}
|
||||
onClick={() => onVerifyScopeChange?.('all')}
|
||||
key={y}
|
||||
onClick={() => onSelectYear(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'
|
||||
}`}
|
||||
>
|
||||
全量订单
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-pill-btn ${verifyScope === 'verified' ? 'is-active' : ''}`}
|
||||
onClick={() => onVerifyScopeChange?.('verified')}
|
||||
title="真实账本已返回全量有效订单"
|
||||
>
|
||||
仅已核对
|
||||
{y}
|
||||
</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="数据刷新"
|
||||
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={14} className={refreshing ? 'animate-spin' : ''} aria-hidden />
|
||||
刷新
|
||||
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
|
||||
<span className="text-[11px] font-bold">刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,116 +1,55 @@
|
||||
import type { HydrogenCustomerRow, HydrogenStationFull } from '../../types';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronRight, Search } from 'lucide-react';
|
||||
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
|
||||
import {
|
||||
buildCustomerDrillPayload,
|
||||
buildStationDrillPayload,
|
||||
formatKg as fmtKg,
|
||||
formatYuan as fmtYuan,
|
||||
type OverviewDrillPayload,
|
||||
type OverviewDrillRequest,
|
||||
type OverviewScope,
|
||||
} from '../model';
|
||||
import { OverviewDrillDialog } from './OverviewDrillDialog';
|
||||
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
|
||||
|
||||
export function StationSummaryTable({
|
||||
stations,
|
||||
onSelectStation,
|
||||
scope = 'global',
|
||||
scopeLabel,
|
||||
onDrillRequest,
|
||||
}: {
|
||||
stations: HydrogenStationFull[];
|
||||
onSelectStation?: (stationId: number) => void;
|
||||
scope?: OverviewScope;
|
||||
scopeLabel?: string | null;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}) {
|
||||
const [province, setProvince] = useState('all');
|
||||
const [sortKey, setSortKey] = useState<'name' | 'province' | 'kg' | 'share' | 'revenue' | 'revenueShare'>('kg');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
const provinces = useMemo(() => [...new Set(stations.map(station => station.province?.trim()).filter(Boolean) as string[])], [stations]);
|
||||
const filteredStations = province === 'all'
|
||||
? stations
|
||||
: stations.filter(station => station.province === province);
|
||||
const sortedStations = useMemo(() => sortBy(filteredStations, sortKey, sortDirection, (station, key) => station[key]), [filteredStations, sortDirection, sortKey]);
|
||||
const changeSort = (nextKey: typeof sortKey) => {
|
||||
const next = toggleSort(sortKey, sortDirection, nextKey);
|
||||
setSortKey(next.key);
|
||||
setSortDirection(next.direction);
|
||||
};
|
||||
const openStation = (station: HydrogenStationFull) => {
|
||||
setSelectedStationId(station.id);
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
|
||||
else setDrill(buildStationDrillPayload(station));
|
||||
};
|
||||
export function StationSummaryTable({ stations }: { stations: HydrogenStationFull[] }) {
|
||||
return (
|
||||
<>
|
||||
{stations.length > 0 && (
|
||||
<div className="ehb-sum-table-card">
|
||||
<div className="ehb-sum-table-card__head" style={{ flexWrap: 'wrap', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="ehb-sum-table-card__title"><Search size={14} className="text-sky-600" />加氢站加氢汇总{scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : ''}</div>
|
||||
<div className="ehb-mini-tabs">
|
||||
<button type="button" onClick={() => setProvince('all')} className={`ehb-mini-tab ${province === 'all' ? 'is-active' : ''}`}>全国</button>
|
||||
{provinces.map(item => <button key={item} type="button" onClick={() => setProvince(item)} className={`ehb-mini-tab ${province === item ? 'is-active' : ''}`}>{item}</button>)}
|
||||
<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>
|
||||
<div className="ehb-sum-table-card__meta">
|
||||
统计范围内共 {stations.length} 站
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ehb-sum-table-wrap">
|
||||
<table className="ehb-sum-table">
|
||||
<div className="overflow-x-auto -mx-1 px-1">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-idx">#</th>
|
||||
<th style={{ textAlign: 'left' }}><SortableColumnHeader label="加氢站" sortKey="name" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
||||
<th style={{ textAlign: 'left' }}><SortableColumnHeader label="所属省份" sortKey="province" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
||||
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="加氢量" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="占比" sortKey="share" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="氢费收入" sortKey="revenue" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="收入占比" sortKey="revenueShare" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
<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>
|
||||
{sortedStations.map((s, i) => {
|
||||
{stations.map((s, i) => {
|
||||
const kgFmt = fmtKg(s.kg);
|
||||
const revFmt = fmtYuan(s.revenue);
|
||||
return (
|
||||
<tr key={s.name + i} onClick={() => openStation(s)} style={{ cursor: 'pointer' }} title="查看加氢站汇总明细">
|
||||
<td className="col-idx">{i + 1}</td>
|
||||
<td style={{ fontWeight: 600, color: '#0284c7' }}>
|
||||
{s.name} <span style={{ fontSize: 11, fontWeight: 400, opacity: 0.8 }}>钻取 ›</span>
|
||||
<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>
|
||||
<span style={{ fontSize: 11, color: '#0284c7', background: '#eff6ff', padding: '1px 6px', borderRadius: 4, fontWeight: 500 }}>
|
||||
{s.province ?? '未归属'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }} className="col-bold-kg">
|
||||
{kgFmt.value} <span style={{ fontSize: 11, fontWeight: 400, color: '#64748b' }}>{kgFmt.unit}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="ehb-ratio-flex">
|
||||
<div className="ehb-mini-bar-track">
|
||||
<div className="ehb-mini-bar-fill is-blue" style={{ width: `${Math.min(100, s.share * 100 * 2.5)}%` }} />
|
||||
<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="ehb-ratio-text">{(s.share * 100).toFixed(1)}%</span>
|
||||
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }} className="col-green-fee">
|
||||
¥{revFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{revFmt.unit}</span>
|
||||
<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>
|
||||
<div className="ehb-ratio-flex">
|
||||
<div className="ehb-mini-bar-track">
|
||||
<div className="ehb-mini-bar-fill is-green" style={{ width: `${Math.min(100, s.revenueShare * 100 * 5)}%` }} />
|
||||
<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="ehb-ratio-text">{(s.revenueShare * 100).toFixed(1)}%</span>
|
||||
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -121,98 +60,55 @@ export function StationSummaryTable({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<OverviewDrillDialog
|
||||
payload={drill}
|
||||
onClose={() => { setDrill(null); setSelectedStationId(null); }}
|
||||
onPrimaryAction={selectedStationId && onSelectStation ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomerSummaryTable({
|
||||
customers,
|
||||
scope = 'global',
|
||||
scopeLabel,
|
||||
onDrillRequest,
|
||||
}: {
|
||||
customers: HydrogenCustomerRow[];
|
||||
scope?: OverviewScope;
|
||||
scopeLabel?: string | null;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}) {
|
||||
const [sortKey, setSortKey] = useState<'name' | 'payer' | 'kg' | 'cost' | 'revenue'>('kg');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const sortedCustomers = useMemo(() => sortBy(customers, sortKey, sortDirection, (customer, key) => customer[key]), [customers, sortDirection, sortKey]);
|
||||
const changeSort = (nextKey: typeof sortKey) => {
|
||||
const next = toggleSort(sortKey, sortDirection, nextKey);
|
||||
setSortKey(next.key);
|
||||
setSortDirection(next.direction);
|
||||
};
|
||||
const openCustomer = (customer: HydrogenCustomerRow, index: number) => {
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'customer', key: `${customer.name}-${index}`, label: customer.name });
|
||||
else setDrill(buildCustomerDrillPayload(customer));
|
||||
};
|
||||
export function CustomerSummaryTable({ customers }: { customers: HydrogenCustomerRow[] }) {
|
||||
return (
|
||||
<>
|
||||
{customers.length > 0 && (
|
||||
<div className="ehb-sum-table-card">
|
||||
<div className="ehb-sum-table-card__head">
|
||||
<div className="ehb-sum-table-card__title">
|
||||
<Search size={14} className="text-sky-600" style={{ display: 'inline-block', verticalAlign: 'middle', marginRight: 6 }} />
|
||||
客户账单汇总{scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : ''}
|
||||
<span className="ehb-title-sub ehb-hide-h5">
|
||||
(已收 / 未收:等待客户能源账户和对账单打通后获取)
|
||||
</span>
|
||||
<span className="ehb-title-sub ehb-show-h5">
|
||||
(已收未收打通中)
|
||||
</span>
|
||||
<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="ehb-sum-table-card__meta">
|
||||
Top {customers.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-sum-table-wrap">
|
||||
<table className="ehb-sum-table">
|
||||
<div className="overflow-x-auto -mx-1 px-1">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-idx">#</th>
|
||||
<th style={{ textAlign: 'left' }}><SortableColumnHeader label="客户" sortKey="name" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
||||
<th style={{ textAlign: 'center' }}><SortableColumnHeader label="承担方" sortKey="payer" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="center" /></th>
|
||||
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="加氢量" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="成本支出" sortKey="cost" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="应收" sortKey="revenue" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
<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>
|
||||
{sortedCustomers.map((c2, i) => {
|
||||
{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} onClick={() => openCustomer(c2, i)} style={{ cursor: 'pointer' }} title="点击查看客户账单明细">
|
||||
<td className="col-idx">{i + 1}</td>
|
||||
<td style={{ fontWeight: 600, color: '#0284c7' }}>
|
||||
{c2.name} <span style={{ fontSize: 11, fontWeight: 400, opacity: 0.8 }}>钻取 ›</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<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="ehb-payer-tag is-own">羚牛</span>
|
||||
) : c2.payer === 'mixed' ? (
|
||||
<span className="ehb-payer-tag is-mix">混合</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
|
||||
) : (
|
||||
<span className="ehb-payer-tag is-ext">客户</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold">客户</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }} className="col-bold-kg">
|
||||
{kgFmt.value} <span style={{ fontSize: 11, fontWeight: 400, color: '#64748b' }}>{kgFmt.unit}</span>
|
||||
<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 style={{ textAlign: 'right' }} className="col-amber-fee">
|
||||
¥{costFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{costFmt.unit}</span>
|
||||
<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 style={{ textAlign: 'right' }} className="col-green-fee">
|
||||
¥{revFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{revFmt.unit}</span>
|
||||
<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>
|
||||
);
|
||||
@@ -222,7 +118,6 @@ export function CustomerSummaryTable({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,37 +2,12 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { HydrogenOverviewResponse } from '../api.js';
|
||||
import {
|
||||
buildCustomerDrillPayload,
|
||||
buildMetricDrillPayload,
|
||||
buildMonthDrillPayload,
|
||||
buildRegionDrillPayload,
|
||||
buildStationDrillPayload,
|
||||
deriveOverviewMetrics,
|
||||
formatKg,
|
||||
formatRelative,
|
||||
formatYuan,
|
||||
} from './model.js';
|
||||
|
||||
const drillKpi = {
|
||||
yearKg: 10_000,
|
||||
yearFee: 80_000,
|
||||
yearRevenue: 75_000,
|
||||
yearProfit: 15_000,
|
||||
ourYearKg: 4_000,
|
||||
ourYearFee: 20_000,
|
||||
customerYearKg: 6_000,
|
||||
monthKg: 1_000,
|
||||
monthFee: 8_000,
|
||||
monthRevenue: 7_500,
|
||||
monthProfit: 1_500,
|
||||
todayKg: 100,
|
||||
todayFee: 800,
|
||||
todayRevenue: 750,
|
||||
todayProfit: 150,
|
||||
lingniuBornKg: 0,
|
||||
lingniuBornFee: 0,
|
||||
};
|
||||
|
||||
test('重量和金额单位保持现有阈值及负数格式', () => {
|
||||
assert.deepEqual(formatKg(999.5), { value: '999.50', unit: 'Kg' });
|
||||
assert.deepEqual(formatKg(1000), { value: '1.00', unit: 'T' });
|
||||
@@ -70,7 +45,7 @@ test('总览派生指标保持月份顺序、动能、集中度和收益率口
|
||||
lingniuBornKg: 0,
|
||||
lingniuBornFee: 0,
|
||||
},
|
||||
top5: [{ id: 1, rank: 1, name: 'A', kg: 600, fee: 100, share: 0.6 }],
|
||||
top5: [{ rank: 1, name: 'A', kg: 600, fee: 100, share: 0.6 }],
|
||||
regions: [],
|
||||
monthly: [
|
||||
{ month: '2026-01', kg: 100, fee: 20, revenue: 30, profit: 10 },
|
||||
@@ -78,13 +53,11 @@ test('总览派生指标保持月份顺序、动能、集中度和收益率口
|
||||
],
|
||||
customers: [],
|
||||
stations: [
|
||||
{ id: 1, name: 'A', kg: 600, share: 0.6, revenue: 300, revenueShare: 0.6 },
|
||||
{ id: 2, name: 'B', kg: 400, share: 0.4, revenue: 200, revenueShare: 0.4 },
|
||||
{ name: 'A', kg: 600, share: 0.6, revenue: 300, revenueShare: 0.6 },
|
||||
{ name: 'B', kg: 400, share: 0.4, revenue: 200, revenueShare: 0.4 },
|
||||
],
|
||||
availableYears: [2026],
|
||||
year: 2026,
|
||||
latestLedgerTime: '2026-08-17 21:50:09',
|
||||
filter: { stationId: null, vehicleScope: 'all', verifyScope: 'all' },
|
||||
} satisfies HydrogenOverviewResponse;
|
||||
|
||||
const metrics = deriveOverviewMetrics(data);
|
||||
@@ -93,56 +66,7 @@ test('总览派生指标保持月份顺序、动能、集中度和收益率口
|
||||
assert.equal(metrics.latestMonth?.month, '2026-02');
|
||||
assert.equal(metrics.monthMomentum, 50);
|
||||
assert.equal(metrics.top5Share, 60);
|
||||
assert.equal(metrics.customerGrossMarginPct, -10);
|
||||
assert.equal(metrics.profitYield, -10);
|
||||
assert.equal(metrics.stationAvgKg, 500);
|
||||
assert.deepEqual(metrics.monthlyDual.map(item => item.monthLabel), ['1月', '2月']);
|
||||
});
|
||||
|
||||
test('KPI 下钻只使用真实汇总口径且保留客户单毛利语义', () => {
|
||||
const payload = buildMetricDrillPayload(drillKpi, 'yearProfit');
|
||||
assert.equal(payload.title, '客户单毛利');
|
||||
assert.equal(payload.metrics[0]?.label, '客户单毛利');
|
||||
assert.equal(payload.metrics[0]?.value, '¥1.5 万元');
|
||||
assert.equal(payload.rows.length, 0);
|
||||
assert.match(payload.emptyMessage ?? '', /未返回站点、客户、车牌及单笔订单层级/);
|
||||
});
|
||||
|
||||
test('月度下钻按接口返回的羚牛与外部车辆加氢量拆分', () => {
|
||||
const payload = buildMonthDrillPayload({
|
||||
month: '2026-08',
|
||||
kg: 1_000,
|
||||
lingniuKg: 700,
|
||||
externalKg: 300,
|
||||
fee: 4_500,
|
||||
revenue: 5_000,
|
||||
profit: 500,
|
||||
});
|
||||
assert.equal(payload.rows[0]?.category, '羚牛车辆');
|
||||
assert.equal(payload.rows[0]?.share, '70.0%');
|
||||
assert.equal(payload.rows[1]?.share, '30.0%');
|
||||
assert.equal(payload.metrics[3]?.label, '客户单毛利');
|
||||
});
|
||||
|
||||
test('省级区域下钻仅关联同省真实站点,市级缺少映射时明确空态', () => {
|
||||
const stations = [
|
||||
{ id: 1, name: '嘉兴站', province: '浙江', kg: 600, revenue: 1_000, share: 0.6, revenueShare: 0.5 },
|
||||
{ id: 2, name: '广州站', province: '广东', kg: 400, revenue: 1_000, share: 0.4, revenueShare: 0.5 },
|
||||
];
|
||||
const provincePayload = buildRegionDrillPayload({ region: '浙江', kg: 600, share: 0.6 }, stations, 'province');
|
||||
assert.equal(provincePayload.rows.length, 1);
|
||||
assert.equal(provincePayload.rows[0]?.station, '嘉兴站');
|
||||
|
||||
const cityPayload = buildRegionDrillPayload({ region: '嘉兴', kg: 600, share: 0.6 }, stations, 'city');
|
||||
assert.equal(cityPayload.rows.length, 0);
|
||||
assert.match(cityPayload.emptyMessage ?? '', /未返回城市字段/);
|
||||
});
|
||||
|
||||
test('站点与客户弹层提供汇总指标及真实事件入口', () => {
|
||||
const stationPayload = buildStationDrillPayload({ id: 9, name: '测试站', province: '广东', kg: 900, revenue: 3_000, share: 0.3, revenueShare: 0.4 });
|
||||
assert.equal(stationPayload.primaryActionLabel, '进入单站视图');
|
||||
assert.equal(stationPayload.metrics[0]?.value, '900.00 Kg');
|
||||
|
||||
const customerPayload = buildCustomerDrillPayload({ name: '测试客户', payer: 'customer', kg: 500, cost: 2_000, revenue: 2_500 });
|
||||
assert.equal(customerPayload.metrics[3]?.label, '价差');
|
||||
assert.equal(customerPayload.metrics[3]?.value, '¥500 元');
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user