Compare commits
32
Commits
df6b8201e8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2f21a3a8a | ||
|
|
17b084ce62 | ||
|
|
dae727dfde | ||
|
|
0c26b973ef | ||
|
|
6ea1b3d5dd | ||
|
|
c49a02e3d9 | ||
|
|
d7538ed6eb | ||
|
|
6822eafd09 | ||
|
|
7c8e886663 | ||
|
|
c4e2c03348 | ||
|
|
f82867b159 | ||
|
|
25e53b8f66 | ||
|
|
9750d35ba4 | ||
|
|
7802e5b0eb | ||
|
|
e4c8195ede | ||
|
|
5196db5df7 | ||
|
|
523f7b312d | ||
|
|
c199f031c5 | ||
|
|
795fb207cb | ||
|
|
954d626afc | ||
|
|
507bc90ed2 | ||
|
|
5d14a28b0f | ||
|
|
b28ca49491 | ||
|
|
9a9c9d08e1 | ||
|
|
680c7e0662 | ||
|
|
7556dbcebc | ||
|
|
3daa9808ce | ||
|
|
89dca5c5a4 | ||
|
|
7d792933dd | ||
|
|
0dc7e89c1d | ||
|
|
4430fb75f1 | ||
|
|
df259fc12b |
@@ -12,3 +12,7 @@ DB_PORT=3306
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_NAME=
|
||||
# Local development uses the public OneOS endpoint; the ECS private IP is not reachable locally.
|
||||
ONEOS_MILEAGE_API_BASE_URL=https://open.d.lnoneos.com
|
||||
ONEOS_MILEAGE_API_KEY=
|
||||
ONEOS_MILEAGE_API_TIMEOUT_MS=20000
|
||||
|
||||
@@ -3,3 +3,5 @@ dist
|
||||
.env
|
||||
.env.local
|
||||
.worktrees
|
||||
.DS_Store
|
||||
scripts-tmp
|
||||
|
||||
+5
-3
@@ -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 --registry=https://registry.npmmirror.com/ --no-audit --no-fund
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
@@ -10,14 +10,16 @@ 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 --registry=https://registry.npmmirror.com/ --no-audit --no-fund
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY src/server ./src/server
|
||||
COPY src/shared ./src/shared
|
||||
COPY tsconfig.json ./
|
||||
|
||||
EXPOSE 3001
|
||||
ENV NODE_ENV=production
|
||||
ENV SERVER_PORT=3001
|
||||
ENV EXTERNAL_API_BASE=https://lnh2e.com
|
||||
ENV JWT_SECRET=ln-bi-jwt-prod-secret
|
||||
# JWT_SECRET 不再写入镜像:镜像层里的默认密钥等同于公开密钥,任何人可离线伪造令牌。
|
||||
# 必须在运行环境注入(至少 32 字符),缺失时服务会拒绝启动(见 src/server/config.ts)。
|
||||
CMD ["npm", "run", "start"]
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# LN-BI
|
||||
|
||||
羚牛业务数据的统一 BI Web 应用。React 19 + Vite 前端,Hono 后端,单进程同时提供 API 与静态页面。
|
||||
|
||||
## 入口
|
||||
|
||||
| 入口 | 模块 | 路由 |
|
||||
| --- | --- | --- |
|
||||
| 资产 BI | 资产管理、里程管理、车辆/加氢热力图、智能调度 | `/asset` |
|
||||
| 能源 BI | 氢费 BI、电能、ETC | `/energy` |
|
||||
| 隐藏页 | 充电记录导入(需能源角色) | `/ele/import` |
|
||||
| 隐藏页 | 用户反馈管理 | `/admin/feedback` |
|
||||
| 独立入口 | 氢能经营看板(与 `/energy` 内同一组件) | `/energy/hydrogen-board` |
|
||||
|
||||
页面内部用 Hash 保存子页面状态,例如 `/energy#hydrogen/overview`。入口兼容逻辑见 `src/app/routing.ts`。
|
||||
|
||||
## 快速开始
|
||||
|
||||
需要 Node.js 22 与 npm。
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
cp .env.local.example .env.local # 填入数据库等连接配置,切勿提交
|
||||
npm run dev:local # 只读本地预览:前端 127.0.0.1:8115,API 127.0.0.1:3001
|
||||
```
|
||||
|
||||
`dev:local` 会强制 `DB_READ_ONLY=1`、`HYDROGEN_DB_READ_ONLY=1`、`MILEAGE_REPORT_AUTO_ARCHIVE=0`、
|
||||
`DEV_BYPASS_AUTH=1`,并且**不启动建表与定时任务**,适合看真实数据而不写库。不要把它暴露到公网。
|
||||
|
||||
常规开发(前端 3000,后端 3001,`/api` 由 Vite 代理):
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
| --- | --- |
|
||||
| `npm run dev` | 前后端开发模式 |
|
||||
| `npm run dev:local` | 只读本地预览(免登录,禁写库) |
|
||||
| `npm run lint` | 类型检查(`tsc --noEmit`) |
|
||||
| `npm test` | Node 内置测试运行器 |
|
||||
| `npm run build` | 生成 `dist` |
|
||||
| `npm start` | 生产式启动(需先 build,且必须提供环境变量) |
|
||||
|
||||
发布门禁是 `lint` + `test` + `build` 三者同时通过(CI 见 `woodpecker.yml`)。
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ 应用外壳:路由解析、导航注册
|
||||
├── auth/ 前端认证状态与注入
|
||||
├── components/ 跨模块 UI(Shell、反馈、通用控件)
|
||||
├── modules/ 前端业务域(每个域自带 api / model / components / css)
|
||||
│ ├── assets/ 资产管理
|
||||
│ ├── mileage/ 里程(monitoring / statistics / daily-report)
|
||||
│ ├── scheduling/智能调度
|
||||
│ ├── energy/ 能源(hydrogen / electric / etc)
|
||||
│ ├── ele/ 电能数据导入
|
||||
│ ├── admin/ 反馈后台
|
||||
│ └── *-heatmap/ 两类热力图
|
||||
├── shared/ 前后端共享的叶子层(角色常量、跨端 DTO、日期区间、Excel、高德接入)
|
||||
└── server/
|
||||
├── config.ts 环境变量集中读取 + 启动期校验
|
||||
├── app.ts 应用装配(无副作用,便于测试)
|
||||
├── index.ts 进程启动
|
||||
├── local.ts 只读预览启动
|
||||
├── db/ MySQL / PostgreSQL 连接
|
||||
├── middleware/认证、只读
|
||||
├── auth/ 登录(SSO 换票 / 固定密码)、权限
|
||||
└── routes/ 按业务域的 HTTP 路由
|
||||
```
|
||||
|
||||
## 架构约定
|
||||
|
||||
分层规则、依赖方向与「新增一个模块该放哪」见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||
其中 6 条硬规则由 `src/architecture.test.ts` 断言,违反即测试失败。
|
||||
|
||||
## 部署注意事项
|
||||
|
||||
- `JWT_SECRET` **必须**在运行环境注入且不少于 32 字符;SSO 模式下缺失时服务会拒绝启动。
|
||||
镜像内不再内置默认密钥(历史默认值等同于公开密钥,可被离线伪造令牌)。
|
||||
- 数据库口令、Harbor 凭据、OSS / OneOS / 高德密钥一律通过环境变量或 CI Secret 注入,
|
||||
仓库内不保留真实值。历史提交中出现过的凭据都需要轮换。
|
||||
- 生产环境请显式设置 `NODE_ENV=production` 与 `DEV_BYPASS_AUTH=0`;后者会绕过全部认证。
|
||||
+30
-12
@@ -1,20 +1,29 @@
|
||||
version: '3.8'
|
||||
|
||||
# 凭据一律通过 Portainer / 环境变量注入,不再写入本文件。
|
||||
# 注意:此前提交到 Git 的数据库口令与 JWT 密钥已视为泄漏,必须轮换后再使用。
|
||||
# JWT_SECRET 至少要 32 个字符,缺失时服务会拒绝启动(src/server/config.ts)。
|
||||
|
||||
services:
|
||||
ln-bi:
|
||||
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
|
||||
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.2.0
|
||||
network_mode: host
|
||||
environment:
|
||||
DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
|
||||
DB_PORT: "3306"
|
||||
DB_USER: "oneos_db_prod"
|
||||
DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
|
||||
DB_NAME: "ln_asset_management"
|
||||
MILEAGE_DB_HOST: "101.133.130.65"
|
||||
MILEAGE_DB_PORT: "3306"
|
||||
MILEAGE_DB_USER: "bi_reader_02"
|
||||
MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
|
||||
MILEAGE_DB_NAME: "hydrogen_energy"
|
||||
NODE_ENV: "production"
|
||||
# 生产必须保持为 0:该开关会绕过全部认证。
|
||||
DEV_BYPASS_AUTH: "0"
|
||||
DB_HOST: "${DB_HOST:?请注入主业务库地址}"
|
||||
DB_PORT: "${DB_PORT:-3306}"
|
||||
DB_USER: "${DB_USER:?请注入主业务库只读账号}"
|
||||
DB_PASSWORD: "${DB_PASSWORD:?请注入主业务库口令}"
|
||||
DB_NAME: "${DB_NAME:-ln_asset_management}"
|
||||
# 氢能库未配置时复用主库,因此默认不再单独注入;如需独立只读库再配置。
|
||||
HYDROGEN_DB_HOST: "${HYDROGEN_DB_HOST:-}"
|
||||
HYDROGEN_DB_PORT: "${HYDROGEN_DB_PORT:-3306}"
|
||||
HYDROGEN_DB_USER: "${HYDROGEN_DB_USER:-}"
|
||||
HYDROGEN_DB_PASSWORD: "${HYDROGEN_DB_PASSWORD:-}"
|
||||
HYDROGEN_DB_NAME: "${HYDROGEN_DB_NAME:-}"
|
||||
HYDROGEN_TENANT_ID: "${HYDROGEN_TENANT_ID:-000000}"
|
||||
# ECS production accesses OneOS over the private network. Configure the key
|
||||
# as a Portainer stack environment variable; never commit it to this file.
|
||||
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
|
||||
@@ -22,7 +31,9 @@ services:
|
||||
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
|
||||
SERVER_PORT: "8111"
|
||||
EXTERNAL_API_BASE: "https://lnh2e.com"
|
||||
JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
|
||||
BI_AUTH_MODE: "${BI_AUTH_MODE:-sso}"
|
||||
BI_AUTH_PASSWORD: "${BI_AUTH_PASSWORD:-}"
|
||||
JWT_SECRET: "${JWT_SECRET:?请注入至少 32 字符的随机签名密钥}"
|
||||
AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
|
||||
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
|
||||
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
|
||||
@@ -31,6 +42,13 @@ services:
|
||||
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
|
||||
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
|
||||
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}"
|
||||
# 用户反馈截图上传所需;此前缺失会导致反馈上传必然失败。
|
||||
OSS_REGION: "${OSS_REGION}"
|
||||
OSS_ENDPOINT: "${OSS_ENDPOINT}"
|
||||
OSS_BUCKET: "${OSS_BUCKET}"
|
||||
OSS_ACCESS_KEY_ID: "${OSS_ACCESS_KEY_ID}"
|
||||
OSS_ACCESS_KEY_SECRET: "${OSS_ACCESS_KEY_SECRET}"
|
||||
OSS_BASE_DIR: "${OSS_BASE_DIR}"
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,165 @@
|
||||
# 架构与分层约定
|
||||
|
||||
本文是这个仓库的结构契约。**6 条硬规则由 `src/architecture.test.ts` 断言**,破坏即测试失败。
|
||||
|
||||
## 1. 分层与依赖方向
|
||||
|
||||
```
|
||||
app/ 应用外壳(路由、导航注册)
|
||||
↓
|
||||
modules/ 前端业务域 —— 只依赖 shared/
|
||||
↓
|
||||
shared/ 叶子层:角色常量、跨端 DTO、纯工具(不得依赖 modules/ 或 server/)
|
||||
|
||||
server/ 后端 —— 只依赖 shared/,不得依赖 modules/
|
||||
```
|
||||
|
||||
**依赖只能向下**。前端通过 HTTP 调用后端,不通过 import。
|
||||
|
||||
### 6 条硬规则
|
||||
|
||||
| # | 规则 | 违反后果 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `server/**` 不得 import `modules/**` | 服务端被前端类型/组件绑架,无法独立部署与测试 |
|
||||
| 2 | `modules/**`、`components/**`、`auth/**`、`app/**` 不得 import `server/**` | 打包会拖入服务端代码与密钥 |
|
||||
| 3 | `shared/**` 不得 import `modules/**` / `server/**` / `components/**` | 叶子层反向依赖会让依赖图成环 |
|
||||
| 4 | 不得出现 `vendor/` 引用 | 生产代码必须位于业务域目录,不能藏在"第三方/参考"目录里 |
|
||||
| 5 | `model.ts` / `model.tsx` 不得 import `react` | 纯模型必须能在 Node 里直接单测,不依赖 DOM |
|
||||
| 6 | `@ts-nocheck` 只允许出现在已登记的原型快照文件 | 类型门禁对最大文件失效 |
|
||||
|
||||
### 关于第 6 条的豁免清单
|
||||
|
||||
这三个文件是**逐字节保留**的 8113 验收原型(UI / CSS 冻结,改动需重新做桌面与移动端截图验收):
|
||||
|
||||
```
|
||||
modules/energy/hydrogen/board/EnergyBiBoardApp.tsx
|
||||
modules/energy/hydrogen/station-daily/StationDailyApp.tsx
|
||||
modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx
|
||||
```
|
||||
|
||||
豁免清单是**只减不增**的:新增 `@ts-nocheck` 会被测试拦下,请改为修正类型。
|
||||
|
||||
## 2. 前端业务域的形状
|
||||
|
||||
每个域尽量保持同一形状,便于"进一个目录就知道从哪读起":
|
||||
|
||||
```
|
||||
modules/<domain>/
|
||||
index.tsx 域入口组件(导出给 app/modules.ts 注册)
|
||||
api.ts 只做 HTTP,不做业务判断
|
||||
types.ts DTO
|
||||
model.ts 纯函数:筛选 / 排序 / 聚合 / 口径(可被 node:test 直接测)
|
||||
components/ 无状态展示组件
|
||||
*.css 域内样式
|
||||
```
|
||||
|
||||
复杂域可以再分子域,例如氢能:
|
||||
|
||||
```
|
||||
modules/energy/hydrogen/
|
||||
index.tsx 导航入口
|
||||
api.ts types.ts 数据访问与 DTO
|
||||
model/ 纯逻辑(格式化、承担方标签、按日汇总格式)
|
||||
board/ 经营看板 UI
|
||||
station-daily/ 单站日报 UI
|
||||
drill/ 下钻弹层 UI
|
||||
common/ 域内共享工具与组件
|
||||
fonts/ 看板专用字体
|
||||
```
|
||||
|
||||
判定标准很简单:**能在 Node 里直接测的放 `model/`;需要浏览器/DOM 的放 UI 子目录。**
|
||||
|
||||
## 3. 后端结构
|
||||
|
||||
```
|
||||
server/
|
||||
config.ts 环境变量集中读取 + assertRuntimeConfig() 启动期校验
|
||||
app.ts createApp():装配中间件、挂载路由、静态托管(无副作用,可测试)
|
||||
index.ts 进程启动:config 校验 → app → bootstrap → listen
|
||||
local.ts 只读预览启动(不建表、不定时任务)
|
||||
bootstrap.ts 启动副作用总入口
|
||||
db/ mysql / hydrogen / heatmap 连接
|
||||
db/schema/ 运行时建表/改表的唯一位置(只读模式下整体跳过)
|
||||
middleware/ auth(JWT → 注入 user)、read-only
|
||||
auth/ 登录换票、固定密码、权限过滤与脱敏
|
||||
routes/<domain>/ 按业务域的 HTTP 路由
|
||||
```
|
||||
|
||||
### 后端业务域的形状(`server/routes/<domain>/`)
|
||||
|
||||
命名约定:`index.ts` 是**聚合器**(把子路由挂到前缀上),`routes.ts` 是**叶子路由**。
|
||||
|
||||
| 域 | 文件 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `vehicles/` | `routes.ts` `repository.ts` `model.ts` `utils.ts` `types.ts` | ✅ 完整分层;SQL 有契约测试 |
|
||||
| `ele/` | `routes.ts` `repository.ts` `model.ts`(+ `model.test.ts` `routes.test.ts`) | ✅ 完整分层;改造前后 SQL/参数/响应体已做等价性验证 |
|
||||
| `feedback/` | `routes.ts` `repository.ts` `oss.ts`(+ `routes.test.ts`) | ✅ 完整分层;同上 |
|
||||
| `mileage/` | `index.ts`(聚合)+ `monitoring.ts` `targets.ts` `trend.ts` `daily-report.ts` `vehicle-recent.ts` + `repository.ts` + `*-model.ts` + `cache.ts` `oneos-api.ts` `daily-report-{service,store,scheduler}.ts` | ✅ 完整分层;车辆关联信息 SQL 与 18 个查询集中在此 |
|
||||
| `energy/` | `index.ts`(聚合)+ `electric.ts` `etc.ts` `hydrogen-station-board.ts` `hydrogen-bi-v2.ts` + `repository.ts` + `query-model.ts` `cache.ts` `constants.ts` | ✅ 完整分层;WHERE 片段组装(`buildFilterClauses` / `buildGroupSelect` / `buildScopedWhere` / `resolvedWhere`)与共享 SQL 片段也都在 repository / constants |
|
||||
| `scheduling/` | `index.ts`(聚合)+ `suggestions.ts` `notify.ts` `repository.ts` + `algorithm.ts` `notification-model.ts` | ✅ 完整分层;跨域数据源(里程车辆信息 / OneOS)显式注入 |
|
||||
| `hydrogen-heatmap/` | `routes.ts` `repository.ts` `model.ts`(+ `routes.test.ts`) | ✅ 完整分层;`buildWhere` 片段与参数顺序已锁定 |
|
||||
| `vehicle-heatmap/` | `routes.ts` `repository.ts` `model.ts`(+ `routes.test.ts`) | ✅ 完整分层;同时覆盖 MySQL(考核批次)与 PG(定位点)两个库 |
|
||||
|
||||
**全部八个域**(`vehicles` / `ele` / `feedback` / `vehicle-heatmap` / `hydrogen-heatmap` / `scheduling` / `energy` / `mileage`)
|
||||
由架构测试守护:必须存在 `repository.ts`,且该域**其他任何非测试文件都不得含 SQL**。
|
||||
其余域(`energy` / `mileage`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**,
|
||||
请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。
|
||||
|
||||
|
||||
### 中间件顺序(在 `app.ts` 中显式体现)
|
||||
|
||||
```
|
||||
cors → read-only → /api/auth(公开) → authMiddleware → 各业务域路由
|
||||
→ /api/health → /api/* 未匹配返回 JSON 404 → 静态托管 + SPA 回退
|
||||
```
|
||||
|
||||
两个已经踩过的坑,改动时不要退回去:
|
||||
|
||||
- **未匹配的 `/api/*` 必须返回 JSON 404**。若落到 SPA 回退,会返回 `200 text/html`,
|
||||
前端 `res.ok` 为真后在 `res.json()` 抛解析错误,把"路由不存在"伪装成数据问题。
|
||||
- **模块守卫必须 fail-closed**,写法是 `if (!canAccessX(user?.roles))`,
|
||||
不能写成 `if (user && !canAccessX(user.roles))`——后者在 `user` 缺失时静默放行。
|
||||
|
||||
### 角色与数据权限
|
||||
|
||||
- 角色常量唯一真源:`src/shared/auth/roles.ts`(前后端共用)。
|
||||
- 数据范围过滤与客户名脱敏:`src/server/auth/permissions.ts`。
|
||||
- `permissionLevel` 只表示**数据范围**,不等于模块权限;能源/调度/反馈各自另有白名单。
|
||||
|
||||
## 4. 新增一个业务模块的步骤
|
||||
|
||||
1. `src/modules/<domain>/`:建 `api.ts`(HTTP)、`model.ts`(纯逻辑)、`index.tsx`(入口)、`components/`。
|
||||
2. `src/app/modules.ts`:注册导航项,必要时用 `roles.ts` 的判断控制可见性。
|
||||
3. `src/server/routes/<domain>.ts`(或 `routes/<domain>/`):实现接口;需要模块级权限就加 **fail-closed** 守卫。
|
||||
4. 数据权限:读取后调用 `filterByPermission` + `maskCustomerNames`,或写等价的 SQL 过滤。
|
||||
5. 测试:纯逻辑进 `model.test.ts`;接口口径用**固定夹具**锁 SQL 与参数(参考 `server/routes/energy/routes.test.ts`)。
|
||||
6. 跑 `npm run lint && npm test && npm run build`。
|
||||
|
||||
## 5. 已知未尽事项
|
||||
|
||||
诚实记录,避免后来者以为已经做完:
|
||||
|
||||
- **后端八个域都已拆出 repository**,SQL 只允许出现在各域的 `repository.ts`(以及共享片段模块
|
||||
`energy/constants.ts`)中,由架构测试守护。新增查询请沿用同一形状:路由只做校验与组装,
|
||||
SQL 进 `repository.ts`,纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。
|
||||
守卫用的是"语句形状"正则(如 `update <table> set`)而不是裸关键字,避免把
|
||||
`UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。
|
||||
- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护,
|
||||
已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用——
|
||||
要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。
|
||||
- **`src/lib/cn.ts` 刻意不引入 `tailwind-merge`**:全仓只有 2 处调用,为此新增一个
|
||||
传递依赖不划算。若将来条件类覆盖变多,再换 `clsx` + `tailwind-merge`。
|
||||
- **前端 `dist` 体积**:氢能看板单个 chunk 约 200 kB(gzip 50 kB),来自原型渲染方式(大量内联样式)。
|
||||
|
||||
### 已收敛的重复实现(不要退回多份)
|
||||
|
||||
| 能力 | 唯一实现 | 由测试守护 |
|
||||
| --- | --- | --- |
|
||||
| Excel 导出(拼装与写出) | `src/shared/xlsx.ts` | 架构测试:`modules/**` 不得再出现 `book_new` / `book_append_sheet` / `writeFile(` |
|
||||
| 高德 JSAPI 加载与底图 | `src/shared/amap.ts` | 架构测试:只有它可 import `@amap/amap-jsapi-loader` |
|
||||
| 运行时 DDL(建表/改表) | `src/server/db/schema/*` | 架构测试:其他 server 文件不得出现 `CREATE TABLE` / `ALTER TABLE`;每个 schema 模块必须检查 `ddlAllowed()` |
|
||||
| 自然日区间(近 N 天/快捷区间) | `src/shared/date-range.ts`、`modules/energy/daily-range/model.ts` | `date-range` 暂无独立测试;改动请补 |
|
||||
| 氢能数值格式化 | `modules/energy/hydrogen/model/display-format.ts` | `display-format.test.ts` |
|
||||
| 角色常量与模块可见性 | `src/shared/auth/roles.ts` | `app/modules.test.ts` |
|
||||
| 环境变量读取与校验 | `src/server/config.ts` | `auth/password.test.ts` 覆盖密码模式;JWT 缺失拒绝启动已实测 |
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# 登录方式与 Portainer 配置
|
||||
|
||||
不设置 `BI_AUTH_MODE` 时默认 `sso`,沿用业务系统 jumpToken 登录。
|
||||
设置 `BI_AUTH_MODE=password` 时只开放固定密码登录,关闭 SSO 换票入口。
|
||||
浏览器从 `/api/auth/config` 获取模式,密码不会打包到前端,不使用 VITE_ 密码变量。
|
||||
|
||||
## Portainer Stack
|
||||
|
||||
在现有 BI 服务的 `environment` 中合并以下配置,保留原镜像、端口、数据库等配置:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
BI_AUTH_MODE: ${BI_AUTH_MODE:-sso}
|
||||
BI_AUTH_PASSWORD: ${BI_AUTH_PASSWORD:-}
|
||||
JWT_SECRET: ${JWT_SECRET:?必须配置独立的随机签名密钥}
|
||||
DEV_BYPASS_AUTH: "0"
|
||||
NODE_ENV: production
|
||||
```
|
||||
|
||||
在 Stack 的 Environment variables 区域录入以下名称和值:
|
||||
|
||||
| 名称 | 固定密码模式 | SSO 模式 |
|
||||
| --- | --- | --- |
|
||||
| BI_AUTH_MODE | password | sso |
|
||||
| BI_AUTH_PASSWORD | 非空密码,允许短密码,推荐独立随机密码 | 留空 |
|
||||
| JWT_SECRET | 独立随机密钥,至少 32 字符 | 保留部署专用密钥 |
|
||||
|
||||
不要把真实密码提交进 Git。Portainer 中填写的 Stack 变量必须通过上面的 environment 映射才能进入容器。
|
||||
更新 Stack 并重新创建容器;单纯 Restart 不会更新容器环境变量。
|
||||
容器方式部署:Duplicate/Edit → Advanced container settings → Env,填写同样变量,再重新部署。
|
||||
需要先构建包含本功能的新镜像;旧镜像不认识这些变量。
|
||||
|
||||
## 权限、安全和验证
|
||||
|
||||
- 固定密码身份是共享只读身份,具有全量看板数据读取范围及能源访问权限,无调度/反馈管理角色;服务端拒绝写入方法。
|
||||
- 会话有效期 8 小时。更换密码、JWT_SECRET 或切换模式后,原密码会话失效。
|
||||
- 每个服务实例 15 分钟最多 20 次密码验证请求;多副本需在网关配置共享限流。共享限流可能被恶意请求耗尽,建议只在内网或受控网络开放。
|
||||
- 外网必须使用 HTTPS,避免明文传输密码和令牌。密码/环境变量对拥有容器管理权限的人可见。
|
||||
- 密码为空或全为空白,或签名密钥少于 32 字符时拒绝登录;无默认访问密码。不要沿用镜像中的旧默认 JWT_SECRET。
|
||||
- 短密码容易被猜中,仅建议在内网或受限访问环境使用;登录限流保持开启。
|
||||
- DB_READ_ONLY=1 时密码登录仍可用,业务写入仍被禁止。
|
||||
- 测试未登录访问、错误密码、正确密码、刷新恢复会话;切回 sso 后检查跳转登录。
|
||||
- 开发免登录仅 SSO 开发模式生效,生产必须保持 DEV_BYPASS_AUTH=0。
|
||||
|
||||
Portainer 官方说明:https://docs.portainer.io/user/docker/containers/advanced
|
||||
以及 https://docs.portainer.io/sts/user/docker/stacks/add
|
||||
Vendored
BIN
Binary file not shown.
@@ -43,7 +43,7 @@ const mileagePool = mysql.createPool({
|
||||
host: '101.133.130.65',
|
||||
port: 3306,
|
||||
user: 'bi_reader_02',
|
||||
password: 'bi_reader_02_Pass',
|
||||
password: process.env.MILEAGE_DB_PASSWORD,
|
||||
database: 'hydrogen_energy',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
### 数据库 2:hydrogen_energy(新增连接)
|
||||
|
||||
- 连接信息:`101.133.130.65:3306`,用户 `bi_reader_02`,密码 `bi_reader_02_Pass`,库名 `hydrogen_energy`
|
||||
- 连接信息:`101.133.130.65:3306`,用户 `bi_reader_02`,密码 `<见环境变量 MILEAGE_DB_PASSWORD>`,库名 `hydrogen_energy`
|
||||
- `v_vehicle_daily_stats` — 1004 辆车的每日里程明细(plate, vin, stat_date, daily_km, total_km, day_hydrogen, daily_run_secs, source)
|
||||
|
||||
## 架构
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.15",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ln-bi",
|
||||
"version": "1.1.15",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ln-bi",
|
||||
"private": true,
|
||||
"version": "1.1.15",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:local": "concurrently -k -n server,client \"npm run dev:local:server\" \"npm run dev:local:client\"",
|
||||
@@ -13,7 +13,7 @@
|
||||
"build": "vite build",
|
||||
"start": "node --import tsx src/server/index.ts",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "node --import tsx --test $(find src -type f -name '*.test.ts' -print | sort)",
|
||||
"test": "node --import tsx --test $(find src -type f \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print | sort)",
|
||||
"test:mileage-report": "node --import tsx --test src/server/routes/mileage/daily-report-model.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
沪A00113F
|
||||
沪A00220F
|
||||
沪A00333F
|
||||
沪A00607F
|
||||
沪A01056F
|
||||
沪A01311F
|
||||
沪A01775F
|
||||
沪A01813F
|
||||
沪A01855F
|
||||
沪A02303F
|
||||
沪A02311F
|
||||
沪A02326F
|
||||
沪A02361F
|
||||
沪A02720F
|
||||
沪A03086F
|
||||
沪A03397F
|
||||
沪A03565F
|
||||
沪A03620F
|
||||
沪A03659F
|
||||
沪A03801F
|
||||
沪A03870F
|
||||
沪A05035F
|
||||
沪A05113F
|
||||
沪A05223F
|
||||
沪A05501F
|
||||
沪A05675F
|
||||
沪A05697F
|
||||
沪A05830F
|
||||
沪A06335F
|
||||
沪A06599F
|
||||
沪A06695F
|
||||
沪A07006F
|
||||
沪A07153F
|
||||
沪A07806F
|
||||
沪A08037F
|
||||
沪A08150F
|
||||
沪A08315F
|
||||
沪A08598F
|
||||
沪A08786F
|
||||
沪A09100F
|
||||
沪A09251F
|
||||
沪A09276F
|
||||
沪A09303F
|
||||
沪A09313F
|
||||
沪A09322F
|
||||
沪A09689F
|
||||
沪A30010F
|
||||
沪A30399F
|
||||
沪A31031F
|
||||
沪A31211F
|
||||
沪A31281F
|
||||
沪A31308F
|
||||
沪A31381F
|
||||
沪A31613F
|
||||
沪A32269F
|
||||
沪A33216F
|
||||
沪A35236F
|
||||
沪A35798F
|
||||
沪A35879F
|
||||
沪A35898F
|
||||
沪A36133F
|
||||
沪A36169F
|
||||
沪A36569F
|
||||
沪A36980F
|
||||
沪A37785F
|
||||
沪A38795F
|
||||
沪A39287F
|
||||
沪A39289F
|
||||
沪A39585F
|
||||
沪A39608F
|
||||
沪A39626F
|
||||
沪A39815F
|
||||
沪A39835F
|
||||
沪A39912F
|
||||
沪A50026F
|
||||
沪A50069F
|
||||
沪A50309F
|
||||
沪A51580F
|
||||
沪A51612F
|
||||
沪A51677F
|
||||
沪A51893F
|
||||
沪A52331F
|
||||
沪A52511F
|
||||
沪A53309F
|
||||
沪A53322F
|
||||
沪A53506F
|
||||
沪A53960F
|
||||
沪A55179F
|
||||
沪A55297F
|
||||
沪A55339F
|
||||
沪A55666F
|
||||
沪A55695F
|
||||
沪A56122F
|
||||
沪A56701F
|
||||
沪A56959F
|
||||
沪A56988F
|
||||
沪A57139F
|
||||
沪A57167F
|
||||
沪A57198F
|
||||
沪A57838F
|
||||
沪A57850F
|
||||
沪A57895F
|
||||
沪A58087F
|
||||
沪A58159F
|
||||
沪A58185F
|
||||
沪A58307F
|
||||
沪A58533F
|
||||
沪A58538F
|
||||
沪A58593F
|
||||
沪A58922F
|
||||
沪A59095F
|
||||
沪A59510F
|
||||
沪A59613F
|
||||
沪A59682F
|
||||
沪A59799F
|
||||
沪A59932F
|
||||
沪A60339F
|
||||
沪A60691F
|
||||
沪A60820F
|
||||
沪A61187F
|
||||
沪A61193F
|
||||
沪A61312F
|
||||
沪A61559F
|
||||
沪A61600F
|
||||
沪A61711F
|
||||
沪A61738F
|
||||
沪A62322F
|
||||
沪A62772F
|
||||
沪A62928F
|
||||
沪A63013F
|
||||
沪A63305F
|
||||
沪A63522F
|
||||
沪A63660F
|
||||
沪A63697F
|
||||
沪A65036F
|
||||
沪A65181F
|
||||
沪A65522F
|
||||
沪A65995F
|
||||
沪A66216F
|
||||
沪A66256F
|
||||
沪A66329F
|
||||
沪A66593F
|
||||
沪A66710F
|
||||
沪A66921F
|
||||
沪A67018F
|
||||
沪A67033F
|
||||
沪A67872F
|
||||
沪A68115F
|
||||
沪A68139F
|
||||
沪A68332F
|
||||
沪A68613F
|
||||
沪A68658F
|
||||
沪A68752F
|
||||
沪A69311F
|
||||
沪A69826F
|
||||
沪A69997F
|
||||
沪A85021F
|
||||
沪A89315F
|
||||
沪A89385F
|
||||
沪A89662F
|
||||
浙F00885F
|
||||
浙F08889F
|
||||
浙F09898F
|
||||
粤A00255F
|
||||
粤A02683F
|
||||
粤A02956F
|
||||
粤A03502F
|
||||
粤A03532F
|
||||
粤A03569F
|
||||
粤A05106F
|
||||
粤A05391F
|
||||
粤A05428F
|
||||
粤A05839F
|
||||
粤A05985F
|
||||
粤A05995F
|
||||
粤A06569F
|
||||
粤A06931F
|
||||
粤A06932F
|
||||
@@ -1,60 +0,0 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: 'rm-uf65w5v2r77n674x2.mysql.rds.aliyuncs.com',
|
||||
port: 3306,
|
||||
user: 'root',
|
||||
password: 'LN#Passw0rd@2026',
|
||||
database: 'lingniu_prod',
|
||||
connectTimeout: 15000, ssl: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const excelPlates = new Set(
|
||||
fs.readFileSync('/Users/kkfluous/Projects/ai-coding/ln-bi/scripts-tmp/excel_plates.txt', 'utf8').trim().split('\n').map((s) => s.trim())
|
||||
);
|
||||
console.log('excel plates:', excelPlates.size);
|
||||
|
||||
// 按 dept-stats 逻辑查金可鹏 18T Operating
|
||||
const [rows] = await pool.query<any[]>(`
|
||||
SELECT truck.plate_number AS plate,
|
||||
dic_type.dic_name AS type_label,
|
||||
dic_status.dic_name AS status_label,
|
||||
cus.customer_name AS customer,
|
||||
org_truck.org_name AS subject_org
|
||||
FROM tab_truck truck
|
||||
LEFT JOIN tab_dic dic_type ON dic_type.parent_code='dic_truck_type' AND dic_type.dic_code=truck.model AND dic_type.is_deleted=0
|
||||
LEFT JOIN tab_dic dic_status ON dic_status.parent_code='dic_truck_rent_status' AND dic_status.dic_code=truck.truck_rent_status AND dic_status.is_deleted=0
|
||||
LEFT JOIN tab_truck_status_info si ON si.truck_id=truck.id AND si.is_deleted=0
|
||||
LEFT JOIN tab_contract c ON c.id=si.contract_id AND c.is_deleted=0
|
||||
LEFT JOIN tab_customer cus ON cus.id=c.customer_id AND cus.is_deleted=0
|
||||
LEFT JOIN tab_org org_truck ON org_truck.id=truck.org_id AND org_truck.is_deleted=0
|
||||
LEFT JOIN tab_user u ON u.id=c.bd AND u.is_deleted=0
|
||||
WHERE truck.is_deleted=0 AND truck.is_operation=1
|
||||
AND u.user_name='金可鹏'
|
||||
AND dic_type.dic_name LIKE '%18吨%'
|
||||
AND dic_status.dic_name IN ('租赁','自营','挂靠')
|
||||
ORDER BY truck.plate_number
|
||||
`);
|
||||
|
||||
console.log('DB 金可鹏 18T operating:', rows.length);
|
||||
const dbPlates = new Set((rows as any[]).map((r) => (r.plate || '').trim()));
|
||||
|
||||
const extra = [...dbPlates].filter((p) => !excelPlates.has(p)).sort();
|
||||
const missing = [...excelPlates].filter((p) => !dbPlates.has(p)).sort();
|
||||
|
||||
console.log('\n=== DB 有但 Excel 没有(多出来的) ===');
|
||||
console.log('数量:', extra.length);
|
||||
for (const p of extra) {
|
||||
const r = (rows as any[]).find((x) => x.plate === p);
|
||||
console.log(' ', p, '|', r?.type_label, '|', r?.customer, '|', r?.subject_org);
|
||||
}
|
||||
|
||||
console.log('\n=== Excel 有但 DB 没有 ===');
|
||||
console.log('数量:', missing.length);
|
||||
for (const p of missing) console.log(' ', p);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
+4
-2
@@ -3,6 +3,7 @@ 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";
|
||||
@@ -15,14 +16,14 @@ 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(
|
||||
import("./modules/energy/hydrogen/board/EnergyBiBoardApp").then(
|
||||
({ EnergyBiBoardApp }) => ({ default: EnergyBiBoardApp }),
|
||||
),
|
||||
);
|
||||
normalizeBrowserPath();
|
||||
|
||||
function AuthGate() {
|
||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||
const { isLoading, isAuthenticated, error, user, mode } = useAuth();
|
||||
const [route, setRoute] = useState(readBrowserRoute);
|
||||
const { routeKey, pathSet } = route;
|
||||
|
||||
@@ -71,6 +72,7 @@ function AuthGate() {
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (mode === 'password') return <PasswordLogin />;
|
||||
return <UnauthorizedPage message={error || undefined} />;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import type { PathSet } from './routing';
|
||||
const AssetsModule = lazy(() => import('../modules/assets/AssetsModule'));
|
||||
const MileageModule = lazy(() => import('../modules/mileage/MileageModule'));
|
||||
const SchedulingModule = lazy(() => import('../modules/scheduling/SchedulingModule'));
|
||||
const HydrogenModule = lazy(() => import('../modules/energy/HydrogenModule'));
|
||||
const HydrogenModule = lazy(() => import('../modules/energy/hydrogen/index'));
|
||||
const ElectricModule = lazy(() => import('../modules/energy/ElectricModule'));
|
||||
const EtcModule = lazy(() => import('../modules/energy/EtcModule'));
|
||||
const VehicleHeatmapModule = lazy(() => import('../modules/vehicle-heatmap/VehicleHeatmapModule'));
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
/**
|
||||
* 架构守护测试。
|
||||
*
|
||||
* 这些不是代码风格偏好,而是这个仓库赖以保持"能读懂"的结构约束。
|
||||
* 一旦被打破(尤其是 client/server 互相引用、shared 反向依赖上层),
|
||||
* 依赖图会重新变成无法追踪的网,所以用断言固化。
|
||||
*/
|
||||
|
||||
const srcDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (/\.(ts|tsx|css)$/.test(entry.name)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const allFiles = walk(srcDir);
|
||||
const rel = (file: string) => path.relative(srcDir, file);
|
||||
|
||||
/** 取出文件里所有 import 说明符(含副作用 import / 动态 import / @import)。 */
|
||||
function specifiers(file: string): string[] {
|
||||
const source = readFileSync(file, "utf8");
|
||||
const found: string[] = [];
|
||||
const patterns = [
|
||||
/(?:^|[^\w$])(?:import|export)\s+[\s\S]*?\sfrom\s*['"]([^'"]+)['"]/g,
|
||||
/(?:^|[^\w$])import\s*['"]([^'"]+)['"]/g,
|
||||
/(?:^|[^\w$])import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
||||
/@import\s+['"]([^'"]+)['"]/g,
|
||||
];
|
||||
for (const re of patterns) {
|
||||
for (const match of source.matchAll(re)) found.push(match[1]);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 把相对 import 解析为 src 下的路径(用于判断它指向哪个分区)。 */
|
||||
function targetPath(file: string, spec: string): string | null {
|
||||
if (!spec.startsWith(".")) return null;
|
||||
return path.relative(srcDir, path.resolve(path.dirname(file), spec));
|
||||
}
|
||||
|
||||
/** 去掉注释后再做 SQL 关键字匹配:注释里的 "update status" 之类不应触发守卫。 */
|
||||
function stripComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:])\/\/[^\n]*/g, "$1 ");
|
||||
}
|
||||
|
||||
function filesUnder(prefix: string): string[] {
|
||||
return allFiles.filter((f) => rel(f).startsWith(prefix));
|
||||
}
|
||||
|
||||
function fileContains(file: string, re: RegExp): boolean {
|
||||
return re.test(readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
test("分层铁律:server 不得依赖前端 modules", () => {
|
||||
const violations: string[] = [];
|
||||
for (const file of filesUnder("server/")) {
|
||||
for (const spec of specifiers(file)) {
|
||||
const target = targetPath(file, spec);
|
||||
if (target && target.startsWith("modules/")) violations.push(`${rel(file)} -> ${spec}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(violations, [], "server 必须独立于前端:请把共享内容下沉到 src/shared");
|
||||
});
|
||||
|
||||
test("分层铁律:前端不得依赖 server", () => {
|
||||
const violations: string[] = [];
|
||||
const clientDirs = ["modules/", "components/", "auth/", "app/"];
|
||||
for (const dir of clientDirs) {
|
||||
for (const file of filesUnder(dir)) {
|
||||
for (const spec of specifiers(file)) {
|
||||
const target = targetPath(file, spec);
|
||||
if (target && target.startsWith("server/")) violations.push(`${rel(file)} -> ${spec}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(violations, [], "前端不得直接引用服务端模块");
|
||||
});
|
||||
|
||||
test("分层铁律:shared 是最底层,不得反向依赖上层", () => {
|
||||
const violations: string[] = [];
|
||||
for (const file of filesUnder("shared/")) {
|
||||
for (const spec of specifiers(file)) {
|
||||
const target = targetPath(file, spec);
|
||||
if (target && (target.startsWith("modules/") || target.startsWith("server/") || target.startsWith("components/"))) {
|
||||
violations.push(`${rel(file)} -> ${spec}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(violations, [], "src/shared 必须保持为可被前后端共同依赖的叶子层");
|
||||
});
|
||||
|
||||
test("原型代码不再散落在 vendor 目录", () => {
|
||||
const violations: string[] = [];
|
||||
for (const file of allFiles) {
|
||||
for (const spec of specifiers(file)) {
|
||||
if (spec.includes("vendor/")) violations.push(`${rel(file)} -> ${spec}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(violations, [], "生产代码应位于 src/modules 下的业务域目录,而不是 src/vendor");
|
||||
});
|
||||
|
||||
test("纯模型文件不得依赖 React(保证可被 node:test 直接测)", () => {
|
||||
const violations: string[] = [];
|
||||
for (const file of allFiles.filter((f) => /(^|\/)model\.tsx?$/.test(rel(f)))) {
|
||||
for (const spec of specifiers(file)) {
|
||||
if (spec === "react" || spec.startsWith("react/")) violations.push(`${rel(file)} -> ${spec}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(violations, [], "model.ts 应只包含可在 Node 中直接执行的纯函数");
|
||||
});
|
||||
|
||||
test("高德 SDK 只在 shared/amap.ts 接入", () => {
|
||||
// 两个热力图曾各自写一份 load + 底图 + 控件 + 色带,导致同类地图长得不一样。
|
||||
const importers = allFiles
|
||||
.filter((f) => rel(f) !== "shared/amap.ts")
|
||||
.filter((f) => specifiers(f).some((s) => s === "@amap/amap-jsapi-loader"))
|
||||
.map(rel);
|
||||
assert.deepEqual(importers, [], "请通过 src/shared/amap.ts 使用高德地图,不要各自加载 SDK");
|
||||
});
|
||||
|
||||
test("Excel 导出只在 shared/xlsx.ts 组装工作簿", () => {
|
||||
// 允许 server 侧直接读 xlsx(导入解析),但不允许前端再次自行拼装 workbook。
|
||||
const offenders = allFiles
|
||||
.filter((f) => rel(f).startsWith("modules/"))
|
||||
.filter((f) => {
|
||||
const source = readFileSync(f, "utf8");
|
||||
return /book_new|book_append_sheet|writeFile\(/.test(source);
|
||||
})
|
||||
.map(rel);
|
||||
assert.deepEqual(offenders, [], "请改用 src/shared/xlsx.ts 的 writeWorkbook / export*Sheet");
|
||||
});
|
||||
|
||||
test("类型豁免清单只减不增:@ts-nocheck 仅限已登记的 8113 原型快照", () => {
|
||||
// 这三个文件是逐字节保留的验收原型(UI/CSS 冻结),显式登记为唯一豁免。
|
||||
const allowed = [
|
||||
"modules/energy/hydrogen/board/EnergyBiBoardApp.tsx",
|
||||
"modules/energy/hydrogen/station-daily/StationDailyApp.tsx",
|
||||
"modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx",
|
||||
];
|
||||
const actual = allFiles.filter((f) => fileContains(f, /^\/\/\s*@ts-nocheck/m)).map(rel).sort();
|
||||
assert.deepEqual(actual, [...allowed].sort(), "新增 @ts-nocheck 会让类型门禁失效,请改为修正类型");
|
||||
});
|
||||
|
||||
test("运行时 DDL 只允许出现在 server/db/schema", () => {
|
||||
// 建表原先散落在路由与 store 里,且由 GET 触发,使"只读预览"仍可能写库。
|
||||
const offenders = allFiles
|
||||
.filter((f) => rel(f).startsWith("server/") && !rel(f).startsWith("server/db/schema/"))
|
||||
.filter((f) => !rel(f).endsWith(".test.ts"))
|
||||
.filter((f) => /CREATE TABLE|ALTER TABLE/.test(readFileSync(f, "utf8")))
|
||||
.map(rel);
|
||||
assert.deepEqual(offenders, [], "请把建表/改表语句集中到 src/server/db/schema/");
|
||||
});
|
||||
|
||||
test("运行时建表必须尊重只读模式", () => {
|
||||
const schemaFiles = allFiles.filter((f) => rel(f).startsWith("server/db/schema/") && rel(f) !== "server/db/schema/guard.ts");
|
||||
const offenders = schemaFiles
|
||||
.filter((f) => !readFileSync(f, "utf8").includes("ddlAllowed()"))
|
||||
.map(rel);
|
||||
assert.deepEqual(offenders, [], "每个 schema 模块都要在 DB_READ_ONLY=1 时跳过 DDL");
|
||||
});
|
||||
|
||||
test("已完整分层的域:repository.ts 存在,且 SQL 只允许出现在那里", () => {
|
||||
// 这些域已完成 routes / repository / model 拆分;其余域仍把 SQL 放在路由里
|
||||
// (见 docs/ARCHITECTURE.md 的"后端业务域形状"表)。
|
||||
const layered = [
|
||||
"server/routes/vehicles",
|
||||
"server/routes/ele",
|
||||
"server/routes/feedback",
|
||||
"server/routes/vehicle-heatmap",
|
||||
"server/routes/hydrogen-heatmap",
|
||||
"server/routes/scheduling",
|
||||
"server/routes/energy",
|
||||
"server/routes/mileage",
|
||||
];
|
||||
// 用"语句形状"而不是裸关键字:UpdateNotification 或 "update error" 这类标识符/日志
|
||||
// 不应误判,而小写 SQL 也依然能被抓到。
|
||||
const SQL = /\bselect\b[\s\S]{0,400}?\bfrom\b|\binsert\s+(into|ignore)\b|\bupdate\s+[\w.`]+\s+set\b|\bdelete\s+from\b|\bcreate\s+table\b|\balter\s+table\b/i;
|
||||
const offenders: string[] = [];
|
||||
for (const dir of layered) {
|
||||
const abs = path.join(srcDir, dir);
|
||||
if (!existsSync(path.join(abs, "repository.ts"))) {
|
||||
offenders.push(`${dir}/repository.ts 缺失`);
|
||||
continue;
|
||||
}
|
||||
for (const file of readdirSync(abs)) {
|
||||
if (!file.endsWith(".ts")) continue;
|
||||
// repository.ts 是 SQL 的归属地;constants.ts 允许存放被多条查询共享的
|
||||
// SQL 片段(如公共 WHERE / CTE),否则这些片段会被迫复制到每个查询里。
|
||||
if (file === "repository.ts" || file === "constants.ts" || file.endsWith(".test.ts")) continue;
|
||||
if (SQL.test(stripComments(readFileSync(path.join(abs, file), "utf8")))) {
|
||||
offenders.push(`${dir}/${file} 仍含 SQL`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offenders, [], "SQL 必须留在各域的 repository.ts");
|
||||
});
|
||||
@@ -5,6 +5,7 @@ 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,
|
||||
@@ -19,8 +20,6 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setTokenGetter(() => tokenRef.current);
|
||||
|
||||
// 防止 StrictMode 双重调用(jumpToken 一次性使用)
|
||||
if (authStarted.current) return;
|
||||
authStarted.current = true;
|
||||
|
||||
// 监听 401 事件
|
||||
const onUnauthorized = () => {
|
||||
@@ -30,14 +29,25 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
window.addEventListener('auth:unauthorized', onUnauthorized);
|
||||
|
||||
authenticate();
|
||||
if (!authStarted.current) { authStarted.current = true; 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 (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
||||
if (currentMode === 'sso' && import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
@@ -59,15 +69,15 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
tokenRef.current = savedToken;
|
||||
// 验证 token 是否仍然有效(尝试请求 health)
|
||||
try {
|
||||
const res = await fetch('/api/health', {
|
||||
const res = await fetch(`${AUTH_API}/me`, {
|
||||
headers: { Authorization: `Bearer ${savedToken}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const savedUser = sessionStorage.getItem('bi_user');
|
||||
const savedUser = await res.json();
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
user: savedUser ? JSON.parse(savedUser) : null,
|
||||
user: savedUser,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
@@ -75,6 +85,12 @@ 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
|
||||
@@ -119,8 +135,18 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithPassword(password: string) {
|
||||
const response = await fetch(`${AUTH_API}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.token) throw new Error(data.message || '登录失败');
|
||||
tokenRef.current = data.token;
|
||||
sessionStorage.setItem('bi_jwt', data.token);
|
||||
sessionStorage.setItem('bi_user', JSON.stringify(data.user));
|
||||
setState({ isLoading: false, isAuthenticated: true, user: data.user, error: null });
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={state}>
|
||||
<AuthContext.Provider value={{ ...state, mode, loginWithPassword }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from './useAuth';
|
||||
|
||||
export default function PasswordLogin() {
|
||||
const { loginWithPassword, error: sessionError } = useAuth();
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
return <main className="min-h-screen flex items-center justify-center bg-slate-950 p-6">
|
||||
<form className="w-full max-w-sm rounded-2xl bg-white p-7 shadow-xl" onSubmit={async event => {
|
||||
event.preventDefault(); if (busy || !loginWithPassword) return;
|
||||
setBusy(true); setError('');
|
||||
try { await loginWithPassword(password); } catch (e) { setError(e instanceof Error ? e.message : '登录失败,请重试'); }
|
||||
finally { setBusy(false); setPassword(''); }
|
||||
}}>
|
||||
<p className="text-xs font-semibold text-blue-600">羚牛 · 能源 BI</p>
|
||||
<h1 className="mt-2 text-2xl font-bold text-slate-900">访问看板</h1>
|
||||
<p className="mt-2 mb-6 text-sm text-slate-500">请输入管理员提供的访问密码。此入口仅提供只读访问。</p>
|
||||
<label htmlFor="bi-password" className="text-sm font-medium text-slate-700">访问密码</label>
|
||||
<input id="bi-password" type="password" autoComplete="current-password" required maxLength={1024} value={password} onChange={e => setPassword(e.target.value)} className="mt-2 w-full rounded-lg border border-slate-300 px-3 py-3 text-base focus:outline-blue-600" />
|
||||
{error || sessionError ? <p role="alert" className="mt-3 text-sm text-red-600">{error || sessionError}</p> : null}
|
||||
<button disabled={busy || !password} className="mt-5 min-h-11 w-full rounded-lg bg-blue-600 px-4 py-3 font-semibold text-white disabled:opacity-50">{busy ? '正在验证…' : '进入看板'}</button>
|
||||
</form>
|
||||
</main>;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export interface AuthState {
|
||||
mode?: 'sso' | 'password';
|
||||
loginWithPassword?: (password: string) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
user: {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
const DemoModeContext = createContext(false);
|
||||
|
||||
export function DemoModeProvider({ enabled, children }: { enabled: boolean; children: ReactNode }) {
|
||||
return <DemoModeContext.Provider value={enabled}>{children}</DemoModeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useDemoMode() {
|
||||
return useContext(DemoModeContext);
|
||||
}
|
||||
|
||||
export default function Blur({ children }: { children: ReactNode }) {
|
||||
const demo = useContext(DemoModeContext);
|
||||
if (!demo) return <>{children}</>;
|
||||
return <span className="blur-[5px] select-none">{children}</span>;
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useState, useEffect, useMemo, type ComponentType, type ElementType, Sus
|
||||
import { motion } from 'motion/react';
|
||||
import { Building2, ChevronRight } from 'lucide-react';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { DemoModeProvider } from './Blur';
|
||||
import FeedbackFab from './FeedbackFab';
|
||||
import { cn } from '../lib/cn';
|
||||
import { LoadingState } from './ui/surface';
|
||||
@@ -93,7 +92,6 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<DemoModeProvider enabled={false}>
|
||||
<div className="enterprise-grid-bg flex min-h-screen">
|
||||
{/* 氢费看板按原型交付;其他模块继续保留全局水印。 */}
|
||||
{!isHydrogenPrototype ? (
|
||||
@@ -220,6 +218,5 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</DemoModeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
MapPin,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { buildJsonSheet, writeWorkbook } from '../../shared/xlsx';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -37,10 +37,10 @@ import {
|
||||
getWeeklyFlowRange,
|
||||
selectFlowDetails,
|
||||
type VehicleModalSelection,
|
||||
type FlowSelection,
|
||||
} from './model';
|
||||
import { SearchSelect } from '../../components/SearchSelect';
|
||||
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
||||
import Blur from '../../components/Blur';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
|
||||
import { AssetsHeader } from './components/AssetsHeader';
|
||||
@@ -87,7 +87,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,16 +384,16 @@ 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 || '',
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(table);
|
||||
const ws = buildJsonSheet(table);
|
||||
ws['!cols'] = [
|
||||
{ wch: 14 },
|
||||
{ wch: 8 },
|
||||
@@ -404,9 +404,7 @@ export default function AssetsModule() {
|
||||
{ wch: 14 },
|
||||
{ wch: 24 },
|
||||
];
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '明细');
|
||||
XLSX.writeFile(wb, `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
|
||||
writeWorkbook([{ name: '明细', sheet: ws }], `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
|
||||
}, [flowRange.end, flowRange.start, flowStats]);
|
||||
|
||||
const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]);
|
||||
@@ -705,7 +703,7 @@ export default function AssetsModule() {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||
<span className="font-bold text-gray-700 text-xs"><Blur>{m.manager}</Blur></span>
|
||||
<span className="font-bold text-gray-700 text-xs">{m.manager}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -787,7 +785,7 @@ export default function AssetsModule() {
|
||||
>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-1">
|
||||
{isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />}
|
||||
<Blur>{m.manager}</Blur>
|
||||
{m.manager}
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{m.department}</td>
|
||||
<td
|
||||
@@ -907,7 +905,7 @@ export default function AssetsModule() {
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />}
|
||||
<span className="text-[11px] font-bold text-gray-700"><Blur>{m.manager}</Blur></span>
|
||||
<span className="text-[11px] font-bold text-gray-700">{m.manager}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -986,7 +984,7 @@ export default function AssetsModule() {
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<h3 className="text-sm font-bold text-gray-800 shrink-0"><Blur>{m.manager}</Blur></h3>
|
||||
<h3 className="text-sm font-bold text-gray-800 shrink-0">{m.manager}</h3>
|
||||
<span className="text-[11px] text-gray-500 shrink-0">{m.department}</span>
|
||||
<div
|
||||
className="text-[11px] font-bold text-blue-600 whitespace-nowrap"
|
||||
@@ -1510,12 +1508,12 @@ export default function AssetsModule() {
|
||||
>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-2">
|
||||
{isExpanded ? <ChevronDown size={14} className="text-emerald-600" /> : <ChevronRight size={14} className="text-gray-400" />}
|
||||
<Blur>{cust.customer}</Blur>
|
||||
{cust.customer}
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">
|
||||
<span className="bg-gray-100 px-2 py-0.5 rounded text-[10px] font-medium">{cust.region}</span>
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{cust.manager}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{cust.manager}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: false, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T` }); }}>{cust.t4_5}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: true, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T冷链` }); }}>{cust.t4_5c}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '18T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 18T` }); }}>{cust.t18}</td>
|
||||
@@ -1530,7 +1528,7 @@ export default function AssetsModule() {
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||
<div className="text-[10px] text-gray-400 uppercase mb-1">客户详情</div>
|
||||
<div className="text-sm font-bold text-gray-700"><Blur>{cust.customer}</Blur></div>
|
||||
<div className="text-sm font-bold text-gray-700">{cust.customer}</div>
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||
<div className="text-[10px] text-gray-400 uppercase mb-1">主要车型</div>
|
||||
@@ -1540,7 +1538,7 @@ export default function AssetsModule() {
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||
<div className="text-[10px] text-gray-400 uppercase mb-1">业务经理</div>
|
||||
<div className="text-sm font-bold text-gray-700"><Blur>{cust.manager}</Blur></div>
|
||||
<div className="text-sm font-bold text-gray-700">{cust.manager}</div>
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||
<div className="text-[10px] text-gray-400 uppercase mb-1">资产占比</div>
|
||||
@@ -1574,7 +1572,7 @@ export default function AssetsModule() {
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? <ChevronDown size={16} className="text-emerald-600" /> : <ChevronRight size={16} className="text-gray-400" />}
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-gray-800 text-sm"><Blur>{cust.customer}</Blur></span>
|
||||
<span className="font-bold text-gray-800 text-sm">{cust.customer}</span>
|
||||
<span className="text-[10px] text-emerald-600 font-medium">{cust.region}区域</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1592,7 +1590,7 @@ export default function AssetsModule() {
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-gray-50 p-2 rounded border border-gray-100">
|
||||
<div className="text-[8px] text-gray-400 uppercase mb-1">客户详情</div>
|
||||
<div className="text-[10px] font-bold text-gray-700"><Blur>{cust.customer}</Blur></div>
|
||||
<div className="text-[10px] font-bold text-gray-700">{cust.customer}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded border border-gray-100">
|
||||
<div className="text-[8px] text-gray-400 uppercase mb-1">主要车型</div>
|
||||
@@ -1602,7 +1600,7 @@ export default function AssetsModule() {
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded border border-gray-100">
|
||||
<div className="text-[8px] text-gray-400 uppercase mb-1">业务经理</div>
|
||||
<div className="text-xs font-bold text-gray-700"><Blur>{cust.manager}</Blur></div>
|
||||
<div className="text-xs font-bold text-gray-700">{cust.manager}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded border border-gray-100">
|
||||
<div className="text-[8px] text-gray-400 uppercase mb-1">资产占比</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import Blur from '../../../components/Blur';
|
||||
import { SearchSelect } from '../../../components/SearchSelect';
|
||||
import type { WeeklyDetailItem } from '../api';
|
||||
import type { ModalVehicleFilters, VehicleModalSelection } from '../model';
|
||||
@@ -66,6 +65,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>
|
||||
@@ -167,8 +167,8 @@ export function VehicleDetailModal({
|
||||
<tbody className="text-[11px]">
|
||||
{filteredModalWeeklyDetail.map((v, i) => (
|
||||
<tr key={`${v.truck_id}-${i}`} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors ${i % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
|
||||
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center"><Blur>{v.plate_number}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{v.customer_name || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center">{v.plate_number}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.customer_name || '—'}</td>
|
||||
<td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -215,12 +215,12 @@ export function VehicleDetailModal({
|
||||
{showPlateNumbers.source === 'customer' ? (
|
||||
<>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.departmentName || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 font-medium text-gray-700"><Blur>{v.customerManager || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 font-medium text-gray-700">{v.customerManager || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.brandLabel || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.type}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]"><Blur>{v.subjectOrg || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800"><Blur>{v.customerName || '—'}</Blur></td>
|
||||
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]">{v.subjectOrg || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800">{v.customerName || '—'}</td>
|
||||
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}>{v.plateNumber || v.vin || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center">
|
||||
<span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${
|
||||
v.status === 'Operating' ? 'bg-green-100 text-green-700' :
|
||||
@@ -233,13 +233,13 @@ export function VehicleDetailModal({
|
||||
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600">{v.location === '其他' ? '对接中' : v.location}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center font-bold text-orange-600">{'—'}</td>
|
||||
<td className="p-2 text-gray-500 text-[10px]"><Blur>{v.orgName || '—'}</Blur></td>
|
||||
<td className="p-2 text-gray-500 text-[10px]">{v.orgName || '—'}</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
|
||||
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}>{v.plateNumber || v.vin || '—'}</td>
|
||||
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center"><Blur>{v.customerName || '—'}</Blur></td>
|
||||
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center">{v.customerName || '—'}</td>
|
||||
)}
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td>
|
||||
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td>
|
||||
|
||||
@@ -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,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 +0,0 @@
|
||||
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 {
|
||||
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';
|
||||
|
||||
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';
|
||||
const [pick, setPick] = useState<RangeMode>('last15');
|
||||
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [highlightedDate, setHighlightedDate] = useState<string | null>(null);
|
||||
const [datasets, setDatasets] = useState<DailyDatasets>({ lingniu: null, external: null });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
|
||||
const [details, setDetails] = useState<Record<string, DailyDetailState>>({});
|
||||
|
||||
const effectiveRange = useMemo(
|
||||
() => normalizeRange(dateRange.start, dateRange.end),
|
||||
[dateRange.start, dateRange.end],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onRangeTextChange?.(`${effectiveRange.start} 至 ${effectiveRange.end}`);
|
||||
}, [effectiveRange.start, effectiveRange.end, onRangeTextChange]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const query = pick === 'custom'
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||
: { range: pick };
|
||||
|
||||
Promise.all([
|
||||
fetchHydrogenDaily(query, 'lingniu'),
|
||||
fetchHydrogenDaily(query, 'external'),
|
||||
])
|
||||
.then(([lingniu, external]) => {
|
||||
if (cancelled) return;
|
||||
setDatasets({ lingniu, external });
|
||||
setUpdatedAt(formatUpdatedAt(new Date()));
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [pick, effectiveRange.start, effectiveRange.end, refreshKey]);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scope !== 'station') return;
|
||||
if (selectedStationId !== null && stationOptions.some((station) => station.id === selectedStationId)) return;
|
||||
setSelectedStationId(stationOptions[0]?.id ?? null);
|
||||
}, [scope, selectedStationId, stationOptions]);
|
||||
|
||||
const scopedRows = useMemo(() => {
|
||||
if (scope !== 'station' || selectedStationId === null) return vehicleRows;
|
||||
return filterHydrogenRowsByStation(vehicleRows, selectedStationId);
|
||||
}, [scope, selectedStationId, vehicleRows]);
|
||||
|
||||
const summary = useMemo(
|
||||
() => summarizeHydrogenRows(scopedRows),
|
||||
[scopedRows],
|
||||
);
|
||||
const trend = useMemo(
|
||||
() => buildHydrogenDailyTrend(datasets.lingniu, datasets.external, vehicleScope, scope === 'station' ? selectedStationId : null),
|
||||
[datasets.external, datasets.lingniu, scope, selectedStationId, vehicleScope],
|
||||
);
|
||||
|
||||
const selectedStation = stationOptions.find((station) => station.id === selectedStationId);
|
||||
|
||||
const dayCount = useMemo(() => {
|
||||
const start = new Date(effectiveRange.start).getTime();
|
||||
const end = new Date(effectiveRange.end).getTime();
|
||||
return Math.max(1, Math.round(Math.abs(end - start) / (24 * 3600 * 1000)) + 1);
|
||||
}, [effectiveRange.end, effectiveRange.start]);
|
||||
|
||||
const lingniuSum = useMemo(() => {
|
||||
return (datasets.lingniu ?? []).reduce((s, r) => s + r.totalKg, 0);
|
||||
}, [datasets.lingniu]);
|
||||
|
||||
const externalSum = useMemo(() => {
|
||||
return (datasets.external ?? []).reduce((s, r) => s + r.totalKg, 0);
|
||||
}, [datasets.external]);
|
||||
|
||||
const loadDetail = async (date: string, force = false) => {
|
||||
if (!force && details[date]?.data) return;
|
||||
setDetails((previous) => ({
|
||||
...previous,
|
||||
[date]: { loading: true, data: previous[date]?.data ?? null, error: null },
|
||||
}));
|
||||
try {
|
||||
const data = await fetchHydrogenDailyDetail(
|
||||
date,
|
||||
vehicleScope,
|
||||
scope === 'station' ? selectedStationId : null,
|
||||
verifyScope,
|
||||
);
|
||||
setDetails((previous) => ({
|
||||
...previous,
|
||||
[date]: { loading: false, data, error: null },
|
||||
}));
|
||||
} catch (reason) {
|
||||
setDetails((previous) => ({
|
||||
...previous,
|
||||
[date]: {
|
||||
loading: false,
|
||||
data: previous[date]?.data ?? null,
|
||||
error: reason instanceof Error ? reason.message : String(reason),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRow = (date: string) => {
|
||||
setExpanded((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(date)) {
|
||||
next.delete(date);
|
||||
} else {
|
||||
next.add(date);
|
||||
void loadDetail(date);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectDate = (date: string) => {
|
||||
setExpanded((previous) => new Set(previous).add(date));
|
||||
void loadDetail(date);
|
||||
setHighlightedDate(date);
|
||||
const element = document.getElementById(`hydrogen-daily-row-${date}`);
|
||||
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
};
|
||||
|
||||
if (loading && !scopedRows) {
|
||||
return <LoadingState label="正在加载氢气按日看板数据..." />;
|
||||
}
|
||||
|
||||
if (error && !scopedRows) {
|
||||
return <ErrorState message={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<DailyRangeControls
|
||||
pick={pick}
|
||||
dateRange={dateRange}
|
||||
customer={vehicleScope === 'all' ? 'all' : vehicleScope === 'lingniu' ? 'lingniu' : 'external'}
|
||||
stations={scope === 'station' ? stationOptions : undefined}
|
||||
selectedStationId={scope === 'station' ? selectedStationId : undefined}
|
||||
vehicleScope={vehicleScope}
|
||||
updatedAt={updatedAt}
|
||||
loading={loading}
|
||||
onQuickPick={(p: DateQuickPick) => {
|
||||
setPick(p);
|
||||
setDateRange(getQuickRange(p));
|
||||
}}
|
||||
onCustomPick={() => setPick('custom')}
|
||||
onDateRangeChange={(field, value) => {
|
||||
setPick('custom');
|
||||
setDateRange((prev) => ({ ...prev, [field]: value }));
|
||||
}}
|
||||
onCustomerChange={(cust: CustomerType) => {
|
||||
setVehicleScope(cust === 'all' ? 'all' : cust === 'lingniu' ? 'lingniu' : 'external');
|
||||
}}
|
||||
onStationChange={scope === 'station' ? setSelectedStationId : undefined}
|
||||
onVehicleScopeChange={setVehicleScope}
|
||||
onRefresh={() => setRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
|
||||
{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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRefreshKey((k) => k + 1)}
|
||||
className="inline-flex items-center gap-1 font-bold text-amber-900 hover:underline"
|
||||
>
|
||||
<RefreshCw size={12} /> 重试
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{scope === 'station' && selectedStation ? (
|
||||
<StationDailyOverview
|
||||
station={selectedStation}
|
||||
totalKg={summary.totalKg}
|
||||
totalFee={summary.totalFee}
|
||||
averagePrice={summary.avgPrice}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DailyKpiGrid
|
||||
rangeLabel={getRangeModeLabel(pick)}
|
||||
rangeText={`${effectiveRange.start} 至 ${effectiveRange.end}`}
|
||||
totalKg={summary.totalKg}
|
||||
activeDays={summary.activeDays}
|
||||
dayCount={dayCount}
|
||||
averageKg={summary.avgKg}
|
||||
stationCount={summary.stationCount}
|
||||
vehicleScope={vehicleScope}
|
||||
lingniuKg={lingniuSum}
|
||||
externalKg={externalSum}
|
||||
selectedStationName={scope === 'station' ? selectedStation?.name : undefined}
|
||||
/>
|
||||
|
||||
<DailyTrendChart
|
||||
rows={trend}
|
||||
averageKg={summary.avgKg}
|
||||
peakDay={summary.peakDay}
|
||||
lowDay={summary.lowDay}
|
||||
zeroDays={summary.zeroDays}
|
||||
selectedDate={highlightedDate}
|
||||
onSelectDate={handleSelectDate}
|
||||
/>
|
||||
|
||||
{scopedRows && scopedRows.length > 0 ? (
|
||||
<DailyDetailTable
|
||||
rows={scopedRows}
|
||||
totalKg={summary.totalKg}
|
||||
totalFee={summary.totalFee}
|
||||
expanded={expanded}
|
||||
highlightedDate={highlightedDate}
|
||||
details={details}
|
||||
onToggle={toggleRow}
|
||||
onRetryDetail={(date) => void loadDetail(date, true)}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="当前筛选条件下没有加氢记录"
|
||||
description="可尝试切换统计区间、车辆范围或站点。"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
fetchHydrogenOverview,
|
||||
fetchHydrogenOverviewDetail,
|
||||
type HydrogenOverviewResponse,
|
||||
type HydrogenVehicleScope,
|
||||
type HydrogenVerifyScope,
|
||||
} from './api';
|
||||
import type { HydrogenBoardScope } from './HydrogenBoardChrome';
|
||||
import { DistributionCharts } from './hydrogen-overview/components/DistributionCharts';
|
||||
import { HydrogenOverviewSkeleton } from './hydrogen-overview/components/HydrogenOverviewSkeleton';
|
||||
import { InsightCards } from './hydrogen-overview/components/InsightCards';
|
||||
import { KpiSection } from './hydrogen-overview/components/KpiSection';
|
||||
import { MonthlyCharts } from './hydrogen-overview/components/MonthlyCharts';
|
||||
import { OverviewDrillTreeDialog } from './hydrogen-overview/components/OverviewDrillTreeDialog';
|
||||
import { OverviewHeader } from './hydrogen-overview/components/OverviewHeader';
|
||||
import { RefreshOverlay } from './hydrogen-overview/components/RefreshOverlay';
|
||||
import { CustomerSummaryTable, StationSummaryTable } from './hydrogen-overview/components/SummaryTables';
|
||||
import {
|
||||
deriveOverviewMetrics,
|
||||
formatYuan,
|
||||
type OverviewDrillRequest,
|
||||
type OverviewScope,
|
||||
} from './hydrogen-overview/model';
|
||||
import type {
|
||||
HydrogenOverviewDetailGroupBy,
|
||||
HydrogenOverviewDetailResponse,
|
||||
} from './types';
|
||||
import './styles/energy-bi-board.css';
|
||||
|
||||
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);
|
||||
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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);
|
||||
|
||||
try {
|
||||
const result = await fetchHydrogenOverview({
|
||||
year: activeYear,
|
||||
vehicleScope,
|
||||
verifyScope,
|
||||
stationId: effectiveStationId,
|
||||
force,
|
||||
});
|
||||
setData(result);
|
||||
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));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [activeYear, currentYear, effectiveStationId, onRangeTextChange, vehicleScope, verifyScope]);
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, [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 (request.kind === 'customer' && !request.entityId) selection.customerName = request.key;
|
||||
if (request.kind === 'month') selection.month = request.key;
|
||||
if (request.kind === 'region') selection.region = request.key;
|
||||
|
||||
setDrillTree({
|
||||
title: request.label || '数据下钻穿透',
|
||||
selection,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDrillLoad = useCallback(async (
|
||||
groupBy: HydrogenOverviewDetailGroupBy | null,
|
||||
selection: DrillSelection,
|
||||
scopeParam: HydrogenVehicleScope,
|
||||
includeAll = false,
|
||||
): Promise<HydrogenOverviewDetailResponse> => {
|
||||
return fetchHydrogenOverviewDetail({
|
||||
year: activeYear,
|
||||
vehicleScope: scopeParam,
|
||||
verifyScope,
|
||||
stationId: selection.stationId ?? effectiveStationId,
|
||||
customerId: selection.customerId ?? null,
|
||||
customerName: selection.customerName ?? null,
|
||||
plateNo: selection.plateNo ?? null,
|
||||
month: selection.month ?? null,
|
||||
date: selection.date ?? null,
|
||||
region: selection.region ?? null,
|
||||
groupBy,
|
||||
limit: includeAll ? 500 : 50,
|
||||
});
|
||||
}, [activeYear, effectiveStationId, verifyScope]);
|
||||
|
||||
const derived = useMemo(() => {
|
||||
if (!data) return null;
|
||||
return deriveOverviewMetrics(data);
|
||||
}, [data]);
|
||||
|
||||
if (loading && !data) {
|
||||
return <HydrogenOverviewSkeleton />;
|
||||
}
|
||||
|
||||
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: '元' };
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverviewHeader
|
||||
activeYear={activeYear}
|
||||
availableYears={DEFAULT_AVAILABLE_YEARS}
|
||||
vehicleScope={vehicleScope}
|
||||
verifyScope={verifyScope}
|
||||
latestLedgerTime={data?.latestLedgerTime ?? null}
|
||||
lastRefreshAt={lastRefreshAt}
|
||||
refreshing={refreshing}
|
||||
onSelectYear={(y) => setActiveYear(y)}
|
||||
onVehicleScopeChange={(vs) => setVehicleScope(vs)}
|
||||
onVerifyScopeChange={setVerifyScope}
|
||||
onRefresh={() => void load(true)}
|
||||
/>
|
||||
|
||||
{data && derived ? (
|
||||
<section className="ehb-host" aria-label="经营总览">
|
||||
<KpiSection
|
||||
kpi={data.kpi}
|
||||
scope={scope}
|
||||
scopeLabel={selectedStationName}
|
||||
onDrillRequest={handleDrillRequest}
|
||||
/>
|
||||
|
||||
<InsightCards
|
||||
monthAvgKg={derived.monthAvgKg}
|
||||
bestMonth={derived.bestMonth}
|
||||
latestMonth={derived.latestMonth}
|
||||
monthMomentum={derived.monthMomentum}
|
||||
top5Share={derived.top5Share}
|
||||
customerGrossMarginPct={derived.customerGrossMarginPct}
|
||||
stationAvgKg={derived.stationAvgKg}
|
||||
stationCount={data.stations.length}
|
||||
yearProfitValue={yearProfitFmt.value}
|
||||
yearProfitUnit={yearProfitFmt.unit}
|
||||
yearRevenueValue={yearRevenueFmt.value}
|
||||
yearRevenueUnit={yearRevenueFmt.unit}
|
||||
stations={data.stations}
|
||||
onSelectStation={(stId) => {
|
||||
setInternalStationId(stId);
|
||||
onSelectStation?.(stId);
|
||||
}}
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RefreshOverlay refreshing={refreshing} hasData={Boolean(data)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
import HydrogenOverview from './HydrogenOverview';
|
||||
import HydrogenDaily from './HydrogenDaily';
|
||||
import HydrogenSettlement from './HydrogenSettlement';
|
||||
import type { HydrogenBoardScope } from './HydrogenBoardChrome';
|
||||
|
||||
export type HydrogenSubTab = 'daily' | 'overview' | 'settlement';
|
||||
|
||||
interface Props {
|
||||
sub: HydrogenSubTab;
|
||||
onSubChange?: (sub: HydrogenSubTab) => void;
|
||||
onScopeChange?: (scope: HydrogenBoardScope) => void;
|
||||
onDailyRangeTextChange?: (rangeText: string) => void;
|
||||
onOverviewRangeTextChange?: (rangeText: string) => void;
|
||||
}
|
||||
|
||||
export default function HydrogenView({
|
||||
sub,
|
||||
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} />;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
+21
-153
@@ -1,117 +1,17 @@
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import type {
|
||||
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
|
||||
HydrogenCustomerRow, HydrogenStationFull,
|
||||
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
|
||||
CustomerType, DateQuickPick,
|
||||
HydrogenStationBoardResponse,
|
||||
HydrogenDailyDetailResponse,
|
||||
HydrogenOverviewDetailGroupBy,
|
||||
HydrogenOverviewDetailResponse,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api/energy';
|
||||
|
||||
export interface HydrogenOverviewResponse {
|
||||
kpi: HydrogenKpi;
|
||||
top5: HydrogenStationTop[];
|
||||
regions: HydrogenRegionShare[];
|
||||
monthly: HydrogenMonthlyPoint[];
|
||||
customers: HydrogenCustomerRow[];
|
||||
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> {
|
||||
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');
|
||||
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[]> {
|
||||
const q = new URLSearchParams({ customer });
|
||||
if (query.range) q.set('range', query.range);
|
||||
if (query.startDate) q.set('startDate', query.startDate);
|
||||
if (query.endDate) q.set('endDate', query.endDate);
|
||||
if (query.verifyScope === 'verified') q.set('verifyScope', 'verified');
|
||||
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
|
||||
}
|
||||
|
||||
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()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 氢能单站日报数据源。
|
||||
* 注意:该接口路径仍带 `/hydrogen` 前缀(历史命名),但为当前单站日报页面在用,
|
||||
* 与已下线的 `/hydrogen/overview|daily|settlement` 等旧接口无关。
|
||||
*/
|
||||
export interface HydrogenStationBoardQuery {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
@@ -126,6 +26,13 @@ export function fetchHydrogenStationBoard(query: HydrogenStationBoardQuery): Pro
|
||||
return fetchJson<HydrogenStationBoardResponse>(`${BASE}/hydrogen/station-board?${params.toString()}`);
|
||||
}
|
||||
|
||||
/** 电能与 ETC 共用的自然日区间查询参数。 */
|
||||
export interface EnergyRangeQuery {
|
||||
range?: DateQuickPick;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export interface ElectricOverviewResponse {
|
||||
kpi: ElectricKpi;
|
||||
trend: ElectricDailyRow[];
|
||||
@@ -136,12 +43,15 @@ export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
||||
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||
}
|
||||
|
||||
export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
|
||||
const q = new URLSearchParams({ customer });
|
||||
if (query.range) q.set('range', query.range);
|
||||
if (query.startDate) q.set('startDate', query.startDate);
|
||||
if (query.endDate) q.set('endDate', query.endDate);
|
||||
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
||||
export function fetchElectricMonthly(
|
||||
customer: CustomerType,
|
||||
query: EnergyRangeQuery = { range: 'last15' },
|
||||
): Promise<ElectricMonthGroup[]> {
|
||||
const params = new URLSearchParams({ customer });
|
||||
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 fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${params.toString()}`);
|
||||
}
|
||||
|
||||
export interface EtcOverviewResponse {
|
||||
@@ -158,45 +68,3 @@ export interface EtcOverviewResponse {
|
||||
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,6 +1,5 @@
|
||||
import { Calendar, Fuel, RefreshCw, Truck } from 'lucide-react';
|
||||
import type { CustomerType, DateQuickPick } from '../types';
|
||||
import type { HydrogenDailyVehicleScope } from '../hydrogen-daily/model';
|
||||
import { QUICK_PICK_OPTIONS, type RangeMode } from './model';
|
||||
|
||||
interface DailyRangeControlsProps {
|
||||
@@ -9,7 +8,6 @@ interface DailyRangeControlsProps {
|
||||
customer: CustomerType;
|
||||
stations?: { id: number; name: string }[];
|
||||
selectedStationId?: number | null;
|
||||
vehicleScope?: HydrogenDailyVehicleScope;
|
||||
updatedAt?: string | null;
|
||||
loading?: boolean;
|
||||
onQuickPick: (pick: DateQuickPick) => void;
|
||||
@@ -17,7 +15,6 @@ interface DailyRangeControlsProps {
|
||||
onDateRangeChange: (field: 'start' | 'end', value: string) => void;
|
||||
onCustomerChange: (customer: CustomerType) => void;
|
||||
onStationChange?: (stationId: number | null) => void;
|
||||
onVehicleScopeChange?: (scope: HydrogenDailyVehicleScope) => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
@@ -27,7 +24,6 @@ export default function DailyRangeControls({
|
||||
customer,
|
||||
stations = [],
|
||||
selectedStationId = null,
|
||||
vehicleScope,
|
||||
updatedAt,
|
||||
loading = false,
|
||||
onQuickPick,
|
||||
@@ -35,7 +31,6 @@ export default function DailyRangeControls({
|
||||
onDateRangeChange,
|
||||
onCustomerChange,
|
||||
onStationChange,
|
||||
onVehicleScopeChange,
|
||||
onRefresh,
|
||||
}: DailyRangeControlsProps) {
|
||||
return (
|
||||
@@ -92,47 +87,23 @@ export default function DailyRangeControls({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{onVehicleScopeChange && vehicleScope ? (
|
||||
<div className="flex rounded-md bg-slate-100 p-0.5" aria-label="车辆归属">
|
||||
{([
|
||||
['all', '全部车辆'],
|
||||
['lingniu', '仅羚牛车辆'],
|
||||
['external', '仅外部车辆'],
|
||||
] as const).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onVehicleScopeChange(id)}
|
||||
className={`inline-flex h-8 items-center gap-1.5 rounded px-2.5 text-[12px] font-medium transition-colors ${
|
||||
vehicleScope === id
|
||||
? 'bg-white font-semibold text-blue-600 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
<Truck size={13} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex rounded-md bg-slate-100 p-0.5">
|
||||
{(['lingniu', 'external'] as const).map(option => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => onCustomerChange(option)}
|
||||
className={`inline-flex h-8 items-center gap-1.5 rounded px-3 text-[12px] font-medium transition-colors ${
|
||||
customer === option
|
||||
? 'bg-white font-semibold text-blue-600 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
<Truck size={13} />
|
||||
{option === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex rounded-md bg-slate-100 p-0.5">
|
||||
{(['lingniu', 'external'] as const).map(option => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => onCustomerChange(option)}
|
||||
className={`inline-flex h-8 items-center gap-1.5 rounded px-3 text-[12px] font-medium transition-colors ${
|
||||
customer === option
|
||||
? 'bg-white font-semibold text-blue-600 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
<Truck size={13} />
|
||||
{option === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{updatedAt ? <span className="font-mono text-[11px] text-slate-400">{updatedAt}</span> : null}
|
||||
|
||||
@@ -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,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);
|
||||
};
|
||||
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,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,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>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { HydrogenDailyRow } from '../types.js';
|
||||
import {
|
||||
buildHydrogenDailyTrend,
|
||||
getQuickRange,
|
||||
filterHydrogenRowsByStation,
|
||||
getHydrogenDailyStations,
|
||||
getRangeModeLabel,
|
||||
mergeHydrogenDailyRows,
|
||||
normalizeRange,
|
||||
summarizeHydrogenRows,
|
||||
} from './model.js';
|
||||
|
||||
test('快捷日期继续使用本地自然日并覆盖本周、本月和近15天', () => {
|
||||
const now = new Date(2026, 7, 12, 23, 30);
|
||||
assert.deepEqual(getQuickRange('thisWeek', now), {
|
||||
start: '2026-08-10',
|
||||
end: '2026-08-12',
|
||||
});
|
||||
assert.deepEqual(getQuickRange('thisMonth', now), {
|
||||
start: '2026-08-01',
|
||||
end: '2026-08-12',
|
||||
});
|
||||
assert.deepEqual(getQuickRange('last15', now), {
|
||||
start: '2026-07-29',
|
||||
end: '2026-08-12',
|
||||
});
|
||||
});
|
||||
|
||||
test('全部车辆由两类真实日报按日期和站点合并', () => {
|
||||
const lingniu: HydrogenDailyRow[] = [{
|
||||
date: '2026-08-11', totalKg: 100, totalFee: 2_400, chainPct: 0, customerType: 'lingniu',
|
||||
stations: [{ id: 1, name: 'A站', kg: 100, fee: 2_400, pricePerKg: 24, chainPct: 0 }],
|
||||
}];
|
||||
const external: HydrogenDailyRow[] = [{
|
||||
date: '2026-08-11', totalKg: 50, totalFee: 1_000, chainPct: 0, customerType: 'external',
|
||||
stations: [{ id: 1, name: 'A站', kg: 50, fee: 1_000, pricePerKg: 20, chainPct: 0 }],
|
||||
}];
|
||||
|
||||
const merged = mergeHydrogenDailyRows(lingniu, external)!;
|
||||
assert.equal(merged[0].totalKg, 150);
|
||||
assert.equal(merged[0].totalFee, 3_400);
|
||||
assert.equal(merged[0].stations[0].pricePerKg, 22.67);
|
||||
assert.deepEqual(buildHydrogenDailyTrend(lingniu, external, 'all', null), [{
|
||||
date: '2026-08-11', totalKg: 150, lingniuKg: 100, externalKg: 50,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('趋势数据同时遵守车辆归属和单站筛选', () => {
|
||||
const lingniu: HydrogenDailyRow[] = [{
|
||||
date: '2026-08-11', totalKg: 130, totalFee: 3_000, chainPct: 0, customerType: 'lingniu',
|
||||
stations: [
|
||||
{ id: 1, name: 'A站', kg: 80, fee: 1_920, pricePerKg: 24, chainPct: 0 },
|
||||
{ id: 2, name: 'B站', kg: 50, fee: 1_080, pricePerKg: 21.6, chainPct: 0 },
|
||||
],
|
||||
}];
|
||||
const external: HydrogenDailyRow[] = [{
|
||||
date: '2026-08-11', totalKg: 20, totalFee: 400, chainPct: 0, customerType: 'external',
|
||||
stations: [{ id: 1, name: 'A站', kg: 20, fee: 400, pricePerKg: 20, chainPct: 0 }],
|
||||
}];
|
||||
|
||||
assert.deepEqual(buildHydrogenDailyTrend(lingniu, external, 'lingniu', 1), [{
|
||||
date: '2026-08-11', totalKg: 80, lingniuKg: 80, externalKg: 0,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('自定义日期倒置时仅交换查询边界', () => {
|
||||
assert.deepEqual(normalizeRange('2026-08-12', '2026-08-01'), {
|
||||
start: '2026-08-01',
|
||||
end: '2026-08-12',
|
||||
});
|
||||
assert.equal(getRangeModeLabel('custom'), '自定义区间');
|
||||
assert.equal(getRangeModeLabel('last15'), '近 15 天');
|
||||
});
|
||||
|
||||
test('每日加氢统计保持排序、有效天、站点去重和峰谷口径', () => {
|
||||
const rows: HydrogenDailyRow[] = [
|
||||
{
|
||||
date: '2026-08-12',
|
||||
totalKg: 0,
|
||||
totalFee: 0,
|
||||
chainPct: -1,
|
||||
customerType: 'lingniu',
|
||||
stations: [{ id: 1, name: 'A站', kg: 0, fee: 0, pricePerKg: 0, chainPct: -1 }],
|
||||
},
|
||||
{
|
||||
date: '2026-08-10',
|
||||
totalKg: 100,
|
||||
totalFee: 2_000,
|
||||
chainPct: 0,
|
||||
customerType: 'lingniu',
|
||||
stations: [{ id: 1, name: 'A站', kg: 100, fee: 2_000, pricePerKg: 20, chainPct: 0 }],
|
||||
},
|
||||
{
|
||||
date: '2026-08-11',
|
||||
totalKg: 300,
|
||||
totalFee: 7_500,
|
||||
chainPct: 2,
|
||||
customerType: 'lingniu',
|
||||
stations: [{ id: 2, name: 'B站', kg: 300, fee: 7_500, pricePerKg: 25, chainPct: 2 }],
|
||||
},
|
||||
];
|
||||
|
||||
const summary = summarizeHydrogenRows(rows);
|
||||
assert.deepEqual(rows.map(row => row.date), ['2026-08-12', '2026-08-10', '2026-08-11']);
|
||||
assert.deepEqual(summary.trendData.map(row => row.date), ['2026-08-10', '2026-08-11', '2026-08-12']);
|
||||
assert.equal(summary.totalKg, 400);
|
||||
assert.equal(summary.totalFee, 9_500);
|
||||
assert.equal(summary.activeDays, 2);
|
||||
assert.equal(summary.avgKg, 200);
|
||||
assert.equal(summary.avgPrice, 23.75);
|
||||
assert.equal(summary.stationCount, 2);
|
||||
assert.equal(summary.peakDay?.date, '2026-08-11');
|
||||
assert.equal(summary.lowDay?.date, '2026-08-10');
|
||||
assert.equal(summary.zeroDays, 1);
|
||||
});
|
||||
|
||||
test('空数据保持零值且没有峰谷日', () => {
|
||||
assert.deepEqual(summarizeHydrogenRows(null), {
|
||||
trendData: [],
|
||||
totalKg: 0,
|
||||
totalFee: 0,
|
||||
activeDays: 0,
|
||||
stationCount: 0,
|
||||
avgKg: 0,
|
||||
avgPrice: 0,
|
||||
peakDay: null,
|
||||
lowDay: null,
|
||||
zeroDays: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('站点筛选按站点 ID 重算日报总量、费用和环比', () => {
|
||||
const rows: HydrogenDailyRow[] = [
|
||||
{
|
||||
date: '2026-08-11', totalKg: 150, totalFee: 3_400, chainPct: 0, customerType: 'lingniu',
|
||||
stations: [
|
||||
{ id: 1, name: 'A站', kg: 100, fee: 2_400, pricePerKg: 24, chainPct: 0 },
|
||||
{ id: 2, name: 'B站', kg: 50, fee: 1_000, pricePerKg: 20, chainPct: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2026-08-10', totalKg: 80, totalFee: 1_760, chainPct: 0, customerType: 'lingniu',
|
||||
stations: [{ id: 1, name: 'A站', kg: 80, fee: 1_760, pricePerKg: 22, chainPct: 0 }],
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(getHydrogenDailyStations(rows), [
|
||||
{ id: 1, name: 'A站', totalKg: 180, totalFee: 4_160 },
|
||||
{ id: 2, name: 'B站', totalKg: 50, totalFee: 1_000 },
|
||||
]);
|
||||
|
||||
const filtered = filterHydrogenRowsByStation(rows, 1)!;
|
||||
assert.deepEqual(filtered.map(row => ({ date: row.date, kg: row.totalKg, fee: row.totalFee, chain: row.chainPct })), [
|
||||
{ date: '2026-08-11', kg: 100, fee: 2_400, chain: 0.25 },
|
||||
{ date: '2026-08-10', kg: 80, fee: 1_760, chain: 0 },
|
||||
]);
|
||||
assert.equal(summarizeHydrogenRows(filtered).totalFee, 4_160);
|
||||
});
|
||||
@@ -1,208 +0,0 @@
|
||||
import type { HydrogenDailyRow } from '../types';
|
||||
|
||||
export {
|
||||
formatYmd,
|
||||
getQuickRange,
|
||||
getRangeModeLabel,
|
||||
normalizeRange,
|
||||
QUICK_PICK_OPTIONS,
|
||||
type RangeMode,
|
||||
} from '../daily-range/model';
|
||||
|
||||
export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
|
||||
const source = rows ?? [];
|
||||
// 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。
|
||||
const trendData = [...source].sort((left, right) => left.date.localeCompare(right.date));
|
||||
const totalKg = source.reduce((total, row) => total + row.totalKg, 0);
|
||||
const totalFee = source.reduce((total, row) => total + row.totalFee, 0);
|
||||
const activeDays = source.filter(row => row.totalKg > 0).length;
|
||||
const stationIds = new Set<number>();
|
||||
source.forEach(row => row.stations.forEach(station => stationIds.add(station.id)));
|
||||
const peakDay = trendData.reduce<HydrogenDailyRow | null>(
|
||||
(best, item) => (!best || item.totalKg > best.totalKg ? item : best),
|
||||
null,
|
||||
);
|
||||
const lowDay = trendData
|
||||
.filter(item => item.totalKg > 0)
|
||||
.reduce<HydrogenDailyRow | null>(
|
||||
(low, item) => (!low || item.totalKg < low.totalKg ? item : low),
|
||||
null,
|
||||
);
|
||||
|
||||
return {
|
||||
trendData,
|
||||
totalKg,
|
||||
totalFee,
|
||||
activeDays,
|
||||
stationCount: stationIds.size,
|
||||
avgKg: activeDays > 0 ? totalKg / activeDays : 0,
|
||||
avgPrice: totalKg > 0 ? totalFee / totalKg : 0,
|
||||
peakDay,
|
||||
lowDay,
|
||||
zeroDays: source.filter(row => row.totalKg === 0).length,
|
||||
};
|
||||
}
|
||||
|
||||
export interface HydrogenDailyStationOption {
|
||||
id: number;
|
||||
name: string;
|
||||
totalKg: number;
|
||||
totalFee: number;
|
||||
}
|
||||
|
||||
export type HydrogenDailyVehicleScope = 'all' | 'lingniu' | 'external';
|
||||
export type HydrogenDailyBoardScope = 'global' | 'station';
|
||||
|
||||
export interface HydrogenDailyTrendPoint {
|
||||
date: string;
|
||||
totalKg: number;
|
||||
lingniuKg: number;
|
||||
externalKg: number;
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* “全部车辆”由羚牛与外部车辆两次真实查询合并得到。合并只累加账本
|
||||
* 已返回的加氢量和成本,不引入原型里的演示数据。
|
||||
*/
|
||||
export function mergeHydrogenDailyRows(
|
||||
lingniuRows: HydrogenDailyRow[] | null,
|
||||
externalRows: HydrogenDailyRow[] | null,
|
||||
): HydrogenDailyRow[] | null {
|
||||
if (lingniuRows === null || externalRows === null) return null;
|
||||
|
||||
const rowsByDate = new Map<string, HydrogenDailyRow[]>();
|
||||
for (const row of [...lingniuRows, ...externalRows]) {
|
||||
const rows = rowsByDate.get(row.date) ?? [];
|
||||
rows.push(row);
|
||||
rowsByDate.set(row.date, rows);
|
||||
}
|
||||
|
||||
const merged = [...rowsByDate.entries()].map(([date, rows]) => {
|
||||
const stationsById = new Map<number, HydrogenDailyRow['stations'][number]>();
|
||||
for (const row of rows) {
|
||||
for (const station of row.stations) {
|
||||
const current = stationsById.get(station.id);
|
||||
const kg = round2((current?.kg ?? 0) + station.kg);
|
||||
const fee = round2((current?.fee ?? 0) + station.fee);
|
||||
stationsById.set(station.id, {
|
||||
id: station.id,
|
||||
name: station.name || current?.name || `站点 #${station.id}`,
|
||||
kg,
|
||||
fee,
|
||||
// 跨车辆归属聚合后展示实际成本加权均价。
|
||||
pricePerKg: kg > 0 ? round2(fee / kg) : 0,
|
||||
chainPct: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
const stations = [...stationsById.values()].sort((left, right) => right.kg - left.kg);
|
||||
return {
|
||||
date,
|
||||
totalKg: round2(stations.reduce((sum, station) => sum + station.kg, 0)),
|
||||
totalFee: round2(stations.reduce((sum, station) => sum + station.fee, 0)),
|
||||
chainPct: 0,
|
||||
customerType: 'lingniu' as const,
|
||||
stations,
|
||||
};
|
||||
});
|
||||
|
||||
return recomputeHydrogenDailyChains(merged);
|
||||
}
|
||||
|
||||
function recomputeHydrogenDailyChains(rows: HydrogenDailyRow[]): HydrogenDailyRow[] {
|
||||
const ascending = [...rows]
|
||||
.map(row => ({ ...row, stations: row.stations.map(station => ({ ...station })) }))
|
||||
.sort((left, right) => left.date.localeCompare(right.date));
|
||||
|
||||
let previousTotalKg = 0;
|
||||
const stationPreviousKg = new Map<number, number>();
|
||||
for (const row of ascending) {
|
||||
row.chainPct = previousTotalKg > 0 ? (row.totalKg - previousTotalKg) / previousTotalKg : 0;
|
||||
previousTotalKg = row.totalKg;
|
||||
for (const station of row.stations) {
|
||||
const previousStationKg = stationPreviousKg.get(station.id) ?? 0;
|
||||
station.chainPct = previousStationKg > 0 ? (station.kg - previousStationKg) / previousStationKg : 0;
|
||||
stationPreviousKg.set(station.id, station.kg);
|
||||
}
|
||||
}
|
||||
return ascending.sort((left, right) => right.date.localeCompare(left.date));
|
||||
}
|
||||
|
||||
/** Build one stable station selector from the current date range. */
|
||||
export function getHydrogenDailyStations(rows: HydrogenDailyRow[] | null): HydrogenDailyStationOption[] {
|
||||
const stations = new Map<number, HydrogenDailyStationOption>();
|
||||
for (const row of rows ?? []) {
|
||||
for (const station of row.stations) {
|
||||
const current = stations.get(station.id);
|
||||
stations.set(station.id, {
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
totalKg: (current?.totalKg ?? 0) + station.kg,
|
||||
totalFee: (current?.totalFee ?? 0) + station.fee,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...stations.values()].sort((left, right) => right.totalKg - left.totalKg || left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily endpoint returns all stations in the selected date range. This keeps
|
||||
* station drill-down local and recomputes each day's totals and chain change.
|
||||
*/
|
||||
export function filterHydrogenRowsByStation(
|
||||
rows: HydrogenDailyRow[] | null,
|
||||
stationId: number | null,
|
||||
): HydrogenDailyRow[] | null {
|
||||
if (rows === null || stationId === null) return rows;
|
||||
|
||||
const filtered = rows.map(row => {
|
||||
const stations = row.stations.filter(station => station.id === stationId);
|
||||
return {
|
||||
...row,
|
||||
totalKg: stations.reduce((sum, station) => sum + station.kg, 0),
|
||||
totalFee: stations.reduce((sum, station) => sum + station.fee, 0),
|
||||
stations,
|
||||
};
|
||||
});
|
||||
return recomputeHydrogenDailyChains(filtered);
|
||||
}
|
||||
|
||||
/** Build the prototype's blue/orange stacked daily series from real queries. */
|
||||
export function buildHydrogenDailyTrend(
|
||||
lingniuRows: HydrogenDailyRow[] | null,
|
||||
externalRows: HydrogenDailyRow[] | null,
|
||||
vehicleScope: HydrogenDailyVehicleScope,
|
||||
stationId: number | null,
|
||||
): HydrogenDailyTrendPoint[] {
|
||||
if (lingniuRows === null || externalRows === null) return [];
|
||||
const lingniu = filterHydrogenRowsByStation(lingniuRows, stationId) ?? [];
|
||||
const external = filterHydrogenRowsByStation(externalRows, stationId) ?? [];
|
||||
const byDate = new Map<string, HydrogenDailyTrendPoint>();
|
||||
|
||||
for (const row of lingniu) {
|
||||
byDate.set(row.date, {
|
||||
date: row.date,
|
||||
lingniuKg: vehicleScope === 'external' ? 0 : row.totalKg,
|
||||
externalKg: 0,
|
||||
totalKg: vehicleScope === 'external' ? 0 : row.totalKg,
|
||||
});
|
||||
}
|
||||
for (const row of external) {
|
||||
const current = byDate.get(row.date) ?? {
|
||||
date: row.date,
|
||||
lingniuKg: 0,
|
||||
externalKg: 0,
|
||||
totalKg: 0,
|
||||
};
|
||||
const externalKg = vehicleScope === 'lingniu' ? 0 : row.totalKg;
|
||||
current.externalKg = externalKg;
|
||||
current.totalKg = round2(current.lingniuKg + externalKg);
|
||||
byDate.set(row.date, current);
|
||||
}
|
||||
|
||||
return [...byDate.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
LabelList,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { HydrogenRegionShare, HydrogenStationFull, HydrogenStationTop } from '../../types';
|
||||
import {
|
||||
buildRegionDrillPayload,
|
||||
buildStationDrillPayload,
|
||||
type OverviewDrillPayload,
|
||||
type OverviewDrillRequest,
|
||||
type OverviewScope,
|
||||
} from '../model';
|
||||
import { OverviewDrillDialog } from './OverviewDrillDialog';
|
||||
|
||||
const REGION_COLORS = [
|
||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
||||
'#94a3b8',
|
||||
];
|
||||
|
||||
interface YAxisTickProps {
|
||||
x?: number;
|
||||
y?: number;
|
||||
index?: number;
|
||||
payload?: { value: string };
|
||||
}
|
||||
|
||||
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
|
||||
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
||||
{index + 1}
|
||||
</text>
|
||||
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
||||
{payload?.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
interface DistributionChartsProps {
|
||||
top5: HydrogenStationTop[];
|
||||
regions: HydrogenRegionShare[];
|
||||
stations: HydrogenStationFull[];
|
||||
yearKg: number;
|
||||
onSelectStation?: (stationId: number) => void;
|
||||
scope?: OverviewScope;
|
||||
scopeLabel?: string | null;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}
|
||||
|
||||
export function DistributionCharts({ top5, regions, stations, yearKg, onSelectStation, scope = 'global', scopeLabel, onDrillRequest }: DistributionChartsProps) {
|
||||
const [regionGranularity, setRegionGranularity] = useState<'province' | 'city'>('city');
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
const provinceRegions = useMemo(() => {
|
||||
const totals = new Map<string, number>();
|
||||
for (const station of stations) {
|
||||
const province = station.province?.trim() || '未归属';
|
||||
totals.set(province, (totals.get(province) ?? 0) + station.kg);
|
||||
}
|
||||
return [...totals.entries()]
|
||||
.map(([region, kg]) => ({ region, kg, share: kg / Math.max(1, yearKg) }))
|
||||
.sort((a, b) => b.kg - a.kg);
|
||||
}, [stations, yearKg]);
|
||||
const visibleRegions = regionGranularity === 'province' ? provinceRegions : regions;
|
||||
const openStation = (stationId: number) => {
|
||||
const station = stations.find(item => item.id === stationId);
|
||||
if (!station) return;
|
||||
setSelectedStationId(station.id);
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
|
||||
else setDrill(buildStationDrillPayload(station));
|
||||
};
|
||||
const openRegion = (region: HydrogenRegionShare) => {
|
||||
setSelectedStationId(null);
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'region', key: `${regionGranularity}:${region.region}`, label: region.region });
|
||||
else setDrill(buildRegionDrillPayload(region, stations, regionGranularity));
|
||||
};
|
||||
const scopeText = scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : '';
|
||||
return (
|
||||
<>
|
||||
<div className="ehb-two-charts-row">
|
||||
<div className="ehb-chart-box">
|
||||
<div className="ehb-chart-box-head">
|
||||
<div className="ehb-chart-box-title">加氢站加氢量 Top5{scopeText}</div>
|
||||
<div className="ehb-chart-box-meta">单位:Kg</div>
|
||||
</div>
|
||||
<div className="ehb-chart-box-body h-[260px]">
|
||||
<ResponsiveContainer width="100%" height={260} minWidth={0} initialDimension={{ width: 1, height: 260 }}>
|
||||
<BarChart
|
||||
data={top5}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 24, left: 16, bottom: 0 }}
|
||||
barSize={10}
|
||||
className="cursor-pointer"
|
||||
onClick={(state) => {
|
||||
const stationId = (state as { activePayload?: { payload?: HydrogenStationTop }[] } | undefined)?.activePayload?.[0]?.payload?.id;
|
||||
if (stationId) openStation(stationId);
|
||||
}}
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={180}
|
||||
tick={<RankYAxisTick />}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(59, 130, 246, 0.04)' }}
|
||||
contentStyle={{ borderRadius: '12px', fontSize: '12px', padding: '8px 12px', border: 'none', boxShadow: '0 4px 20px rgba(0,0,0,0.08)' }}
|
||||
formatter={(val: unknown) => [`${Number(val ?? 0).toLocaleString('zh-CN')} Kg`, '加氢量']}
|
||||
/>
|
||||
<Bar dataKey="kg" fill="#3b82f6" radius={[0, 4, 4, 0]}>
|
||||
<LabelList dataKey="kg" position="right" formatter={(v: unknown) => {
|
||||
const value = Number(v ?? 0);
|
||||
return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value);
|
||||
}} fontSize={11} fontWeight={700} fill="#475569" />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ehb-chart-box">
|
||||
<div className="ehb-chart-box-head">
|
||||
<div className="ehb-chart-box-title">各区域加氢占比{scopeText}</div>
|
||||
<div className="ehb-mini-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-mini-tab ${regionGranularity === 'province' ? 'is-active' : ''}`}
|
||||
onClick={() => setRegionGranularity('province')}
|
||||
>
|
||||
按省
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-mini-tab ${regionGranularity === 'city' ? 'is-active' : ''}`}
|
||||
onClick={() => setRegionGranularity('city')}
|
||||
>
|
||||
按市
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-chart-box-body h-[260px] flex items-center justify-center">
|
||||
<div className="relative w-1/2 h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={visibleRegions}
|
||||
dataKey="kg"
|
||||
nameKey="region"
|
||||
innerRadius={48}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
className="cursor-pointer outline-none"
|
||||
onClick={(entry) => openRegion(entry as unknown as HydrogenRegionShare)}
|
||||
>
|
||||
{visibleRegions.map((_, i) => (
|
||||
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
|
||||
<div className="text-base font-bold text-slate-700 leading-tight">{(yearKg / 1000).toFixed(2)}T</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||||
{visibleRegions.map((r, i) => (
|
||||
<button key={r.region} type="button" onClick={() => openRegion(r)} className="flex min-w-0 items-center gap-1.5 rounded px-1 py-0.5 text-left hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500">
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
||||
<span className="text-slate-600 truncate">{r.region}</span>
|
||||
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<OverviewDrillDialog
|
||||
payload={drill}
|
||||
onClose={() => { setDrill(null); setSelectedStationId(null); }}
|
||||
onPrimaryAction={selectedStationId && onSelectStation ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
export function HydrogenOverviewSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 animate-pulse">
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
|
||||
<div className="h-3 w-44 bg-slate-100 rounded" />
|
||||
</div>
|
||||
|
||||
{/* 5 卡占位 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-xl bg-slate-100" />
|
||||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="h-7 w-24 bg-slate-200 rounded" />
|
||||
<div className="space-y-1.5 pt-1 border-t border-slate-50">
|
||||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 月度柱图占位 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="flex items-end gap-2 h-[120px]">
|
||||
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
|
||||
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[100, 78, 56, 40, 28].map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded-full bg-slate-200" />
|
||||
<div className="h-3 w-32 bg-slate-100 rounded" />
|
||||
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
|
||||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
|
||||
<div className="h-4 w-28 bg-slate-100 rounded" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1/2 h-[200px] flex items-center justify-center">
|
||||
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-200" />
|
||||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||||
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||
正在加载氢能总览…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { Activity, ChevronDown, Shield, TrendingDown } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { HydrogenMonthlyPoint, HydrogenStationFull } from '../../types';
|
||||
import {
|
||||
buildStationDrillPayload,
|
||||
formatKg as fmtKg,
|
||||
type OverviewDrillPayload,
|
||||
type OverviewDrillRequest,
|
||||
} from '../model';
|
||||
import { OverviewDrillDialog } from './OverviewDrillDialog';
|
||||
|
||||
interface InsightCardsProps {
|
||||
monthAvgKg: number;
|
||||
bestMonth: HydrogenMonthlyPoint | null;
|
||||
latestMonth: HydrogenMonthlyPoint | undefined;
|
||||
monthMomentum: number | null;
|
||||
top5Share: number;
|
||||
customerGrossMarginPct: number;
|
||||
stationAvgKg: number;
|
||||
stationCount: number;
|
||||
yearProfitValue: string;
|
||||
yearProfitUnit: string;
|
||||
yearRevenueValue: string;
|
||||
yearRevenueUnit: string;
|
||||
stations: HydrogenStationFull[];
|
||||
onSelectStation: (stationId: number) => void;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}
|
||||
|
||||
export function InsightCards({
|
||||
monthAvgKg,
|
||||
bestMonth,
|
||||
latestMonth,
|
||||
monthMomentum,
|
||||
top5Share,
|
||||
customerGrossMarginPct,
|
||||
stationAvgKg,
|
||||
stationCount,
|
||||
yearProfitValue,
|
||||
yearProfitUnit,
|
||||
yearRevenueValue,
|
||||
yearRevenueUnit,
|
||||
stations,
|
||||
onSelectStation,
|
||||
onDrillRequest,
|
||||
}: InsightCardsProps) {
|
||||
const [rankingOpen, setRankingOpen] = useState(false);
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
const rankingRef = useRef<HTMLDivElement>(null);
|
||||
const highestKg = stations[0]?.kg || 1;
|
||||
useEffect(() => {
|
||||
if (!rankingOpen) return;
|
||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||
if (rankingRef.current && !rankingRef.current.contains(event.target as Node)) setRankingOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', closeOnOutsideClick);
|
||||
return () => document.removeEventListener('mousedown', closeOnOutsideClick);
|
||||
}, [rankingOpen]);
|
||||
const openStation = (station: HydrogenStationFull) => {
|
||||
setRankingOpen(false);
|
||||
setSelectedStationId(station.id);
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
|
||||
else setDrill(buildStationDrillPayload(station));
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="ehb-insight" aria-label="经营洞察">
|
||||
<div className="ehb-insight__card">
|
||||
<div className="ehb-insight__icon is-down">
|
||||
<TrendingDown size={18} aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<div className="ehb-insight__title">月度加氢异常波动</div>
|
||||
<div className="ehb-insight__value is-neg">
|
||||
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
|
||||
</div>
|
||||
<div className="ehb-insight__desc">
|
||||
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
|
||||
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
|
||||
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`ehb-insight__card ehb-insight__card--rank ${rankingOpen ? 'is-open' : ''}`}
|
||||
ref={rankingRef}
|
||||
onClick={() => setRankingOpen((v) => !v)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title="点击查看加氢站加氢量排名"
|
||||
>
|
||||
<div className="ehb-insight__icon">
|
||||
<Shield size={18} aria-hidden />
|
||||
</div>
|
||||
<div className="ehb-insight__rank-body">
|
||||
<div className="ehb-insight__title">头部加氢站占比</div>
|
||||
<div className="ehb-insight__value">Top5 {top5Share.toFixed(1)}%</div>
|
||||
<div className="ehb-insight__desc">
|
||||
共 {stationCount} 站 · 单站年均 {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit} · 点击展开加氢量排名
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`ehb-insight__rank-chevron ${rankingOpen ? 'is-open' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{rankingOpen && (
|
||||
<div
|
||||
className="ehb-station-rank-dropdown"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="listbox"
|
||||
aria-label="加氢站加氢量排名"
|
||||
>
|
||||
<div className="ehb-station-rank-dropdown__head">
|
||||
<span>加氢站加氢量排名</span>
|
||||
<span className="ehb-station-rank-dropdown__meta">
|
||||
高 → 低 · 共 {stationCount} 站
|
||||
</span>
|
||||
</div>
|
||||
<div className="ehb-station-rank-dropdown__list">
|
||||
{stations.map((station, index) => (
|
||||
<button
|
||||
key={`${station.id}-${station.name}-${index}`}
|
||||
type="button"
|
||||
className="ehb-station-rank-item"
|
||||
onClick={() => openStation(station)}
|
||||
title="点击钻取该站明细"
|
||||
>
|
||||
<span className={`ehb-station-rank-item__rank ${index < 3 ? 'is-top' : ''}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="ehb-station-rank-item__main">
|
||||
<span className="ehb-station-rank-item__name">{station.name}</span>
|
||||
<span className="ehb-station-rank-item__bar">
|
||||
<span style={{ width: `${Math.max(2, station.kg / highestKg * 100)}%` }} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="ehb-station-rank-item__val">
|
||||
{fmtKg(station.kg).value} {fmtKg(station.kg).unit}
|
||||
</span>
|
||||
<span className="ehb-station-rank-item__share">{(station.share * 100).toFixed(1)}%</span>
|
||||
</button>
|
||||
))}
|
||||
{stations.length === 0 && (
|
||||
<div className="ehb-station-rank-empty">当前筛选下暂无站点数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ehb-insight__card">
|
||||
<div className={`ehb-insight__icon ${customerGrossMarginPct >= 0 ? 'is-ok' : 'is-neg'}`}>
|
||||
<Activity size={18} aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<div className="ehb-insight__title">客户单毛利率</div>
|
||||
<div className={`ehb-insight__value ${customerGrossMarginPct >= 0 ? 'is-pos' : 'is-neg'}`}>
|
||||
{customerGrossMarginPct.toFixed(1)}%
|
||||
</div>
|
||||
<div className="ehb-insight__desc">
|
||||
客户单毛利 {yearProfitValue}{yearProfitUnit} · 客户收入 {yearRevenueValue}{yearRevenueUnit}
|
||||
{customerGrossMarginPct < 0 ? ' · 需关注客户价格与站点成本' : ' · 当前客户单保持正毛利'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<OverviewDrillDialog
|
||||
payload={drill}
|
||||
onClose={() => { setDrill(null); setSelectedStationId(null); }}
|
||||
onPrimaryAction={selectedStationId ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { Activity, Fuel, Search, Truck, Wallet, Zap } from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import type { HydrogenKpi } from '../../types';
|
||||
import {
|
||||
buildMetricDrillPayload,
|
||||
formatKg as fmtKg,
|
||||
formatYuan as fmtYuan,
|
||||
type OverviewDrillPayload,
|
||||
type OverviewDrillRequest,
|
||||
type OverviewMetricKey,
|
||||
type OverviewScope,
|
||||
} from '../model';
|
||||
import { OverviewDrillDialog } from './OverviewDrillDialog';
|
||||
|
||||
interface KpiCardProps {
|
||||
icon: ReactNode;
|
||||
metricKey: OverviewMetricKey;
|
||||
label: string;
|
||||
hero: { value: string; unit: string };
|
||||
rows: { label: string; value: string }[];
|
||||
tone: 'blue' | 'green' | 'amber' | 'purple';
|
||||
valueClass?: string;
|
||||
onOpen: (key: OverviewMetricKey) => void;
|
||||
}
|
||||
|
||||
const TONE_CLASS = {
|
||||
blue: 'bg-blue-50 text-blue-600',
|
||||
green: 'bg-emerald-50 text-emerald-600',
|
||||
amber: 'bg-amber-50 text-amber-600',
|
||||
purple: 'bg-violet-50 text-violet-600',
|
||||
} as const;
|
||||
|
||||
function KpiCard({ icon, metricKey, label, hero, rows, tone, onOpen }: KpiCardProps) {
|
||||
const isYuan = hero.value.startsWith('¥');
|
||||
const numValue = isYuan ? hero.value.replace('¥', '') : hero.value;
|
||||
return (
|
||||
<div
|
||||
className={`ehb-kpi-dual is-${tone}`}
|
||||
onClick={() => onOpen(metricKey)}
|
||||
title="点击查看真实汇总及可用下钻明细"
|
||||
>
|
||||
<div className="ehb-kpi-dual__head">
|
||||
<span className="ehb-kpi-dual__label">
|
||||
{label}
|
||||
<span className="ehb-kpi-drill-hint">
|
||||
<Search size={10} /> 钻取
|
||||
</span>
|
||||
</span>
|
||||
<span className={`ehb-kpi-dual__badge is-${tone}`}>{icon}</span>
|
||||
</div>
|
||||
<div className="ehb-kpi-dual__val">
|
||||
{isYuan && <span className="ehb-kpi-dual__symbol">¥</span>}
|
||||
<span className="ehb-kpi-dual__num">{numValue}</span>
|
||||
{hero.unit && <span className="ehb-kpi-dual__unit">{hero.unit}</span>}
|
||||
</div>
|
||||
<div className="ehb-kpi-dual__deck">
|
||||
{rows.map((row) => (
|
||||
<span key={row.label}>{row.label} {row.value}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface KpiSectionProps {
|
||||
kpi: HydrogenKpi;
|
||||
scope?: OverviewScope;
|
||||
scopeLabel?: string | null;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}
|
||||
|
||||
export function KpiSection({ kpi: k, scope = 'global', scopeLabel, onDrillRequest }: KpiSectionProps) {
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const yearKgFmt = fmtKg(k.yearKg);
|
||||
const yearFeeFmt = fmtYuan(k.yearFee);
|
||||
const yearProfitFmt = fmtYuan(k.yearProfit);
|
||||
const ourYearKgFmt = fmtKg(k.ourYearKg);
|
||||
const customerYearKgFmt = fmtKg(k.customerYearKg);
|
||||
const monthKgFmt = fmtKg(k.monthKg);
|
||||
const monthFeeFmt = fmtYuan(k.monthFee);
|
||||
const todayKgFmt = fmtKg(k.todayKg);
|
||||
const todayFeeFmt = fmtYuan(k.todayFee);
|
||||
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
|
||||
const customerYearFeeFmt = fmtYuan(customerYearFee);
|
||||
const yearRevenueFmt = fmtYuan(k.yearRevenue);
|
||||
|
||||
const openDrill = (key: OverviewMetricKey) => {
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'metric', key, label: key });
|
||||
else setDrill(buildMetricDrillPayload(k, key));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ehb-host-kpi" aria-label={`${scope === 'station' ? scopeLabel || '单站' : '全局'}经营指标`}>
|
||||
<KpiCard metricKey="yearKg" icon={<Fuel size={14} />} tone="blue" label="累计加氢量" hero={yearKgFmt} rows={[{ label: '我司', value: `${ourYearKgFmt.value}${ourYearKgFmt.unit}` }, { label: '客户', value: `${customerYearKgFmt.value}${customerYearKgFmt.unit}` }]} onOpen={openDrill} />
|
||||
<KpiCard metricKey="yearFee" icon={<Wallet size={14} />} tone="blue" label="累计加氢费" hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }} rows={[{ label: '我司', value: `¥${fmtYuan(k.ourYearFee).value}${fmtYuan(k.ourYearFee).unit}` }, { label: '客户', value: `¥${customerYearFeeFmt.value}${customerYearFeeFmt.unit}` }]} onOpen={openDrill} />
|
||||
<KpiCard metricKey="yearProfit" icon={<Activity size={14} />} tone="green" label="客户单毛利" hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }} rows={[{ label: '收入', value: `¥${yearRevenueFmt.value}${yearRevenueFmt.unit}` }, { label: '成本', value: `¥${customerYearFeeFmt.value}${customerYearFeeFmt.unit}` }]} onOpen={openDrill} />
|
||||
<KpiCard metricKey="monthKg" icon={<Truck size={14} />} tone="amber" label="本月加氢" hero={monthKgFmt} rows={[{ label: '加氢费', value: `¥${monthFeeFmt.value}${monthFeeFmt.unit}` }, { label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` }]} onOpen={openDrill} />
|
||||
<KpiCard metricKey="todayKg" icon={<Zap size={14} />} tone="purple" label="本日加氢" hero={todayKgFmt} rows={[{ label: '加氢费', value: `¥${todayFeeFmt.value}${todayFeeFmt.unit}` }, { label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` }]} onOpen={openDrill} />
|
||||
</div>
|
||||
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
LabelList,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { useState } from 'react';
|
||||
import type { HydrogenMonthlyPoint } from '../../types';
|
||||
import {
|
||||
buildMonthDrillPayload,
|
||||
formatYuan as fmtYuan,
|
||||
type OverviewDrillPayload,
|
||||
type OverviewDrillRequest,
|
||||
type OverviewScope,
|
||||
} from '../model';
|
||||
import { OverviewDrillDialog } from './OverviewDrillDialog';
|
||||
|
||||
type MonthlyChartPoint = HydrogenMonthlyPoint & { monthLabel: string };
|
||||
|
||||
interface MonthlyChartsProps {
|
||||
activeYear: number;
|
||||
monthly: HydrogenMonthlyPoint[];
|
||||
monthlyDual: MonthlyChartPoint[];
|
||||
scope?: OverviewScope;
|
||||
scopeLabel?: string | null;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}
|
||||
|
||||
export function MonthlyCharts({ activeYear, monthly, monthlyDual, scope = 'global', scopeLabel, onDrillRequest }: MonthlyChartsProps) {
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const openMonthPoint = (point: MonthlyChartPoint | undefined) => {
|
||||
if (!point) return;
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'month', key: point.month, label: `${point.month} 月度经营明细` });
|
||||
else setDrill(buildMonthDrillPayload(point));
|
||||
};
|
||||
const openMonthFromChart = (state: unknown) => {
|
||||
openMonthPoint((state as { activePayload?: { payload?: MonthlyChartPoint }[] } | undefined)?.activePayload?.[0]?.payload);
|
||||
};
|
||||
const openMonthFromBar = (entry: unknown) => {
|
||||
openMonthPoint((entry as { payload?: MonthlyChartPoint } | undefined)?.payload);
|
||||
};
|
||||
const scopeText = scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : '';
|
||||
return (
|
||||
<>
|
||||
{monthly.length > 0 && (
|
||||
<div className="ehb-chart-box">
|
||||
<div className="ehb-chart-box-head">
|
||||
<div className="ehb-chart-box-title">{activeYear} 年月度加氢量{scopeText}</div>
|
||||
<div className="ehb-chart-box-meta">单位:Kg</div>
|
||||
</div>
|
||||
<div className="ehb-chart-box-body">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }} onClick={openMonthFromChart} className="cursor-pointer">
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']}
|
||||
labelFormatter={(d) => `${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={24} iconSize={8} wrapperStyle={{ fontSize: 11, paddingBottom: 4 }} />
|
||||
<Bar dataKey="lingniuKg" name="羚牛车辆" stackId="kg" fill="#4ba3df" radius={[0, 0, 0, 0]} onClick={openMonthFromBar} />
|
||||
<Bar dataKey="externalKg" name="外部车辆" stackId="kg" fill="#f4bb45" radius={[4, 4, 0, 0]} onClick={openMonthFromBar}>
|
||||
<LabelList dataKey="kg" position="top" formatter={value => {
|
||||
const total = Number(value ?? 0);
|
||||
return total >= 1000 ? `${(total / 1000).toFixed(1)}k` : total.toFixed(0);
|
||||
}} fill="#475569" fontSize={10} fontWeight={700} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{monthly.length > 0 && (
|
||||
<div className="ehb-chart-box">
|
||||
<div className="ehb-chart-box-head">
|
||||
<div className="ehb-chart-box-title">{activeYear} 年月度收支对比{scopeText}</div>
|
||||
<div className="ehb-chart-box-meta">单位:元</div>
|
||||
</div>
|
||||
<div className="ehb-chart-box-body">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }} onClick={openMonthFromChart} className="cursor-pointer">
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={20}
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v, name) => {
|
||||
const f = fmtYuan(Number(v ?? 0));
|
||||
return [`¥${f.value} ${f.unit}`, name];
|
||||
}}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} onClick={openMonthFromBar} />
|
||||
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} onClick={openMonthFromBar} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
import { ChevronDown, RefreshCw, Truck } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { HydrogenVehicleScope } from '../../api';
|
||||
import { formatRefreshTime } from '../model';
|
||||
|
||||
interface BiYearSelectProps {
|
||||
value: number;
|
||||
years: number[];
|
||||
onChange: (year: number) => void;
|
||||
}
|
||||
|
||||
function BiYearSelect({ value, years, onChange }: BiYearSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className="ehb-year-select-wrapper" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-year-select-btn ${isOpen ? 'is-active' : ''}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label="年份选择"
|
||||
>
|
||||
<span className="ehb-year-text">{value} 年</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
style={{
|
||||
transition: 'transform 0.2s ease',
|
||||
transform: isOpen ? 'rotate(180deg)' : 'none',
|
||||
color: '#64748b',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="ehb-year-dropdown">
|
||||
<div className="ehb-year-dropdown__header">切换数据年份</div>
|
||||
<div className="ehb-year-dropdown__list">
|
||||
{years.map((y) => (
|
||||
<button
|
||||
key={y}
|
||||
type="button"
|
||||
className={`ehb-year-dropdown__item ${y === value ? 'is-selected' : ''}`}
|
||||
onClick={() => {
|
||||
onChange(y);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{y} 年</span>
|
||||
{y === value && <span className="ehb-year-check">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewHeaderProps {
|
||||
activeYear: number;
|
||||
availableYears: number[];
|
||||
vehicleScope: HydrogenVehicleScope;
|
||||
verifyScope?: 'all' | 'verified';
|
||||
onVerifyScopeChange?: (scope: 'all' | 'verified') => void;
|
||||
selectedStationId?: number | null;
|
||||
selectedStationName?: string | null;
|
||||
stations?: { id: number; name: string }[];
|
||||
latestLedgerTime?: string | null;
|
||||
lastRefreshAt?: number;
|
||||
refreshing?: boolean;
|
||||
onSelectYear: (year: number) => void;
|
||||
onVehicleScopeChange: (scope: HydrogenVehicleScope) => void;
|
||||
onStationChange?: (stationId: number | null) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function OverviewHeader({
|
||||
activeYear,
|
||||
availableYears,
|
||||
vehicleScope,
|
||||
verifyScope = 'all',
|
||||
onVerifyScopeChange,
|
||||
latestLedgerTime,
|
||||
lastRefreshAt = 0,
|
||||
refreshing = false,
|
||||
onSelectYear,
|
||||
onVehicleScopeChange,
|
||||
onRefresh,
|
||||
}: OverviewHeaderProps) {
|
||||
return (
|
||||
<section className="ehb-daily-filter-card" style={{ marginBottom: 12 }} aria-label="总览筛选工具栏">
|
||||
<div className="ehb-daily-filter-row">
|
||||
<div className="ehb-daily-filter-group">
|
||||
<BiYearSelect value={activeYear} years={availableYears} onChange={onSelectYear} />
|
||||
<div className="ehb-pill-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-pill-btn ${verifyScope === 'all' ? 'is-active' : ''}`}
|
||||
onClick={() => onVerifyScopeChange?.('all')}
|
||||
>
|
||||
全量订单
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-pill-btn ${verifyScope === 'verified' ? 'is-active' : ''}`}
|
||||
onClick={() => onVerifyScopeChange?.('verified')}
|
||||
title="真实账本已返回全量有效订单"
|
||||
>
|
||||
仅已核对
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ehb-daily-filter-group">
|
||||
<div className="ehb-fleet-segmented">
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-fleet-btn ${vehicleScope === 'all' ? 'is-active' : ''}`}
|
||||
onClick={() => onVehicleScopeChange('all')}
|
||||
>
|
||||
全部车辆
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-fleet-btn ${vehicleScope === 'lingniu' ? 'is-active' : ''}`}
|
||||
onClick={() => onVehicleScopeChange('lingniu')}
|
||||
>
|
||||
<Truck size={14} />
|
||||
仅羚牛车辆
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-fleet-btn ${vehicleScope === 'external' ? 'is-active' : ''}`}
|
||||
onClick={() => onVehicleScopeChange('external')}
|
||||
>
|
||||
<Truck size={14} />
|
||||
仅外部车辆
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="ehb-chrome__clock" style={{ fontSize: 12, color: '#64748b' }}>
|
||||
{latestLedgerTime ? `账本 ${latestLedgerTime.slice(5, 16)}` : lastRefreshAt ? formatRefreshTime(lastRefreshAt) : '已同步'}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-btn ehb-btn--ghost"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
title="数据刷新"
|
||||
>
|
||||
<RefreshCw size={14} className={refreshing ? 'animate-spin' : ''} aria-hidden />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
interface RefreshOverlayProps {
|
||||
refreshing: boolean;
|
||||
hasData: boolean;
|
||||
}
|
||||
|
||||
export function RefreshOverlay({ refreshing, hasData }: RefreshOverlayProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{refreshing && hasData && (
|
||||
<motion.div
|
||||
key="refresh-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
|
||||
>
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: '100%' }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
|
||||
style={{ width: '40%' }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
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';
|
||||
|
||||
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));
|
||||
};
|
||||
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>
|
||||
</div>
|
||||
<div className="ehb-sum-table-card__meta">
|
||||
统计范围内共 {stations.length} 站
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ehb-sum-table-wrap">
|
||||
<table className="ehb-sum-table">
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedStations.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>
|
||||
</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)}%` }} />
|
||||
</div>
|
||||
<span className="ehb-ratio-text">{(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>
|
||||
<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)}%` }} />
|
||||
</div>
|
||||
<span className="ehb-ratio-text">{(s.revenueShare * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</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));
|
||||
};
|
||||
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>
|
||||
<div className="ehb-sum-table-card__meta">
|
||||
Top {customers.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-sum-table-wrap">
|
||||
<table className="ehb-sum-table">
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedCustomers.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' }}>
|
||||
{c2.payer === 'lingniu' ? (
|
||||
<span className="ehb-payer-tag is-own">羚牛</span>
|
||||
) : c2.payer === 'mixed' ? (
|
||||
<span className="ehb-payer-tag is-mix">混合</span>
|
||||
) : (
|
||||
<span className="ehb-payer-tag is-ext">客户</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 style={{ textAlign: 'right' }} className="col-amber-fee">
|
||||
¥{costFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{costFmt.unit}</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }} className="col-green-fee">
|
||||
¥{revFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{revFmt.unit}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
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' });
|
||||
assert.deepEqual(formatYuan(9999), { value: '9,999', unit: '元' });
|
||||
assert.deepEqual(formatYuan(12345), { value: '1.23', unit: '万元' });
|
||||
assert.deepEqual(formatYuan(-100_000_000), { value: '-1.00', unit: '亿元' });
|
||||
});
|
||||
|
||||
test('相对更新时间保持秒、分钟、小时和未来时间边界', () => {
|
||||
const now = new Date(2026, 7, 13, 12, 0, 0).getTime();
|
||||
assert.equal(formatRelative(now + 60_000, now), '刚刚');
|
||||
assert.equal(formatRelative(now - 30_000, now), '30 秒前');
|
||||
assert.equal(formatRelative(now - 5 * 60_000, now), '5 分钟前');
|
||||
assert.equal(formatRelative(now - 3 * 60 * 60_000, now), '3 小时前');
|
||||
});
|
||||
|
||||
test('总览派生指标保持月份顺序、动能、集中度和收益率口径', () => {
|
||||
const data = {
|
||||
kpi: {
|
||||
yearKg: 1000,
|
||||
yearFee: 0,
|
||||
yearProfit: -50,
|
||||
ourYearKg: 0,
|
||||
customerYearKg: 0,
|
||||
ourYearFee: 0,
|
||||
yearRevenue: 500,
|
||||
monthKg: 0,
|
||||
monthFee: 0,
|
||||
monthRevenue: 0,
|
||||
monthProfit: 0,
|
||||
todayKg: 0,
|
||||
todayFee: 0,
|
||||
todayRevenue: 0,
|
||||
todayProfit: 0,
|
||||
lingniuBornKg: 0,
|
||||
lingniuBornFee: 0,
|
||||
},
|
||||
top5: [{ id: 1, rank: 1, name: 'A', kg: 600, fee: 100, share: 0.6 }],
|
||||
regions: [],
|
||||
monthly: [
|
||||
{ month: '2026-01', kg: 100, fee: 20, revenue: 30, profit: 10 },
|
||||
{ month: '2026-02', kg: 150, fee: 30, revenue: 40, profit: 10 },
|
||||
],
|
||||
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 },
|
||||
],
|
||||
availableYears: [2026],
|
||||
year: 2026,
|
||||
latestLedgerTime: '2026-08-17 21:50:09',
|
||||
filter: { stationId: null, vehicleScope: 'all', verifyScope: 'all' },
|
||||
} satisfies HydrogenOverviewResponse;
|
||||
|
||||
const metrics = deriveOverviewMetrics(data);
|
||||
assert.equal(metrics.monthAvgKg, 125);
|
||||
assert.equal(metrics.bestMonth?.month, '2026-02');
|
||||
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.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 元');
|
||||
});
|
||||
@@ -1,298 +0,0 @@
|
||||
import type { HydrogenOverviewResponse } from '../api';
|
||||
import type {
|
||||
HydrogenCustomerRow,
|
||||
HydrogenKpi,
|
||||
HydrogenMonthlyPoint,
|
||||
HydrogenRegionShare,
|
||||
HydrogenStationFull,
|
||||
} from '../types';
|
||||
|
||||
export type OverviewScope = 'global' | 'station';
|
||||
export type OverviewMetricKey = 'yearKg' | 'yearFee' | 'yearProfit' | 'monthKg' | 'todayKg';
|
||||
export type OverviewDrillKind = 'metric' | 'month' | 'station' | 'customer' | 'region';
|
||||
|
||||
export interface OverviewDrillRequest {
|
||||
kind: OverviewDrillKind;
|
||||
key: string;
|
||||
label: string;
|
||||
entityId?: number;
|
||||
}
|
||||
|
||||
export interface OverviewDrillMetric {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: 'default' | 'blue' | 'green' | 'amber' | 'red';
|
||||
}
|
||||
|
||||
export interface OverviewDrillColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
align?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export interface OverviewDrillPayload {
|
||||
kind: OverviewDrillKind;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
metrics: OverviewDrillMetric[];
|
||||
columns: OverviewDrillColumn[];
|
||||
rows: Record<string, string | number>[];
|
||||
emptyMessage?: string;
|
||||
primaryActionLabel?: string;
|
||||
groupBy?: 'station' | 'customer' | 'vehicle';
|
||||
groupByOptions?: Array<'station' | 'customer' | 'vehicle'>;
|
||||
rowActionLabel?: string;
|
||||
}
|
||||
|
||||
export function formatKg(kg: number): { value: string; unit: string } {
|
||||
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
|
||||
return { value: kg.toFixed(2), unit: 'Kg' };
|
||||
}
|
||||
|
||||
export function formatYuan(yuan: number): { value: string; unit: string } {
|
||||
const absolute = Math.abs(yuan);
|
||||
if (absolute >= 100_000_000) {
|
||||
return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
|
||||
}
|
||||
if (absolute >= 10_000) {
|
||||
return {
|
||||
value: (yuan / 10_000).toLocaleString('zh-CN', { maximumFractionDigits: 2 }),
|
||||
unit: '万元',
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }),
|
||||
unit: '元',
|
||||
};
|
||||
}
|
||||
|
||||
function formatKgText(value: number): string {
|
||||
const formatted = formatKg(value);
|
||||
return `${formatted.value} ${formatted.unit}`;
|
||||
}
|
||||
|
||||
function formatYuanText(value: number): string {
|
||||
const formatted = formatYuan(value);
|
||||
return `¥${formatted.value} ${formatted.unit}`;
|
||||
}
|
||||
|
||||
const METRIC_LABELS: Record<OverviewMetricKey, string> = {
|
||||
yearKg: '累计加氢量',
|
||||
yearFee: '累计加氢费',
|
||||
yearProfit: '客户单毛利',
|
||||
monthKg: '本月加氢',
|
||||
todayKg: '本日加氢',
|
||||
};
|
||||
|
||||
/**
|
||||
* 总览接口只返回聚合值。弹层先完整呈现可核验汇总,订单树由父页面在接入
|
||||
* 明细接口后通过 onDrillRequest 承接,避免用推算值冒充真实订单。
|
||||
*/
|
||||
export function buildMetricDrillPayload(kpi: HydrogenKpi, key: OverviewMetricKey): OverviewDrillPayload {
|
||||
const customerCost = Math.max(0, kpi.yearFee - kpi.ourYearFee);
|
||||
const common = {
|
||||
kind: 'metric' as const,
|
||||
title: METRIC_LABELS[key],
|
||||
subtitle: '汇总口径穿透 · 当前筛选范围',
|
||||
columns: [],
|
||||
rows: [],
|
||||
emptyMessage: '当前总览接口未返回站点、客户、车牌及单笔订单层级;汇总值已按真实账本展示。',
|
||||
};
|
||||
|
||||
if (key === 'yearKg') {
|
||||
return {
|
||||
...common,
|
||||
metrics: [
|
||||
{ label: '累计加氢量', value: formatKgText(kpi.yearKg), tone: 'blue' },
|
||||
{ label: '我司车辆', value: formatKgText(kpi.ourYearKg) },
|
||||
{ label: '客户车辆', value: formatKgText(kpi.customerYearKg) },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (key === 'yearFee') {
|
||||
return {
|
||||
...common,
|
||||
metrics: [
|
||||
{ label: '累计加氢费', value: formatYuanText(kpi.yearFee), tone: 'blue' },
|
||||
{ label: '我司承担', value: formatYuanText(kpi.ourYearFee) },
|
||||
{ label: '客户承担', value: formatYuanText(customerCost) },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (key === 'yearProfit') {
|
||||
return {
|
||||
...common,
|
||||
metrics: [
|
||||
{ label: '客户单毛利', value: formatYuanText(kpi.yearProfit), tone: kpi.yearProfit >= 0 ? 'green' : 'red' },
|
||||
{ label: '客户收入', value: formatYuanText(kpi.yearRevenue) },
|
||||
{ label: '客户成本', value: formatYuanText(customerCost) },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (key === 'monthKg') {
|
||||
return {
|
||||
...common,
|
||||
metrics: [
|
||||
{ label: '本月加氢量', value: formatKgText(kpi.monthKg), tone: 'amber' },
|
||||
{ label: '本月加氢费', value: formatYuanText(kpi.monthFee) },
|
||||
{ label: '占年比', value: `${kpi.yearKg > 0 ? (kpi.monthKg / kpi.yearKg * 100).toFixed(1) : '0.0'}%` },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
metrics: [
|
||||
{ label: '本日加氢量', value: formatKgText(kpi.todayKg), tone: 'blue' },
|
||||
{ label: '本日加氢费', value: formatYuanText(kpi.todayFee) },
|
||||
{ label: '占月比', value: `${kpi.monthKg > 0 ? (kpi.todayKg / kpi.monthKg * 100).toFixed(1) : '0.0'}%` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMonthDrillPayload(month: HydrogenMonthlyPoint): OverviewDrillPayload {
|
||||
const lingniuKg = month.lingniuKg ?? 0;
|
||||
const externalKg = month.externalKg ?? Math.max(0, month.kg - lingniuKg);
|
||||
return {
|
||||
kind: 'month',
|
||||
title: `${month.month} 月度经营明细`,
|
||||
subtitle: '月度聚合 · 当前车辆范围',
|
||||
metrics: [
|
||||
{ label: '加氢总量', value: formatKgText(month.kg), tone: 'blue' },
|
||||
{ label: '成本支出', value: formatYuanText(month.fee), tone: 'amber' },
|
||||
{ label: '客户收入', value: formatYuanText(month.revenue), tone: 'green' },
|
||||
{ label: '客户单毛利', value: formatYuanText(month.profit), tone: month.profit >= 0 ? 'green' : 'red' },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'category', label: '车辆范围' },
|
||||
{ key: 'kg', label: '加氢量', align: 'right' },
|
||||
{ key: 'share', label: '占比', align: 'right' },
|
||||
],
|
||||
rows: [
|
||||
{ category: '羚牛车辆', kg: formatKgText(lingniuKg), share: `${month.kg > 0 ? (lingniuKg / month.kg * 100).toFixed(1) : '0.0'}%` },
|
||||
{ category: '外部车辆', kg: formatKgText(externalKg), share: `${month.kg > 0 ? (externalKg / month.kg * 100).toFixed(1) : '0.0'}%` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStationDrillPayload(station: HydrogenStationFull): OverviewDrillPayload {
|
||||
return {
|
||||
kind: 'station',
|
||||
title: `「${station.name}」加氢汇总明细`,
|
||||
subtitle: `${station.province || '未归属省份'} · 当前统计周期`,
|
||||
metrics: [
|
||||
{ label: '加氢量', value: formatKgText(station.kg), tone: 'blue' },
|
||||
{ label: '氢费收入', value: formatYuanText(station.revenue), tone: 'green' },
|
||||
{ label: '加氢量占比', value: `${(station.share * 100).toFixed(1)}%` },
|
||||
{ label: '收入占比', value: `${(station.revenueShare * 100).toFixed(1)}%` },
|
||||
],
|
||||
columns: [],
|
||||
rows: [],
|
||||
emptyMessage: '当前总览接口未返回该站按日、客户及车辆流水;可切换到单站视图继续查看真实数据。',
|
||||
primaryActionLabel: '进入单站视图',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCustomerDrillPayload(customer: HydrogenCustomerRow): OverviewDrillPayload {
|
||||
const payerLabel = customer.payer === 'lingniu' ? '羚牛承担' : customer.payer === 'mixed' ? '混合承担' : '客户承担';
|
||||
return {
|
||||
kind: 'customer',
|
||||
title: `「${customer.name}」客户账单明细`,
|
||||
subtitle: `${payerLabel} · 当前统计周期`,
|
||||
metrics: [
|
||||
{ label: '加氢量', value: formatKgText(customer.kg), tone: 'blue' },
|
||||
{ label: '成本支出', value: formatYuanText(customer.cost), tone: 'amber' },
|
||||
{ label: '应收', value: formatYuanText(customer.revenue), tone: 'green' },
|
||||
{ label: '价差', value: formatYuanText(customer.revenue - customer.cost), tone: customer.revenue - customer.cost >= 0 ? 'green' : 'red' },
|
||||
],
|
||||
columns: [],
|
||||
rows: [],
|
||||
emptyMessage: '当前总览接口未返回该客户按日、车牌及单笔加氢流水。',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRegionDrillPayload(
|
||||
region: HydrogenRegionShare,
|
||||
stations: HydrogenStationFull[],
|
||||
granularity: 'province' | 'city',
|
||||
): OverviewDrillPayload {
|
||||
const matchedStations = granularity === 'province'
|
||||
? stations.filter(station => (station.province?.trim() || '未归属') === region.region)
|
||||
: [];
|
||||
return {
|
||||
kind: 'region',
|
||||
title: `${region.region}加氢区域明细`,
|
||||
subtitle: `${granularity === 'province' ? '省级' : '市级'}口径 · 当前统计周期`,
|
||||
metrics: [
|
||||
{ label: '区域加氢量', value: formatKgText(region.kg), tone: 'blue' },
|
||||
{ label: '全局占比', value: `${(region.share * 100).toFixed(1)}%` },
|
||||
{ label: '已关联站点', value: `${matchedStations.length} 站` },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'station', label: '加氢站' },
|
||||
{ key: 'province', label: '所属省份' },
|
||||
{ key: 'kg', label: '加氢量', align: 'right' },
|
||||
{ key: 'share', label: '区域内占比', align: 'right' },
|
||||
],
|
||||
rows: matchedStations.map(station => ({
|
||||
station: station.name,
|
||||
province: station.province || '未归属',
|
||||
kg: formatKgText(station.kg),
|
||||
share: `${region.kg > 0 ? (station.kg / region.kg * 100).toFixed(1) : '0.0'}%`,
|
||||
})),
|
||||
emptyMessage: granularity === 'city'
|
||||
? '当前站点汇总接口未返回城市字段,无法将市级汇总继续关联到具体站点。'
|
||||
: '当前区域暂无可关联站点。',
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRelative(timestamp: number, now = Date.now()): string {
|
||||
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
|
||||
if (seconds < 5) return '刚刚';
|
||||
if (seconds < 60) return `${seconds} 秒前`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
return new Date(timestamp).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
export function formatRefreshTime(timestamp: number, now = Date.now()): string {
|
||||
const exactTime = new Date(timestamp).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
return `${formatRelative(timestamp, now)} · ${exactTime.replace(/\//g, '-')}`;
|
||||
}
|
||||
|
||||
export function deriveOverviewMetrics(data: HydrogenOverviewResponse) {
|
||||
const { kpi, monthly, stations, top5 } = data;
|
||||
const monthAvgKg = monthly.length > 0
|
||||
? monthly.reduce((sum, month) => sum + month.kg, 0) / monthly.length
|
||||
: 0;
|
||||
const bestMonth = monthly.reduce<typeof monthly[number] | null>(
|
||||
(best, item) => (!best || item.kg > best.kg ? item : best),
|
||||
null,
|
||||
);
|
||||
const latestMonth = monthly[monthly.length - 1];
|
||||
const previousMonth = monthly[monthly.length - 2];
|
||||
|
||||
return {
|
||||
monthAvgKg,
|
||||
bestMonth,
|
||||
latestMonth,
|
||||
monthMomentum: latestMonth && previousMonth && previousMonth.kg > 0
|
||||
? ((latestMonth.kg - previousMonth.kg) / previousMonth.kg) * 100
|
||||
: null,
|
||||
top5Share: (top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, kpi.yearKg)) * 100,
|
||||
customerGrossMarginPct: kpi.yearRevenue > 0 ? (kpi.yearProfit / kpi.yearRevenue) * 100 : 0,
|
||||
stationAvgKg: stations.length > 0 ? kpi.yearKg / stations.length : 0,
|
||||
monthlyDual: monthly.map(month => ({
|
||||
...month,
|
||||
monthLabel: `${month.month.slice(5).replace(/^0/, '')}月`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
+27
-2818
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -3,7 +3,7 @@
|
||||
* 本原型独立嵌入 bi-next,禁止引入 OneOS V2 组件;
|
||||
* 字体例外:汉字/UI 与数字等宽对齐 V2 Token(DESIGN §2.2)。
|
||||
*/
|
||||
@import '../../../resources/design-system/fonts/jetbrains-mono/jetbrains-mono.css';
|
||||
@import '../../fonts/jetbrains-mono/jetbrains-mono.css';
|
||||
|
||||
/* —— 访问口令门(轻门禁 · 对齐宿主蓝系,非 V2 组件) —— */
|
||||
.ehb-gate {
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { isPhoneUserAgent, phoneRotation } from './phone-viewport';
|
||||
import { Maximize2 } from 'lucide-react';
|
||||
import './mobile-list-fullscreen.css';
|
||||
|
||||
export function MobileListFullscreenButton({
|
||||
label = '横屏查看',
|
||||
placement = 'overlay',
|
||||
}: {
|
||||
label?: string;
|
||||
placement?: 'overlay' | 'inline';
|
||||
}) {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const targetRef = useRef<HTMLElement | null>(null);
|
||||
const savedStyles = useRef(new Map<string, [string, string]>());
|
||||
const restoreGeometry = () => {
|
||||
const target = targetRef.current;
|
||||
if (!target) return;
|
||||
savedStyles.current.forEach(([value, priority], key) => {
|
||||
if (value) target.style.setProperty(key, value, priority); else target.style.removeProperty(key);
|
||||
});
|
||||
savedStyles.current.clear();
|
||||
};
|
||||
const updateGeometry = () => {
|
||||
const target = targetRef.current;
|
||||
if (!target) return;
|
||||
restoreGeometry();
|
||||
const width = window.visualViewport?.width ?? window.innerWidth;
|
||||
const height = window.visualViewport?.height ?? window.innerHeight;
|
||||
const rotate = phoneRotation(navigator.userAgent, width, height);
|
||||
target.dataset.mobileFullscreenMode = rotate ? 'phone-rotated' : 'native-scroll';
|
||||
if (!rotate) return;
|
||||
const styles: Record<string, string> = {
|
||||
position: 'fixed', top: '0px', left: '0px', right: 'auto', bottom: 'auto',
|
||||
width: `${height}px`, height: `${width}px`, 'max-width': 'none', 'max-height': 'none',
|
||||
margin: '0px', transform: `translateX(${width}px) rotate(90deg)`, 'transform-origin': '0 0',
|
||||
overflow: 'hidden', padding: '0px',
|
||||
};
|
||||
Object.entries(styles).forEach(([key, value]) => {
|
||||
savedStyles.current.set(key, [target.style.getPropertyValue(key), target.style.getPropertyPriority(key)]);
|
||||
target.style.setProperty(key, value, 'important');
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
const resize = () => updateGeometry();
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && targetRef.current) { event.stopPropagation(); leaveLandscape(targetRef.current); }
|
||||
};
|
||||
window.addEventListener('resize', resize);
|
||||
window.visualViewport?.addEventListener('resize', resize);
|
||||
document.addEventListener('keydown', escape, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize);
|
||||
window.visualViewport?.removeEventListener('resize', resize);
|
||||
document.removeEventListener('keydown', escape, true);
|
||||
if (targetRef.current) {
|
||||
restoreGeometry();
|
||||
targetRef.current.removeAttribute('data-mobile-fullscreen-active');
|
||||
targetRef.current.removeAttribute('data-mobile-fullscreen-mode');
|
||||
targetRef.current.classList.remove('is-mobile-list-fullscreen', 'is-mobile-list-fallback');
|
||||
targetRef.current = null;
|
||||
document.documentElement.classList.remove('ehb-landscape-session');
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const leaveLandscape = (target: HTMLElement) => {
|
||||
restoreGeometry();
|
||||
targetRef.current = null;
|
||||
target.removeAttribute('data-mobile-fullscreen-active');
|
||||
target.removeAttribute('data-mobile-fullscreen-mode');
|
||||
target.classList.remove('is-mobile-list-fullscreen', 'is-mobile-list-fallback');
|
||||
document.documentElement.classList.remove('ehb-landscape-session');
|
||||
setIsActive(false);
|
||||
};
|
||||
|
||||
const openFullscreen = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
const target = event.currentTarget.closest<HTMLElement>('[data-mobile-fullscreen-list]');
|
||||
if (!target) return;
|
||||
|
||||
if (target.dataset.mobileFullscreenActive === 'true') {
|
||||
leaveLandscape(target);
|
||||
return;
|
||||
}
|
||||
|
||||
target.dataset.mobileFullscreenActive = 'true';
|
||||
targetRef.current = target;
|
||||
target.classList.add('is-mobile-list-fullscreen');
|
||||
document.documentElement.classList.add('ehb-landscape-session');
|
||||
setIsActive(true);
|
||||
|
||||
updateGeometry();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`mobile-list-fullscreen-trigger${placement === 'inline' ? ' is-inline' : ''}`}
|
||||
aria-label={isActive ? '退出完整宽表' : label}
|
||||
aria-pressed={isActive}
|
||||
title={isActive ? '退出宽表模式' : label}
|
||||
onClick={openFullscreen}
|
||||
>
|
||||
<span className="mobile-list-fullscreen-trigger__label">{isActive ? '退出宽表' : isPhoneUserAgent(navigator.userAgent) ? '横屏查看' : '查看完整表格'}</span>
|
||||
<Maximize2 size={15} aria-hidden />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user