Compare commits
37
Commits
5eb38a4b05
..
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 | ||
|
|
df6b8201e8 | ||
|
|
a177781fe7 | ||
|
|
637a72c1e9 | ||
|
|
98efde3f75 | ||
|
|
6c91a6694a |
@@ -0,0 +1,18 @@
|
||||
# Copy to .env.local; never commit real credentials. Environment values win.
|
||||
HYDROGEN_DB_HOST=
|
||||
HYDROGEN_DB_PORT=3306
|
||||
HYDROGEN_DB_USER=
|
||||
HYDROGEN_DB_PASSWORD=
|
||||
HYDROGEN_DB_NAME=
|
||||
# 单站与外部客户数据租户;仅由服务端设置,不接受网页传入。
|
||||
HYDROGEN_TENANT_ID=000000
|
||||
# Optional legacy/electric business database; use a database-level read-only user.
|
||||
DB_HOST=
|
||||
DB_PORT=3306
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_NAME=
|
||||
# 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
|
||||
@@ -1,4 +1,7 @@
|
||||
node_modules
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -0,0 +1,13 @@
|
||||
# 本地只读预览
|
||||
|
||||
使用 Node.js 22+ 和 npm,在本项目内执行 `npm ci`,避免依赖其他工作目录的 node_modules 软链接。
|
||||
|
||||
将 `.env.local.example` 复制为 `.env.local` 并填写连接配置,或通过进程环境变量提供配置。使用数据库侧只读账号;不要提交真实凭据。
|
||||
|
||||
执行 `npm run dev:local`,浏览器访问 http://127.0.0.1:8115/energy#hydrogen 。前后端固定绑定本机 8115 / 3001;端口占用时退出,不自动换端口。停止终端进程即可停止服务;此命令不配置开机自启。
|
||||
|
||||
本地入口关闭 mock、启用本地免登录,禁用后台建表与定时归档,并拒绝 API 的 POST / PUT / PATCH / DELETE。请勿把免登录预览暴露到公网。氢能查询启用只读 SQL 检查;这不能替代数据库账号的只读权限。
|
||||
|
||||
`npm run dev` / `npm start` 保留原部署方式;设置 `DB_READ_ONLY=1` 时同样不启动后台任务,且 API 禁止写入。生产登录流程需要写请求,因此不要把本地只读模式当作生产部署配置。
|
||||
|
||||
验证:`npm test`、`npm run lint`、`npm run build`;健康检查为 `GET http://127.0.0.1:3001/api/health`。健康检查成功仅代表进程可用,还需检查氢能 meta / overview 的真实数据响应。
|
||||
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",
|
||||
|
||||
+5
-2
@@ -1,16 +1,19 @@
|
||||
{
|
||||
"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\"",
|
||||
"dev:local:server": "node --import tsx src/server/local.ts",
|
||||
"dev:local:client": "DEV_MOCK_API=0 VITE_DEV_BYPASS_AUTH=1 vite --host 127.0.0.1 --port 8115 --strictPort",
|
||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
|
||||
"dev:server": "tsx watch src/server/index.ts",
|
||||
"dev:client": "vite --port=3000 --host=0.0.0.0",
|
||||
"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": {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<svg width="150" height="36" viewBox="0 0 150 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.0459 4.69253L24.4084 4.6875L17.3427 16.9334C16.0108 19.2369 14.67 21.5335 13.357 23.8492C12.8279 24.6836 12.7967 25.5367 14.0379 25.5663C17.6611 25.6526 21.3268 25.5356 24.9495 25.6042C24.4224 26.7246 23.2874 28.5534 22.6381 29.6706L21.6984 31.3061L13.8032 31.3074C11.2823 31.3084 7.42295 31.774 6.15339 28.9623C5.10923 26.6498 6.73472 24.2701 7.86608 22.3109L10.1604 18.3408L18.0459 4.69253Z" fill="#2F2828"/>
|
||||
<path d="M30.7029 4.69293L38.1664 4.6931C40.8704 4.68656 45.5224 4.1044 46.4284 7.56236C47.0408 9.9001 45.3543 12.286 44.21 14.218C42.2951 14.125 39.809 14.1965 37.8457 14.1908C38.3321 13.3838 40.2747 10.8182 38.8199 10.5324C37.4531 10.2639 35.1947 10.4548 33.7653 10.4209C32.0072 13.3847 30.3349 16.4036 28.5844 19.3723C28.4964 19.5215 28.3602 19.7716 28.257 19.9038L21.9204 19.9023L30.7029 4.69293Z" fill="#2F2828"/>
|
||||
<path d="M89.1232 4.67032C89.7835 4.62775 90.9099 4.66078 91.6053 4.65867L90.978 7.75816L98.7808 7.75999C98.6607 8.46499 98.5297 9.16816 98.3877 9.86921L90.5769 9.86774C90.1934 11.0614 89.853 13.7315 89.4731 15.1689L97.9522 15.1851C97.8321 15.8946 97.6872 16.5697 97.5364 17.2721C94.9175 17.1811 91.7297 17.2538 89.0954 17.2677C88.594 19.3603 88.1541 21.9308 87.7303 24.0635C86.9061 24.0335 86.0812 24.0264 85.2563 24.0421C85.7525 22.0904 86.18 19.3243 86.6177 17.2638L85.2372 17.257L77.0092 17.2648C77.1446 16.5758 77.2727 15.8857 77.3957 15.1944C79.8624 15.1112 82.3687 15.2077 84.839 15.1714C85.5417 15.161 86.2942 15.1588 86.9939 15.2057C87.447 13.5537 87.6608 11.5742 88.1087 9.87872C86.2986 9.82772 84.3142 9.86591 82.4909 9.86569C81.7604 11.226 81.0028 12.4816 80.2657 13.8163L77.5362 13.8114C79.6816 10.4975 80.6808 8.44223 82.2311 4.86287C83.0545 4.83579 84.0075 4.85801 84.8405 4.85771C84.4152 5.82928 83.998 6.81499 83.512 7.75669C85.1516 7.77967 86.8351 7.76247 88.4769 7.76452C88.7163 6.8407 88.9417 5.61995 89.1232 4.67032Z" fill="#2F2828"/>
|
||||
<path d="M70.2503 4.65824L73.7308 4.65625C74.7336 7.08713 76.1309 9.57515 77.5099 11.8054C76.5818 11.7752 75.527 11.798 74.5887 11.7967C73.5397 9.99962 72.6069 8.13727 71.7957 6.22117C71.2298 6.89947 70.7149 7.63591 70.1079 8.35496L72.1324 8.35415C72.359 9.56198 72.4332 11.2339 72.4619 12.4558L70.2398 12.4478C70.2372 11.0171 70.1331 9.90113 70.0025 8.48344C69.1539 9.57259 67.962 10.814 66.9868 11.7877L65.7182 11.7932L64.2958 11.7963C64.2202 12.4282 64.0755 13.0643 63.9444 13.6881L60.6789 13.6869C60.5363 14.5168 60.3766 15.3436 60.1998 16.1668L63.9171 16.1664L63.5281 18.0826C62.277 18.0555 60.9445 18.0784 59.687 18.0782C58.9474 20.2403 57.828 22.2532 56.3813 24.0224C55.6509 24.0768 54.3913 24.03 53.5917 24.0442C55.3706 21.7397 56.0946 20.7987 57.3125 18.0802L53.6008 18.0731C53.7608 17.396 53.882 16.8655 53.9824 16.1718C55.2848 16.1587 56.5873 16.1591 57.8898 16.1732L58.3652 13.6896L54.9231 13.6853C55.1139 13.0172 55.1958 12.5027 55.3015 11.8164C56.2519 11.7504 57.7795 11.7881 58.7587 11.8109C58.8945 11.0981 59.037 10.3867 59.1861 9.67657C57.9106 9.6642 56.6351 9.66427 55.3597 9.67657C55.4544 9.05996 55.5544 8.44415 55.66 7.8293C57.5354 7.80764 59.4484 7.82827 61.3266 7.83003C61.9933 6.86852 62.6551 5.74668 63.0825 4.66341L65.5379 4.65888C64.9966 5.88817 64.5907 6.67983 63.9019 7.83288L65.5427 7.82645C65.4452 8.4301 65.2987 9.07927 65.1744 9.68205C63.9717 9.64854 62.7013 9.67152 61.4934 9.67532C61.3775 10.3714 61.2146 11.1007 61.0712 11.7947L63.9987 11.7913C66.5788 9.20798 68.0943 7.61586 70.2503 4.65824Z" fill="#2F2828"/>
|
||||
<path d="M122.532 11.8166C125.798 11.7231 129.441 11.7977 132.734 11.7984C132.466 13.5695 131.978 15.624 131.64 17.4038C131.42 18.5691 130.997 20.9151 130.604 21.9327C130.313 22.7085 129.869 23.4177 129.299 24.0182C128.475 24.0895 127.218 24.0195 126.289 24.0612C126.736 23.701 127.014 23.4316 127.42 23.0228C128.176 22.1519 128.359 21.6669 128.744 20.5722C126.97 20.5048 124.822 20.5601 123.018 20.5584C122.945 21.3158 122.507 23.1882 122.339 24.0641C121.646 24.031 120.793 24.0186 120.111 24.0547C120.247 23.0506 120.522 21.8389 120.724 20.8256L121.799 15.4428C121.945 14.7076 122.299 12.3722 122.532 11.8166ZM129.104 18.8694C129.21 18.2708 129.304 17.7024 129.465 17.1139C127.646 17.0946 125.48 17.1746 123.726 17.1053C123.609 17.6915 123.487 18.2767 123.359 18.8607C125.251 18.8633 127.22 18.8364 129.104 18.8694ZM124.059 15.3809C124.989 15.3848 129.088 15.5282 129.805 15.3507C129.899 14.7602 130.003 14.261 130.135 13.6774C128.223 13.6522 126.311 13.6515 124.399 13.6752C124.302 14.1834 124.187 14.895 124.059 15.3809Z" fill="#2F2828"/>
|
||||
<path d="M28.257 19.9042C30.3408 19.8373 32.5067 19.9107 34.6003 19.8799C33.5304 21.7989 32.4375 23.705 31.3217 25.5977L37.6027 25.587C37.1076 26.6547 36.2454 28.0061 35.655 29.0623C35.3804 29.5534 34.6405 30.8847 34.3403 31.3077L28.0049 31.3022C28.8125 29.6956 29.8532 28.1298 30.7172 26.5471C30.8889 26.2323 31.0858 25.9059 31.2849 25.6084C29.1922 25.6072 27.049 25.5816 24.9604 25.61C25.6026 24.4466 26.26 23.2918 26.9324 22.1456C27.3144 21.4843 27.8407 20.5151 28.257 19.9042Z" fill="#007143"/>
|
||||
<path d="M99.7206 10.1076C106.203 9.98573 113.088 10.0971 119.599 10.1018C118.777 13.8389 118.005 17.7955 117.477 21.5875C117.37 22.3581 118.045 23.5899 118.033 24.042C117.125 24.0338 116.218 24.0361 115.311 24.0488C114.92 21.1109 115.519 19.0866 116.068 16.2425C116.357 14.7439 116.652 13.209 116.981 11.7202L99.4205 11.7137C99.5633 11.1642 99.6343 10.6691 99.7206 10.1076Z" fill="#2F2828"/>
|
||||
<path d="M64.5255 12.945C68.4291 12.8445 72.7488 12.9376 76.6835 12.9394C76.5327 13.651 76.4317 14.5237 75.9691 15.0919C75.4018 15.7882 74.7928 16.4582 74.197 17.1312L70.7419 21.0305L69.7438 21.059C70.2176 22.0474 70.6693 23.0462 71.0985 24.0548C70.1752 24.0352 69.2515 24.0344 68.3282 24.0525C67.9964 23.2947 67.6583 22.5397 67.3138 21.7875C66.4548 19.874 65.5159 17.9972 64.5 16.1621C65.3591 16.1484 66.2391 16.156 67.1001 16.1542C67.8294 17.3343 68.5019 18.5485 69.1155 19.7926C70.6881 18.1842 72.2203 16.5366 73.7103 14.8514L64.1427 14.8496C64.2745 14.2155 64.402 13.5807 64.5255 12.945Z" fill="#2F2828"/>
|
||||
<path d="M100.218 12.4635C104.946 12.4535 109.674 12.4727 114.401 12.5212C114.273 12.9715 114.112 13.3776 113.946 13.814C112.657 14.4639 111.179 15.1061 109.857 15.7056C111.599 15.949 113.275 15.9778 115.027 16.028C114.844 16.6716 114.645 17.3102 114.427 17.943C112.64 17.957 110.455 17.6924 108.705 17.3225C108.285 17.2407 107.508 16.991 107.072 16.8654C104.084 18.0184 101.851 17.949 98.7192 17.9316C98.791 17.2822 98.9052 16.6738 99.0208 16.0327C102.633 15.9161 105.599 15.8462 108.933 14.123C106.178 14.2266 102.668 14.1367 99.8604 14.135C99.9731 13.5764 100.092 13.0192 100.218 12.4635Z" fill="#2F2828"/>
|
||||
<path d="M98.7625 18.3504C100.425 18.3036 102.282 18.3451 103.963 18.3452L114.077 18.3459C113.979 18.982 113.891 19.4244 113.733 20.0481C111.681 19.9793 109.274 20.0318 107.196 20.0488C107.14 20.5854 106.873 21.7822 106.759 22.3601C108.872 22.2731 111.751 22.3361 113.883 22.3568C113.718 22.9049 113.8 23.5534 113.504 23.9553C113.406 24.0871 113.154 24.042 112.96 24.0414C107.731 23.9823 102.283 24.0035 97.0497 24.0421C97.15 23.4629 97.2678 22.9333 97.3996 22.3599C99.6372 22.3158 102.265 22.3081 104.496 22.3613C104.62 21.6 104.787 20.8055 104.936 20.0457C102.829 19.9936 100.54 20.0389 98.4199 20.0394L98.7625 18.3504Z" fill="#2F2828"/>
|
||||
<path d="M133.256 13.9166C133.997 13.9023 134.788 13.9161 135.533 13.9168C135.386 14.8072 135.174 15.7896 135.001 16.6831C137.384 16.4394 139.769 16.2153 142.155 16.0109C141.98 16.6792 141.965 17.2281 141.716 17.9362C141.421 17.9321 141.131 17.9513 140.839 17.9823C138.756 18.2028 136.663 18.3336 134.585 18.5982C134.457 19.6086 134.11 21.1394 133.89 22.1662L135.596 22.1591H140.893C140.792 22.7924 140.684 23.4247 140.569 24.0557C137.586 23.9698 134.244 24.0318 131.246 24.0396C131.9 20.9278 132.738 17.0234 133.256 13.9166Z" fill="#2F2828"/>
|
||||
<path d="M135.098 4.62931C135.853 4.62166 136.607 4.62419 137.363 4.6369C137.162 5.41299 137.017 6.28154 136.868 7.07464C139.102 6.84738 141.908 6.69114 144.053 6.37755C143.943 7.01342 143.835 7.73358 143.657 8.34683L136.465 8.98722C136.314 9.81163 136.157 10.6349 135.994 11.4569C136.445 11.4309 137.106 11.4499 137.573 11.4482C139.397 11.4591 141.222 11.4559 143.046 11.4386C142.935 12.1076 142.839 12.6758 142.674 13.3374C139.624 13.2772 136.387 13.3227 133.323 13.3214C133.552 12.5671 133.793 11.1594 133.955 10.3413C134.324 8.43507 134.706 6.53103 135.098 4.62931Z" fill="#2F2828"/>
|
||||
<path d="M102.916 4.63357C103.716 4.61473 104.573 4.6323 105.378 4.63596L104.934 5.48128L121.832 5.48318C121.739 6.10161 121.672 6.52446 121.507 7.12456C119.818 7.05448 117.769 7.10604 116.053 7.10625L103.984 7.11772C103.38 7.9858 102.762 8.89904 102.054 9.68109C101.302 9.67604 100.124 9.63799 99.419 9.71446C99.8816 9.06719 100.543 8.33907 101.04 7.66743C101.837 6.59222 102.296 5.78928 102.916 4.63357Z" fill="#2F2828"/>
|
||||
<path d="M126 4.67433C126.546 4.62029 128.007 4.65657 128.619 4.65502C127.72 6.37437 126.984 7.53498 125.875 9.09798C127.492 9.05503 129.258 9.08605 130.886 9.08474C130.822 8.136 130.773 7.13368 130.53 6.2156C131.306 6.15771 132.166 6.17288 132.949 6.17043C133.021 7.06163 133.221 8.13834 133.316 9.09988C133.309 9.63037 133.157 10.3841 133.057 10.9028C129.465 10.8026 125.423 10.8945 121.795 10.8945C123.602 8.78078 124.778 7.14823 126 4.67433Z" fill="#2F2828"/>
|
||||
<path d="M37.8456 14.1928C39.809 14.1984 42.2951 14.127 44.2099 14.22L41.9937 18.0919C41.7788 18.4647 41.1512 19.6063 40.9155 19.9025L34.5791 19.8957C35.6336 17.9833 36.7911 16.1017 37.8456 14.1928Z" fill="#007143"/>
|
||||
<path d="M40.9155 19.902C43.0195 19.8499 45.1502 19.9224 47.2506 19.8789C46.4721 21.4168 44.8928 24.105 43.9715 25.602L37.6138 25.6077C38.4831 23.9831 39.9311 21.447 40.9155 19.902Z" fill="#007143"/>
|
||||
<path d="M104.415 7.76391L120.496 7.76172C120.35 8.3135 120.238 8.87355 120.16 9.43887C118.084 9.37345 115.82 9.41552 113.733 9.4153L104.085 9.42408C104.155 8.93055 104.312 8.26469 104.415 7.76391Z" fill="#2F2828"/>
|
||||
<path d="M96.4304 26.879C99.2507 26.6667 98.9579 30.7714 95.9312 31.3474C92.5488 31.3098 93.6973 27.1881 96.4304 26.879ZM95.9883 30.5709C96.9304 30.2786 97.7955 29.3384 97.4662 28.3229C97.3944 28.096 97.2327 27.9087 97.0175 27.8056C96.7964 27.6975 96.4978 27.6448 96.2518 27.6614C95.3083 27.9393 94.49 28.8604 94.7842 29.8668C94.8501 30.099 95.0075 30.2944 95.2205 30.4079C95.4752 30.5427 95.7065 30.5599 95.9883 30.5709Z" fill="#2F2828"/>
|
||||
<path d="M120.579 26.8756C123.424 26.7179 123.034 30.7691 120.086 31.3456C116.806 31.3978 117.733 27.1994 120.579 26.8756ZM120.22 30.5368C122.019 29.9774 122.223 27.4751 120.314 27.6674C119.642 27.9156 119.439 28.0285 119.093 28.7081C118.616 29.6484 119.009 30.7721 120.22 30.5368Z" fill="#2F2828"/>
|
||||
<path d="M116.66 26.9238L117.992 26.9219C117.747 28.2834 117.347 29.9417 117.049 31.3153L116.157 31.3083C116.347 30.6023 116.495 29.7902 116.644 29.0672C116.688 28.9313 116.767 28.502 116.801 28.3408C116.395 29.0335 115.459 30.9511 114.675 31.3444C114.566 31.399 114.465 31.3038 114.371 31.2359C114.165 30.71 113.925 28.8772 113.836 28.242L113.198 31.3117L112.31 31.3165L113.23 26.9279L114.491 26.9225C114.605 27.7811 114.833 28.7942 114.973 29.6896C115.472 28.9993 116.215 27.6823 116.66 26.9238Z" fill="#2F2828"/>
|
||||
<path d="M86.5921 26.9211C86.9917 26.9147 87.4287 26.9264 87.8225 26.9095C90.4759 26.7956 90.1867 29.6754 88.5223 30.9111C87.5305 31.4374 86.7699 31.3173 85.6661 31.3024C85.7957 30.2878 86.3578 28.007 86.5921 26.9211ZM86.7231 30.4522C87.1359 30.4503 87.7347 30.4871 88.1007 30.316C88.6401 29.8127 89.3033 28.7778 88.6848 28.0956C88.3158 27.6887 87.7976 27.7551 87.2911 27.7533C87.1257 28.5612 86.9295 29.669 86.7231 30.4522Z" fill="#2F2828"/>
|
||||
<path d="M123.457 26.9226C124.213 26.9143 125.634 26.8007 126.186 27.3128C126.71 27.7978 125.896 28.5423 125.68 28.9376C125.595 29.0947 126.087 29.5814 126.078 29.8632C126.065 30.2604 125.746 30.6635 125.492 30.9391C124.737 31.4476 123.501 31.3147 122.564 31.3117C122.81 29.8751 123.187 28.3774 123.457 26.9226ZM123.593 30.5161C124.141 30.5116 124.358 30.5255 124.891 30.3884C125.067 30.151 125.129 30.0825 125.184 29.7933C125.024 29.2759 124.325 29.3801 123.862 29.3789L123.593 30.5161ZM123.999 28.6156C124.282 28.6098 124.866 28.6145 125.123 28.5796C125.245 28.3715 125.336 28.252 125.328 28.0082C125.082 27.6121 124.658 27.6901 124.214 27.6711C124.144 27.9762 124.058 28.3111 123.999 28.6156Z" fill="#2F2828"/>
|
||||
<path d="M90.735 26.9215C92.024 26.9243 94.8238 26.5442 93.2493 29.0441C93.0876 29.3009 92.7831 29.446 92.5137 29.583C92.7384 30.1413 92.9478 30.7419 93.1556 31.3095C92.8314 31.3198 92.3036 31.3998 92.0979 31.1551C91.9179 30.6395 91.7971 30.0956 91.3931 29.741C90.8792 29.6812 90.7592 30.8999 90.6765 31.3136L89.8201 31.3149C90.1114 29.8477 90.4166 28.3831 90.735 26.9215ZM91.2167 28.8258C91.4736 28.8227 92.2707 28.8268 92.4815 28.7911C92.6447 28.5511 92.7304 28.4484 92.7926 28.1652C92.6755 27.6152 91.9171 27.7496 91.4458 27.7535C91.366 28.1101 91.2891 28.4675 91.2167 28.8258Z" fill="#2F2828"/>
|
||||
<path d="M69.3081 26.9222L70.1771 26.9141C69.9698 28.186 69.5337 30.0383 69.2388 31.3177L68.1065 31.3098C67.8398 30.3494 67.4692 29.2815 67.1696 28.3128C67.0189 29.2546 66.7757 30.3753 66.5616 31.3076L65.6787 31.3079L66.6075 26.9221L67.7409 26.9223C68.0639 27.8733 68.37 28.898 68.6717 29.8604C68.873 28.8787 69.0851 27.8992 69.3081 26.9222Z" fill="#2F2828"/>
|
||||
<path d="M108.881 29.8868C109.027 29.2478 109.289 27.5001 109.54 27.0002C109.723 26.889 110.12 26.9136 110.352 26.9105C110.263 27.8288 109.679 30.3103 109.457 31.3037L108.341 31.3085C108.011 30.3531 107.719 29.367 107.384 28.4004L106.772 31.3134L105.9 31.3077C106.155 29.911 106.523 28.3126 106.832 26.9175L107.966 26.9206C108.2 27.8185 108.6 28.9795 108.881 29.8868Z" fill="#2F2828"/>
|
||||
<path d="M61.0197 26.9225L61.8629 26.9102C61.7079 28.0991 61.2332 30.0627 60.9722 31.3099L59.8503 31.3086C59.519 30.3592 59.2069 29.3429 58.8931 28.3815L58.2722 31.3125L57.3802 31.3049C57.7123 30.0129 58.0673 28.24 58.3208 26.9184L59.4601 26.922C59.6904 27.8033 60.1149 29.0009 60.4008 29.8927C60.5467 28.9635 60.7999 27.8383 61.0197 26.9225Z" fill="#2F2828"/>
|
||||
<path d="M78.6518 26.9215L79.5294 26.9146C79.4255 27.4595 79.2959 28.0172 79.1766 28.5603C79.689 28.553 80.216 28.5592 80.7298 28.5592L81.0951 26.923L81.9493 26.9141C81.8519 27.9269 81.2576 30.0868 81.0526 31.1643C81.0343 31.2615 80.9867 31.26 80.8865 31.3094C80.6281 31.3129 80.44 31.3262 80.1889 31.262C80.2658 30.6683 80.4253 30.0042 80.5534 29.4127L78.998 29.4118C78.867 30.0925 78.7586 30.6456 78.5669 31.3166L77.7339 31.3159C77.9893 29.9039 78.3363 28.3198 78.6518 26.9215Z" fill="#2F2828"/>
|
||||
<path d="M103.391 26.918L106.201 26.9222L106.032 27.7871C105.48 27.7389 104.623 27.7531 104.059 27.7493C104.008 28.0771 103.939 28.4261 103.88 28.7546L105.576 28.7521L105.445 29.4786C105.093 29.4693 104.014 29.408 103.727 29.5427L103.677 29.7236L103.488 30.4471L105.46 30.4415C105.391 30.7111 105.332 31.0336 105.273 31.3098C104.347 31.2889 103.359 31.3061 102.428 31.3067C102.714 29.9523 103.028 28.2233 103.391 26.918Z" fill="#2F2828"/>
|
||||
<path d="M101.23 26.8806C101.741 26.8186 102.269 26.978 102.715 27.2175C102.67 27.4649 102.637 27.8569 102.466 28.0164C102.325 28.0441 101.618 27.7141 101.272 27.6162C100.683 27.7595 100.181 27.9029 99.8684 28.4645C99.2265 29.6163 99.5405 30.8072 101.032 30.4857C101.475 30.0635 101.026 29.4324 101.687 29.1473C101.888 29.1663 102.02 29.1878 102.216 29.2257C102.312 29.5647 101.987 30.9407 101.742 31.0531C97.9931 32.7738 97.5708 27.2933 101.23 26.8806Z" fill="#2F2828"/>
|
||||
<path d="M74.9385 26.9214C75.1712 26.9118 75.4808 26.8744 75.6821 26.9707C75.7905 27.2226 75.7246 27.2838 75.6726 27.6035C75.3345 28.829 75.382 30.2578 74.2211 31.0737C73.6941 31.4969 72.454 31.4555 72.0957 30.8256C71.57 29.9017 72.2194 27.9607 72.4365 26.9236L73.3245 26.923C73.2315 27.4568 72.5294 29.9806 72.8643 30.2786C74.3522 31.6025 74.7181 27.8186 74.9385 26.9214Z" fill="#2F2828"/>
|
||||
<path d="M64.4934 26.8798C65.0658 26.8499 65.4038 26.9622 65.9183 27.1614C65.9004 27.4821 65.8231 27.8782 65.7694 28.201C65.3237 27.8267 65.0369 27.7669 64.4898 27.6111C64.0346 27.7299 63.4555 27.898 63.1815 28.3015C62.8659 28.7125 62.5666 30.01 63.1207 30.2988C64.5127 31.0242 64.4083 30.3038 64.6865 29.2341C64.6957 29.1988 65.0789 29.1547 65.1737 29.1403L65.443 29.2169C65.5798 29.5824 65.2474 30.6016 65.1325 31.0264C64.7903 31.1835 64.4256 31.2859 64.0516 31.3298C60.9761 31.6856 61.3322 27.1955 64.4934 26.8798Z" fill="#2F2828"/>
|
||||
<path d="M85.1552 26.9215L86.2297 26.918C85.7693 27.4771 85.3097 28.0605 84.8551 28.6258C84.028 29.5226 84.0163 30.1406 83.7762 31.3103L82.8773 31.3089C83.4446 29.0983 83.1291 29.0171 82.3298 26.9199L83.3107 26.9197C83.512 27.4507 83.7059 27.9844 83.8926 28.5204L85.1552 26.9215Z" fill="#2F2828"/>
|
||||
<path d="M139.373 26.9247L140.422 26.918C140.11 27.4009 139.441 28.178 139.059 28.6479C138.207 29.6578 138.305 30.0537 137.985 31.3112L137.113 31.3094C137.262 30.7606 137.396 30.0375 137.516 29.4702C137.197 28.6188 136.873 27.7696 136.543 26.9227L137.516 26.921C137.728 27.4476 137.932 27.9767 138.13 28.5083C138.556 27.9893 138.97 27.4613 139.373 26.9247Z" fill="#2F2828"/>
|
||||
<path d="M57.3993 4.67527C58.1061 4.65383 58.8637 4.66377 59.5748 4.66016C59.8347 5.63537 59.8946 6.41442 59.9652 7.40979C59.3798 7.36896 58.4268 7.39837 57.8139 7.39757C57.7331 6.3945 57.6463 5.64817 57.3993 4.67527Z" fill="#2F2828"/>
|
||||
<path d="M133.406 26.9219L136.447 26.9253L136.333 27.7751C135.986 27.7619 135.585 27.7671 135.234 27.7638C135.054 28.5778 134.891 29.4212 134.722 30.2397L134.468 31.3148L133.589 31.3145C133.858 30.1379 134.11 28.9573 134.343 27.7731C134.007 27.7561 133.613 27.7725 133.273 27.7783L133.406 26.9219Z" fill="#2F2828"/>
|
||||
<path d="M53.4287 26.918L54.3232 26.9254L53.5875 30.453L55.5179 30.4498L55.3763 31.3123C54.4204 31.2926 53.4227 31.3031 52.4631 31.2988C52.7142 30.6151 53.2746 27.7241 53.4287 26.918Z" fill="#2F2828"/>
|
||||
<path d="M128.859 26.924L129.753 26.9219C129.475 28.0093 129.226 29.3489 128.993 30.4563L130.915 30.4496L130.763 31.3133C129.848 31.2883 128.851 31.3078 127.93 31.3087L128.859 26.924Z" fill="#2F2828"/>
|
||||
<path d="M127.308 26.9204L128.164 26.918L127.239 31.3152L126.351 31.3151C126.65 29.8952 126.949 28.3149 127.308 26.9204Z" fill="#2F2828"/>
|
||||
<path d="M56.7724 26.918L57.6656 26.9141C57.3133 28.3287 57.0132 29.8795 56.7123 31.3129L55.8318 31.3086C56.1293 29.8417 56.4429 28.3781 56.7724 26.918Z" fill="#2F2828"/>
|
||||
<path d="M70.8595 26.9214L71.7381 26.918C71.567 28.0722 71.0941 30.1443 70.8119 31.3022L69.9707 31.3105C70.1852 29.9437 70.5812 28.3064 70.8595 26.9214Z" fill="#2F2828"/>
|
||||
<path d="M132.146 26.9187L133.056 26.918C132.809 28.082 132.418 30.2402 132.087 31.3152L131.25 31.3052C131.533 29.8444 131.902 28.3846 132.146 26.9187Z" fill="#2F2828"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -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); });
|
||||
+19
-1
@@ -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";
|
||||
@@ -13,10 +14,16 @@ import {
|
||||
|
||||
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
|
||||
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
|
||||
const HydrogenPrototypeBoard = lazy(
|
||||
() =>
|
||||
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;
|
||||
|
||||
@@ -65,6 +72,7 @@ function AuthGate() {
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (mode === 'password') return <PasswordLogin />;
|
||||
return <UnauthorizedPage message={error || undefined} />;
|
||||
}
|
||||
|
||||
@@ -83,6 +91,16 @@ function AuthGate() {
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (routeKey === "energy/hydrogen-board") {
|
||||
if (!canAccessEnergy(user?.roles)) {
|
||||
return <UnauthorizedPage message="无能源管理模块访问权限" />;
|
||||
}
|
||||
return (
|
||||
<Suspense fallback={<LoadingState label="正在加载氢能经营看板" />}>
|
||||
<HydrogenPrototypeBoard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// /energy 整组按能源权限控制
|
||||
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
|
||||
|
||||
@@ -35,4 +35,5 @@ test('keeps energy heatmap visibility and the independent energy navigation', ()
|
||||
buildModules('energy', []).map(module => module.id),
|
||||
['hydrogen', 'electric', 'etc'],
|
||||
);
|
||||
assert.equal(buildModules('energy', [])[0]?.label, '氢费BI');
|
||||
});
|
||||
|
||||
+2
-2
@@ -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'));
|
||||
@@ -48,7 +48,7 @@ const SCHEDULING_MODULE: ModuleConfig = {
|
||||
};
|
||||
|
||||
const ENERGY_MODULES: ModuleConfig[] = [
|
||||
{ id: 'hydrogen', label: '氢能', icon: Fuel, component: HydrogenModule },
|
||||
{ id: 'hydrogen', label: '氢费BI', icon: Fuel, component: HydrogenModule },
|
||||
{ id: 'electric', label: '电能', icon: Zap, component: ElectricModule },
|
||||
{ id: 'etc', label: 'ETC', icon: Wallet, component: EtcModule },
|
||||
];
|
||||
|
||||
@@ -9,11 +9,14 @@ test('normalizes legacy asset paths without losing search parameters', () => {
|
||||
test('keeps canonical and hidden administration paths unchanged', () => {
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/asset', search: '', hash: '#assets' }), null);
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/admin/feedback', search: '', hash: '' }), null);
|
||||
assert.equal(resolveCanonicalUrl({ pathname: '/energy/hydrogen-board', search: '', hash: '' }), null);
|
||||
});
|
||||
|
||||
test('derives path groups and hidden routes from path or hash', () => {
|
||||
assert.equal(getPathSet('/energy'), 'energy');
|
||||
assert.equal(getPathSet('/energy/hydrogen-board'), 'energy');
|
||||
assert.equal(getPathSet('/asset'), 'asset');
|
||||
assert.equal(getRouteKey('/energy/hydrogen-board', ''), 'energy/hydrogen-board');
|
||||
assert.equal(getRouteKey('/asset', '#/ele/import'), 'ele/import');
|
||||
assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback');
|
||||
assert.equal(getRouteKey('/asset', '#mileage'), '');
|
||||
|
||||
+11
-2
@@ -5,7 +5,13 @@ interface BrowserLocation {
|
||||
search: string;
|
||||
hash: string;
|
||||
}
|
||||
const ROOT_PATHS = new Set(['/asset', '/energy', '/ele/import', '/admin/feedback']);
|
||||
const ROOT_PATHS = new Set([
|
||||
'/asset',
|
||||
'/energy',
|
||||
'/energy/hydrogen-board',
|
||||
'/ele/import',
|
||||
'/admin/feedback',
|
||||
]);
|
||||
|
||||
const LEGACY_PATHS: Record<string, { path: string; hash?: string }> = {
|
||||
'/': { path: '/asset' },
|
||||
@@ -31,10 +37,13 @@ export function normalizeBrowserPath(): void {
|
||||
}
|
||||
|
||||
export function getPathSet(pathname: string): PathSet {
|
||||
return pathname === '/energy' ? 'energy' : 'asset';
|
||||
return pathname === '/energy' || pathname === '/energy/hydrogen-board' ? 'energy' : 'asset';
|
||||
}
|
||||
|
||||
export function getRouteKey(pathname: string, hash: string): string {
|
||||
if (pathname === '/energy/hydrogen-board') {
|
||||
return 'energy/hydrogen-board';
|
||||
}
|
||||
if (pathname === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') {
|
||||
return 'ele/import';
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Building2, ChevronRight, ShieldCheck } from 'lucide-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 ? (
|
||||
@@ -155,7 +153,9 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-2 border-t border-white/10 pt-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-400/10 text-emerald-300 ring-1 ring-emerald-300/20" title={user?.userName || '当前用户'}>
|
||||
<ShieldCheck size={18} />
|
||||
<svg width="28" height="22" viewBox="4 3 44 30" role="img" aria-label="羚牛 Logo">
|
||||
<image href="/lingniu-logo-light.svg" width="150" height="36" style={{ filter: 'brightness(0) invert(1)' }} />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div>
|
||||
</div>
|
||||
@@ -218,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[];
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function ElectricDaily() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
setMonths(null);
|
||||
const query = pick === 'custom'
|
||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
|
||||
: { range: pick };
|
||||
@@ -38,7 +39,11 @@ export default function ElectricDaily() {
|
||||
// 默认展开最新一个月
|
||||
if (m.length > 0) setOpenMonths(new Set([m[0].month]));
|
||||
})
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
.catch(e => {
|
||||
if (cancelled) return;
|
||||
setMonths(null);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
|
||||
|
||||
@@ -71,6 +76,23 @@ export default function ElectricDaily() {
|
||||
setDateRange(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<DailyRangeControls
|
||||
pick={pick}
|
||||
dateRange={dateRange}
|
||||
customer={customer}
|
||||
onQuickPick={applyQuickPick}
|
||||
onCustomPick={() => setPick('custom')}
|
||||
onDateRangeChange={updateDateRange}
|
||||
onCustomerChange={setCustomer}
|
||||
/>
|
||||
<ErrorState message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<DailyRangeControls
|
||||
|
||||
@@ -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,9 +0,0 @@
|
||||
import { EnergyBiBoardApp } from './hydrogen-bi-v2/PrototypeBoard';
|
||||
|
||||
/**
|
||||
* New standalone Hydrogen BI surface. The legacy feature files remain for a
|
||||
* controlled rollback, but the live hydrogen route now uses only v2.
|
||||
*/
|
||||
export default function HydrogenModule() {
|
||||
return <EnergyBiBoardApp />;
|
||||
}
|
||||
@@ -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,544 +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);
|
||||
}
|
||||
|
||||
export default function HydrogenStationBoard({ embedded = false }: { embedded?: boolean }) {
|
||||
const [dateRange, setDateRange] = useState(defaultRange);
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
const [data, setData] = useState<HydrogenStationBoardResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (force = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchHydrogenStationBoard({
|
||||
startDate: dateRange.start,
|
||||
endDate: dateRange.end,
|
||||
stationId: selectedStationId,
|
||||
force,
|
||||
});
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dateRange.end, dateRange.start, selectedStationId]);
|
||||
|
||||
useEffect(() => { void load(false); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
// 站点列表较长,切换列表/详情时统一从页面顶部开始展示。
|
||||
resetPageScroll();
|
||||
const frame = window.requestAnimationFrame(resetPageScroll);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [selectedStationId]);
|
||||
|
||||
const selectedStation = data?.stations.find(station => station.id === selectedStationId) ?? null;
|
||||
|
||||
if (error && !data) {
|
||||
return (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-5 text-sm text-red-700">
|
||||
<div>单站经营数据暂时不可用。</div>
|
||||
<button type="button" onClick={() => void load(true)} className="mt-3 rounded-lg bg-white px-3 py-2 text-xs font-bold shadow-sm">重新加载</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={embedded ? 'sd-embedded' : 'relative flex flex-col gap-3'}>
|
||||
{!(selectedStation && data?.selected) ? <StationBoardHeader
|
||||
embedded={embedded}
|
||||
selectedStation={selectedStation}
|
||||
range={dateRange}
|
||||
latestLedgerTime={data?.summary.latestLedgerTime ?? null}
|
||||
loading={loading}
|
||||
onBack={() => {
|
||||
setSelectedStationId(null);
|
||||
window.requestAnimationFrame(resetPageScroll);
|
||||
}}
|
||||
onRangeChange={setDateRange}
|
||||
onRefresh={() => void load(true)}
|
||||
onExport={() => data && exportStationEvidence(data, selectedStation)}
|
||||
/> : null}
|
||||
|
||||
{data ? selectedStation && data.selected ? (
|
||||
<StationDetail
|
||||
station={selectedStation}
|
||||
data={data}
|
||||
latestLedgerTime={data.summary.latestLedgerTime}
|
||||
loading={loading}
|
||||
onBack={() => setSelectedStationId(null)}
|
||||
onRangeChange={setDateRange}
|
||||
onRefresh={() => void load(true)}
|
||||
onExport={() => exportStationEvidence(data, selectedStation)}
|
||||
/>
|
||||
) : (
|
||||
<StationOverview
|
||||
data={data}
|
||||
onSelectStation={stationId => {
|
||||
setSelectedStationId(stationId);
|
||||
window.requestAnimationFrame(resetPageScroll);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<StationBoardSkeleton />
|
||||
)}
|
||||
|
||||
{loading && data ? (
|
||||
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-white/45 backdrop-blur-[1px]">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-xs font-bold text-slate-600 shadow-xl">
|
||||
<RefreshCw size={15} className="animate-spin text-blue-600" /> 正在刷新单站数据
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StationBoardHeader({
|
||||
embedded,
|
||||
selectedStation,
|
||||
range,
|
||||
latestLedgerTime,
|
||||
loading,
|
||||
onBack,
|
||||
onRangeChange,
|
||||
onRefresh,
|
||||
onExport,
|
||||
}: {
|
||||
embedded: boolean;
|
||||
selectedStation: HydrogenStationBoardStation | null;
|
||||
range: { start: string; end: string };
|
||||
latestLedgerTime: string | null;
|
||||
loading: boolean;
|
||||
onBack: () => void;
|
||||
onRangeChange: (range: { start: string; end: string }) => void;
|
||||
onRefresh: () => void;
|
||||
onExport: () => void;
|
||||
}) {
|
||||
if (embedded && !selectedStation) {
|
||||
return <header className="sd-topbar sd-topbar--embedded"><p className="sd-topbar__updated">最后更新时间 {latestLedgerTime ?? '暂无账本时间'}</p><div className="sd-topbar__tools"><StationRangePicker range={range} onChange={onRangeChange} /><button type="button" onClick={onRefresh} disabled={loading} className="sd-btn sd-btn--ghost"><RefreshCw size={14} className={loading ? 'animate-spin' : ''} /> 刷新</button></div></header>;
|
||||
}
|
||||
return (
|
||||
<header className="rounded-[12px] border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{selectedStation ? (
|
||||
<button type="button" onClick={onBack} className="inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 px-3 text-xs font-bold text-slate-600 hover:bg-slate-50">
|
||||
<ArrowLeft size={15} /> 返回
|
||||
</button>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
{selectedStation ? <h1 className="truncate text-[18px] font-black text-slate-950">{selectedStation.name}</h1> : null}
|
||||
{selectedStation ? <p className="mt-0.5 text-[11px] font-medium text-slate-500">{regionLabel(selectedStation)} · {range.start} 至 {range.end}</p> : null}
|
||||
<p className={`${selectedStation ? 'mt-1' : ''} text-[11px] font-medium text-slate-400`}>最后更新时间 {latestLedgerTime ?? '暂无账本时间'}</p>
|
||||
</div>
|
||||
<label className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 text-[11px] font-bold text-slate-600">
|
||||
<Calendar size={14} className="text-blue-600" />
|
||||
<span className="hidden sm:inline">查询日期</span>
|
||||
<input aria-label="开始日期" type="date" value={range.start} onChange={event => onRangeChange({ ...range, start: event.target.value })} className="w-[110px] bg-transparent outline-none" />
|
||||
<span>至</span>
|
||||
<input aria-label="结束日期" type="date" value={range.end} onChange={event => onRangeChange({ ...range, end: event.target.value })} className="w-[110px] bg-transparent outline-none" />
|
||||
</label>
|
||||
<button type="button" onClick={onRefresh} disabled={loading} className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-slate-200 px-3 text-xs font-bold text-slate-600 hover:bg-slate-50 disabled:opacity-60">
|
||||
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} /> 刷新
|
||||
</button>
|
||||
{selectedStation ? (
|
||||
<button type="button" onClick={onExport} className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-blue-600 px-3 text-xs font-bold text-white shadow-sm hover:bg-blue-700">
|
||||
<Download size={14} /> 导出取证
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function StationRangePicker({ range, onChange }: { range: { start: string; end: string }; onChange: (range: { start: string; end: string }) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeBoundary, setActiveBoundary] = useState<'start' | 'end'>('start');
|
||||
const [draft, setDraft] = useState(range);
|
||||
const initial = new Date(`${range.start}T00:00:00`);
|
||||
const [displayMonth, setDisplayMonth] = useState({ year: initial.getFullYear(), month: initial.getMonth() + 1 });
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const closeWhenOutside = (event: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', closeWhenOutside);
|
||||
return () => document.removeEventListener('mousedown', closeWhenOutside);
|
||||
}, []);
|
||||
const anchor = new Date(`${range.end}T00:00:00`);
|
||||
const apply = (next: { start: string; end: string }, close = false) => {
|
||||
const normalized = next.start <= next.end ? next : { start: next.end, end: next.start };
|
||||
setDraft(normalized);
|
||||
if (close) { onChange(normalized); setOpen(false); }
|
||||
};
|
||||
const today = formatYmd(anchor);
|
||||
const startOfWeek = new Date(anchor);
|
||||
startOfWeek.setDate(anchor.getDate() - ((anchor.getDay() + 6) % 7));
|
||||
const shortcuts = {
|
||||
today: { start: today, end: today },
|
||||
week: { start: formatYmd(startOfWeek), end: formatYmd(new Date(startOfWeek.getFullYear(), startOfWeek.getMonth(), startOfWeek.getDate() + 6)) },
|
||||
month: { start: `${anchor.getFullYear()}-${String(anchor.getMonth() + 1).padStart(2, '0')}-01`, end: formatYmd(new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0)) },
|
||||
};
|
||||
const daysInMonth = new Date(displayMonth.year, displayMonth.month, 0).getDate();
|
||||
const leadingDays = new Date(displayMonth.year, displayMonth.month - 1, 1).getDay();
|
||||
const selectDay = (day: number) => {
|
||||
const value = `${displayMonth.year}-${String(displayMonth.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
if (activeBoundary === 'start') {
|
||||
const next = { start: value, end: value > draft.end ? value : draft.end };
|
||||
setDraft(next); setActiveBoundary('end'); return;
|
||||
}
|
||||
const next = value < draft.start ? { start: value, end: draft.start } : { start: draft.start, end: value };
|
||||
setDraft(next); setActiveBoundary('start');
|
||||
};
|
||||
return <div className="sd-date sd-date--right" ref={rootRef}>
|
||||
<button type="button" className={`sd-date__trigger ${open ? 'is-open' : ''}`} aria-label="查询日期" aria-haspopup="dialog" aria-expanded={open} onClick={() => {
|
||||
if (!open) { setDraft(range); setActiveBoundary('start'); setDisplayMonth({ year: initial.getFullYear(), month: initial.getMonth() + 1 }); }
|
||||
setOpen(value => !value);
|
||||
}}><span className="sd-date__label">查询日期</span><span className="sd-date__value">{range.start} 至 {range.end}</span><ChevronDown size={15} aria-hidden className="sd-date__icon" /></button>
|
||||
{open ? <div className="sd-date__popover sd-date__popover--range" role="dialog" aria-label="查询日期">
|
||||
<div className="sd-date__shortcuts"><button type="button" onClick={() => apply(shortcuts.today, true)}>本日</button><button type="button" onClick={() => apply(shortcuts.week, true)}>本周</button><button type="button" onClick={() => apply(shortcuts.month, true)}>本月</button></div>
|
||||
<div className="sd-date__range-tabs"><button type="button" className={activeBoundary === 'start' ? 'is-on' : ''} onClick={() => setActiveBoundary('start')}>开始 {draft.start}</button><button type="button" className={activeBoundary === 'end' ? 'is-on' : ''} onClick={() => setActiveBoundary('end')}>结束 {draft.end}</button></div>
|
||||
<div className="sd-date__header"><button type="button" className="sd-date__nav" aria-label="上一月" onClick={() => setDisplayMonth(value => value.month === 1 ? { year: value.year - 1, month: 12 } : { ...value, month: value.month - 1 })}><ChevronLeft size={16} /></button><div className="sd-date__title">{displayMonth.year}年{String(displayMonth.month).padStart(2, '0')}月</div><button type="button" className="sd-date__nav" aria-label="下一月" onClick={() => setDisplayMonth(value => value.month === 12 ? { year: value.year + 1, month: 1 } : { ...value, month: value.month + 1 })}><ChevronRight size={16} /></button></div>
|
||||
<div className="sd-date__week">{['日','一','二','三','四','五','六'].map(day => <span key={day}>{day}</span>)}</div><div className="sd-date__grid">{Array.from({ length: leadingDays }, (_, index) => <span key={`empty-${index}`} className="sd-date__day is-empty" />)}{Array.from({ length: daysInMonth }, (_, index) => { const day = index + 1; const value = `${displayMonth.year}-${String(displayMonth.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const classes = ['sd-date__day', value === draft.start || value === draft.end ? 'is-selected' : '', value >= draft.start && value <= draft.end ? 'is-in-range' : ''].filter(Boolean).join(' '); return <button key={day} type="button" className={classes} onClick={() => selectDay(day)}>{day}</button>; })}</div>
|
||||
<div className="sd-date__footer sd-date__footer--range"><button type="button" className="sd-date__today" onClick={() => apply(shortcuts.today, true)}>本日</button><button type="button" className="sd-btn sd-btn--primary sd-date__apply" onClick={() => apply(draft, true)}>确定</button></div>
|
||||
</div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function StationOverview({ data, onSelectStation }: { data: HydrogenStationBoardResponse; onSelectStation: (id: number) => void }) {
|
||||
const [displayMode, setDisplayMode] = useState<'all' | 'single'>('all');
|
||||
const [displayStationId, setDisplayStationId] = useState(data.stations[0]?.id ?? 0);
|
||||
const [dailyDrill, setDailyDrill] = useState<'kg' | 'fee' | 'payment' | null>(null);
|
||||
const [partnersExpanded, setPartnersExpanded] = useState(false);
|
||||
const { selfManagedStations, partnerStations } = useMemo(() => {
|
||||
const selfManaged = data.stations
|
||||
.filter(isSelfManagedStation)
|
||||
.sort((left, right) => selfManagedStationRank(left) - selfManagedStationRank(right));
|
||||
const selfManagedIds = new Set(selfManaged.map(station => station.id));
|
||||
return {
|
||||
selfManagedStations: selfManaged,
|
||||
partnerStations: data.stations
|
||||
.filter(station => station.kg > 0 && !selfManagedIds.has(station.id))
|
||||
.sort((left, right) => right.kg - left.kg || left.name.localeCompare(right.name, 'zh-CN')),
|
||||
};
|
||||
}, [data.stations]);
|
||||
const visiblePartnerStations = partnersExpanded ? partnerStations : partnerStations.slice(0, 10);
|
||||
const selectedStation = data.stations.filter(station => station.id === displayStationId);
|
||||
// Top5 的条形、图例与钻取入口必须共用同一份按加氢量降序的结果。
|
||||
const shareStations = data.stations
|
||||
.filter(station => station.kg > 0)
|
||||
.sort((left, right) => right.kg - left.kg)
|
||||
.slice(0, 5);
|
||||
const shareTrackTotal = shareStations.reduce((sum, station) => sum + station.kg, 0);
|
||||
return (
|
||||
<>
|
||||
<section aria-label="全站核心指标" className="sd-hero-kpis">
|
||||
<div className="sd-hero-kpi"><span className="sd-hero-kpi__label">加氢站</span><strong className="sd-hero-kpi__value">{data.summary.stationCount}<span>站</span></strong><span className="sd-hero-kpi__sub">统计站点数 · 活跃 {data.summary.activeStationCount} 站</span></div>
|
||||
<StationSummaryMetric label="统计加氢总量" value={formatNumber(data.summary.totalKg)} unit="Kg" helper={`${data.summary.recordCount} 车次 · 点按查看日明细`} onClick={() => setDailyDrill('kg')} />
|
||||
<StationSummaryMetric label="统计加氢量" value={formatNumber(data.summary.totalKg)} unit="Kg" helper={`¥${formatNumber(data.summary.totalFee)} · ${data.summary.daily.length} 天`} onClick={() => setDailyDrill('fee')} />
|
||||
<StationSummaryMetric label="统计现结金额" value={formatNumber(data.summary.paymentAmount)} unit="元" helper={`${data.summary.daily.filter(item => item.paymentAmount > 0).length} 天有进账`} onClick={() => setDailyDrill('payment')} />
|
||||
</section>
|
||||
<section className="sd-share-panel" aria-label="加氢量占比Top5">
|
||||
<div className="sd-share-panel__head"><h2 className="sd-share-panel__title">加氢量占比Top5 <span className="sd-share-panel__range">{data.range.start} 至 {data.range.end}</span></h2><span className="sd-share-panel__total">合计 <strong>{formatNumber(data.summary.totalKg)} Kg</strong></span></div>
|
||||
<div className="sd-share-track">{shareStations.map((station, index) => <button key={station.id} type="button" className={`sd-share-seg sd-share-seg--${index}`} style={{ width: `${shareTrackTotal > 0 ? (station.kg / shareTrackTotal) * 100 : 0}%` }} onClick={() => onSelectStation(station.id)}><span className="sd-share-seg__pct">{(station.share * 100).toFixed(1)}%</span><span className="sd-share-seg__name">{station.name}</span></button>)}</div>
|
||||
<ul className="sd-share-legend">{shareStations.map((station, index) => <li key={station.id}><button type="button" className="sd-share-legend__btn" onClick={() => onSelectStation(station.id)}><span className={`sd-share-legend__swatch sd-share-seg--${index}`} /><span className="sd-share-legend__rank" aria-label={`第 ${index + 1} 名`}>{index + 1}</span><span className="sd-share-legend__name">{station.name}</span><span className="sd-share-legend__val">{formatNumber(station.kg)} Kg · {(station.share * 100).toFixed(1)}%</span></button></li>)}</ul>
|
||||
</section>
|
||||
|
||||
<section aria-label="各站经营概况">
|
||||
<div className="sd-section-head"><h2 className="sd-section-title">各站概况</h2><div className="sd-board-mode" role="tablist" aria-label="各站展示方式"><button type="button" role="tab" aria-selected={displayMode === 'all'} onClick={() => setDisplayMode('all')} className={displayMode === 'all' ? 'is-on' : ''}>全部</button><button type="button" role="tab" aria-selected={displayMode === 'single'} onClick={() => setDisplayMode('single')} className={displayMode === 'single' ? 'is-on' : ''}>单站</button>{displayMode === 'single' ? <select aria-label="选择站点" value={displayStationId} onChange={event => setDisplayStationId(Number(event.target.value))} className="sd-board-station-select">{data.stations.map(station => <option key={station.id} value={station.id}>{station.name}</option>)}</select> : null}</div></div>
|
||||
{displayMode === 'all' ? <div className="sd-station-groups">
|
||||
<StationGroup title="自营站" count={selfManagedStations.length} stations={selfManagedStations} onSelectStation={onSelectStation} emptyText="自营站数据待接入" />
|
||||
<StationGroup title="合作站" count={partnerStations.length} stations={visiblePartnerStations} onSelectStation={onSelectStation} emptyText="本区间暂无合作站加氢量" />
|
||||
{partnerStations.length > 10 ? <button type="button" className="sd-more-btn" onClick={() => setPartnersExpanded(value => !value)}>{partnersExpanded ? '收起合作站' : `更多合作站(还有 ${partnerStations.length - 10} 站)`} <ChevronDown size={14} aria-hidden className={partnersExpanded ? 'rotate-180' : ''} /></button> : null}
|
||||
</div> : <div className="sd-station-single-card"><StationRows stations={selectedStation} onSelectStation={onSelectStation} /></div>}
|
||||
</section>
|
||||
<StationSummaryDailyDialog
|
||||
mode={dailyDrill}
|
||||
range={data.range}
|
||||
rows={data.summary.daily}
|
||||
onClose={() => setDailyDrill(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function isSelfManagedStation(station: HydrogenStationBoardStation): boolean {
|
||||
return station.name.includes('佛山南海羚牛') || station.name.includes('东鹏大道');
|
||||
}
|
||||
|
||||
function selfManagedStationRank(station: HydrogenStationBoardStation): number {
|
||||
return station.name.includes('佛山南海羚牛') ? 0 : 1;
|
||||
}
|
||||
|
||||
function StationGroup({ title, count, stations, onSelectStation, emptyText }: {
|
||||
title: string;
|
||||
count: number;
|
||||
stations: HydrogenStationBoardStation[];
|
||||
onSelectStation: (id: number) => void;
|
||||
emptyText: string;
|
||||
}) {
|
||||
return <section className="sd-station-group" aria-label={title}>
|
||||
<div className="sd-station-group__head"><h3>{title}</h3><span>{count} 站</span></div>
|
||||
{stations.length ? <div className="sd-station-single-card"><StationRows stations={stations} onSelectStation={onSelectStation} /></div> : <p className="sd-station-group__empty">{emptyText}</p>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function StationRows({ stations, onSelectStation }: { stations: HydrogenStationBoardStation[]; onSelectStation: (id: number) => void }) {
|
||||
return <>{stations.map(station => <button key={station.id} type="button" onClick={() => onSelectStation(station.id)} className="sd-station-row"><div className="sd-station-row__main"><span className="sd-station-card__icon"><Fuel size={17} /></span><div><div className="sd-station-card__name">{station.name}</div><div className="sd-station-card__region">{regionLabel(station)}</div></div>{station.kg > 0 ? <MiniSpark values={station.dailyKg.map(item => item.kg)} /> : <div className="sd-spark sd-spark--empty" />}<div className="sd-station-row__metrics"><StationRowMetric label="区间加氢" value={formatNumber(station.kg)} unit="Kg" /><StationRowMetric label="区间金额" value={formatNumber(station.fee)} unit="元" /><StationRowMetric label="现结进账" value={formatNumber(station.paymentAmount)} unit="元" /><div><span className="sd-station-card__m-label">加氢量占比</span><span className="sd-station-card__share">{(station.share * 100).toFixed(1)}%</span></div></div><span className="sd-station-card__go"><ChevronRight size={17} /></span></div></button>)}</>;
|
||||
}
|
||||
|
||||
function StationSummaryMetric({ label, value, unit, helper, onClick }: { label: string; value: string; unit?: string; helper: string; onClick: () => void }) {
|
||||
return <button type="button" onClick={onClick} className="sd-hero-kpi sd-hero-kpi--click"><span className="sd-hero-kpi__label">{label}</span><strong className="sd-hero-kpi__value">{value}{unit ? <span>{unit}</span> : null}</strong><span className="sd-hero-kpi__sub">{helper}</span></button>;
|
||||
}
|
||||
|
||||
function StationSummaryDailyDialog({
|
||||
mode,
|
||||
range,
|
||||
rows,
|
||||
onClose,
|
||||
}: {
|
||||
mode: 'kg' | 'fee' | 'payment' | null;
|
||||
range: { start: string; end: string };
|
||||
rows: HydrogenStationBoardSummaryDailyRow[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [sortKey, setSortKey] = useState<'date' | 'recordCount' | 'kg' | 'fee' | 'paymentCount' | 'paymentAmount'>('date');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const sortedRows = useMemo(() => sortBy(rows, sortKey, sortDirection, (row, key) => row[key]), [rows, sortDirection, sortKey]);
|
||||
const changeSort = (nextKey: typeof sortKey) => {
|
||||
const next = toggleSort(sortKey, sortDirection, nextKey);
|
||||
setSortKey(next.key);
|
||||
setSortDirection(next.direction);
|
||||
};
|
||||
if (!mode) return null;
|
||||
const title = mode === 'kg'
|
||||
? '统计加氢总量 · 日明细'
|
||||
: mode === 'fee'
|
||||
? '统计加氢金额 · 单日数据'
|
||||
: '统计现结金额 · 单日数据';
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-slate-950/60 p-3 backdrop-blur-[4px]" role="presentation" onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
|
||||
<section className="flex max-h-[82vh] w-full max-w-[720px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl" role="dialog" aria-modal="true" aria-labelledby="station-summary-drill-title">
|
||||
<header className="flex items-center justify-between gap-3 border-b border-slate-100 px-4 py-3">
|
||||
<div>
|
||||
<h3 id="station-summary-drill-title" className="text-[15px] font-black text-slate-900">{title}</h3>
|
||||
<p className="mt-0.5 text-[11px] font-medium text-slate-400">{range.start} 至 {range.end}</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200" aria-label="关闭"><X size={16} /></button>
|
||||
</header>
|
||||
<div className="overflow-auto">
|
||||
{mode === 'payment' ? (
|
||||
<table className="w-full min-w-[420px] text-[12px]">
|
||||
<thead className="sticky top-0 bg-slate-50 text-slate-500"><tr><th className="px-4 py-2.5 text-left"><SortableColumnHeader label="日期" sortKey="date" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="现结笔数" sortKey="paymentCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="现结金额(元)" sortKey="paymentAmount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th></tr></thead>
|
||||
<tbody>{sortedRows.map(row => <tr key={row.date} className="border-t border-slate-100"><td className="px-4 py-2.5 font-mono font-semibold text-slate-700">{row.date}</td><td className="px-4 py-2.5 text-right tabular-nums text-slate-600">{row.paymentCount}</td><td className="px-4 py-2.5 text-right font-semibold tabular-nums text-slate-800">{formatNumber(row.paymentAmount)}</td></tr>)}</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<table className="w-full min-w-[620px] text-[12px]">
|
||||
<thead className="sticky top-0 bg-slate-50 text-slate-500"><tr><th className="px-4 py-2.5 text-left"><SortableColumnHeader label="日期" sortKey="date" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="加氢车次" sortKey="recordCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="加氢量(Kg)" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="加氢金额(元)" sortKey="fee" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th></tr></thead>
|
||||
<tbody>{sortedRows.map(row => <tr key={row.date} className="border-t border-slate-100"><td className="px-4 py-2.5 font-mono font-semibold text-slate-700">{row.date}</td><td className="px-4 py-2.5 text-right tabular-nums text-slate-600">{row.recordCount}</td><td className="px-4 py-2.5 text-right font-semibold tabular-nums text-slate-800">{formatNumber(row.kg)}</td><td className="px-4 py-2.5 text-right font-semibold tabular-nums text-slate-800">{formatNumber(row.fee)}</td></tr>)}</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StationRowMetric({ label, value, unit }: { label: string; value: string; unit: string }) {
|
||||
return <div><span className="sd-station-card__m-label">{label}</span><strong>{value}<span>{unit}</span></strong></div>;
|
||||
}
|
||||
|
||||
function MiniSpark({ values }: { values: number[] }) {
|
||||
const max = Math.max(...values, 0);
|
||||
return <div className="sd-spark" aria-label="站点每日加氢趋势">{values.map((value, index) => <span key={index} className="sd-spark__col"><span className="sd-spark__bar" style={{ height: `${max > 0 ? Math.max(6, Math.round((value / max) * 100)) : 0}%` }} /></span>)}</div>;
|
||||
}
|
||||
|
||||
function StationDetail({
|
||||
station,
|
||||
data,
|
||||
latestLedgerTime,
|
||||
loading,
|
||||
onBack,
|
||||
onRangeChange,
|
||||
onRefresh,
|
||||
onExport,
|
||||
}: {
|
||||
station: HydrogenStationBoardStation;
|
||||
data: HydrogenStationBoardResponse;
|
||||
latestLedgerTime: string | null;
|
||||
loading: boolean;
|
||||
onBack: () => void;
|
||||
onRangeChange: (range: { start: string; end: string }) => void;
|
||||
onRefresh: () => void;
|
||||
onExport: () => void;
|
||||
}) {
|
||||
const detail = data.selected!;
|
||||
const [hoveredDate, setHoveredDate] = useState<string | null>(null);
|
||||
const [dailySummaryExpanded, setDailySummaryExpanded] = useState(false);
|
||||
const lastDaily = detail.daily.at(-1);
|
||||
const visibleDailySummary = dailySummaryExpanded
|
||||
? detail.daily
|
||||
: detail.daily.slice(-30);
|
||||
const monthKey = data.range.end.slice(0, 7);
|
||||
const monthRows = detail.customerMonths.filter(item => item.month === monthKey);
|
||||
const monthKg = monthRows.reduce((sum, row) => sum + row.kg, 0);
|
||||
const intervalPayments = detail.daily.reduce((sum, row) => sum + row.paymentAmount, 0);
|
||||
const trendMax = Math.max(...detail.daily.map(row => row.kg), 0);
|
||||
const hovered = detail.daily.find(row => row.date === hoveredDate) ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="sd-detail-top">
|
||||
<div className="sd-detail-top__lead"><button type="button" className="sd-btn sd-btn--ghost" onClick={onBack}><ArrowLeft size={16} /> 返回</button><div><h1 className="sd-detail-top__title">{station.name}</h1><p className="sd-detail-top__meta">{regionLabel(station)} · {data.range.start} 至 {data.range.end}</p><p className="sd-detail-top__updated">最后更新时间 {latestLedgerTime ?? '暂无账本时间'}</p></div></div>
|
||||
<div className="sd-detail-top__tools"><StationRangePicker range={data.range} onChange={onRangeChange} /><button type="button" className="sd-btn sd-btn--ghost" onClick={onRefresh} disabled={loading}><RefreshCw size={14} className={loading ? 'animate-spin' : ''} /> 刷新</button><button type="button" className="sd-btn sd-btn--primary" onClick={onExport}><Download size={14} /> 导出取证</button></div>
|
||||
</section>
|
||||
<section className="sd-hero-kpis sd-hero-kpis--detail">
|
||||
<DetailMetric label="当日加氢" value={formatNumber(lastDaily?.kg ?? 0)} unit="Kg" helper={`¥${formatNumber(lastDaily?.fee ?? 0)} · ${lastDaily?.recordCount ?? 0}车次`} />
|
||||
<DetailMetric label={`近 ${detail.daily.length} 日加氢`} value={formatNumber(station.kg)} unit="Kg" helper={`¥${formatNumber(station.fee)}`} />
|
||||
<DetailMetric label="本月加氢" value={formatNumber(monthKg)} unit="Kg" helper={monthKey} />
|
||||
<DetailMetric label={`近 ${detail.daily.length} 日现结`} value={formatNumber(intervalPayments)} unit="元" helper={`${detail.daily.filter(row => row.paymentAmount > 0).length}天有进账`} />
|
||||
</section>
|
||||
<section className="sd-panel sd-panel--block">
|
||||
<h2 className="sd-panel__title">加氢站每日加氢量汇总({data.range.start} 至 {data.range.end})</h2>
|
||||
<div className="sd-table-scroll"><table className="sd-bi-table sd-bi-table--fill"><thead><tr><th>日期 ↓</th><th className="is-num">加氢量(Kg) ↕</th><th className="is-num">较昨日 ↕</th><th className="is-num">单价 ↕</th><th className="is-num">成本金额(元) ↕</th><th className="is-num">车次 ↕</th></tr></thead><tbody>
|
||||
<tr className="is-total"><td>查询区间合计</td><td className="is-num">{formatNumber(detail.daily.reduce((sum, row) => sum + row.kg, 0))}</td><td className="is-num">—</td><td className="is-num">—</td><td className="is-num">{formatNumber(detail.daily.reduce((sum, row) => sum + row.fee, 0))}</td><td className="is-num">{detail.daily.reduce((sum, row) => sum + row.recordCount, 0)}</td></tr>
|
||||
{[...visibleDailySummary].reverse().map(row => <tr key={row.date}><td className="is-mono">{row.date}</td><td className="is-num">{formatNumber(row.kg)}</td><td className={`is-num ${row.changeKg > 0 ? 'is-stock-up' : row.changeKg < 0 ? 'is-stock-down' : 'is-stock-flat'}`}>{row.changeKg > 0 ? '+' : ''}{formatNumber(row.changeKg)}{row.changeKg !== 0 ? <span className="sd-delta">{row.changeKg > 0 ? '▲' : '▼'}</span> : null}</td><td className="is-num">{formatNumber(row.avgPrice)}</td><td className="is-num">{formatNumber(row.fee)}</td><td className="is-num">{row.recordCount}</td></tr>)}
|
||||
{detail.daily.length > 30 ? <tr><td colSpan={6} className="sd-table-more"><button type="button" onClick={() => setDailySummaryExpanded(value => !value)}>{dailySummaryExpanded ? '收起日期明细' : `更多(还有 ${detail.daily.length - 30} 天)`}</button></td></tr> : null}
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
<section className="sd-panel sd-panel--block"><div className="sd-panel__head-row"><h2 className="sd-panel__title">区间加氢量趋势</h2><span className="sd-trend-legend-chip"><span className="sd-trend-legend-dot" />{station.name}</span></div><div className="sd-trend">{hovered ? <div className="sd-trend-tip"><div className="sd-trend-tip__date">{hovered.date}</div><div className="sd-trend-tip__row"><span className="sd-trend-tip__name">加氢量</span><strong>{formatNumber(hovered.kg)} Kg</strong></div><div className="sd-trend-tip__sub">{hovered.recordCount}车次 · ¥{formatNumber(hovered.fee)}</div></div> : null}<div className="sd-trend--fill">{detail.daily.map(row => <div key={row.date} className={`sd-trend__col ${hoveredDate === row.date ? 'is-hover' : ''}`} onMouseEnter={() => setHoveredDate(row.date)} onMouseLeave={() => setHoveredDate(null)}><span className="sd-trend__val">{formatNumber(row.kg, 0)}</span><div className="sd-trend__bar-wrap"><span className="sd-trend__bar" style={{ height: `${trendMax > 0 ? Math.max(3, Math.round((row.kg / trendMax) * 100)) : 0}%` }} /></div><span className="sd-trend__date">{row.date.slice(5)}</span></div>)}</div></div></section>
|
||||
|
||||
<CustomerMonthMatrix title="客户月加氢量汇总(Kg)" valueKey="kg" rows={detail.customerMonths} />
|
||||
<CustomerMonthMatrix title="客户月加氢费汇总(元)" valueKey="fee" rows={detail.customerMonths} />
|
||||
<StationCashPanels />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailMetric({ label, value, unit, helper }: { label: string; value: string; unit: string; helper: string }) {
|
||||
return <div className="sd-hero-kpi sd-hero-kpi--accent"><span className="sd-hero-kpi__label">{label}</span><strong className="sd-hero-kpi__value">{value}<span className="sd-unit">{unit}</span></strong><span className="sd-hero-kpi__sub">{helper}</span></div>;
|
||||
}
|
||||
|
||||
function CustomerMonthMatrix({ title, valueKey, rows }: { title: string; valueKey: 'kg' | 'fee'; rows: HydrogenStationBoardCustomerMonth[] }) {
|
||||
const months = useMemo(() => [...new Set(rows.map(row => row.month))].sort(), [rows]);
|
||||
const customers = useMemo(() => [...new Set(rows.map(row => row.customerName))].sort((left, right) => left.localeCompare(right, 'zh-CN')), [rows]);
|
||||
const [selectedCustomers, setSelectedCustomers] = useState<string[]>([]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const values = useMemo(() => new Map(rows.map(row => [`${row.customerName}\u0000${row.month}`, row[valueKey]])), [rows, valueKey]);
|
||||
const totals = useMemo(() => new Map(months.map(month => [month, rows.filter(row => row.month === month).reduce((sum,row)=>sum+row[valueKey],0)])), [months, rows, valueKey]);
|
||||
const visibleCustomers = selectedCustomers.length === 0 ? customers : customers.filter(customer => selectedCustomers.includes(customer));
|
||||
const shownCustomers = expanded ? visibleCustomers : visibleCustomers.slice(0, 10);
|
||||
const trendClass = (current: number, previous: number | undefined) => previous == null || current === previous ? '' : current > previous ? 'is-stock-up' : 'is-stock-down';
|
||||
const trendMark = (current: number, previous: number | undefined) => previous == null || current === previous ? '' : current > previous ? '▲' : '▼';
|
||||
return (
|
||||
<section className="sd-panel sd-panel--block">
|
||||
<div className="sd-panel__head-row"><h2 className="sd-panel__title">{title}</h2><CustomerMultiSelect options={customers} value={selectedCustomers} onChange={setSelectedCustomers} /></div>
|
||||
<div className="sd-table-scroll sd-table-scroll--matrix"><table className={`sd-bi-table sd-bi-table--matrix ${valueKey === 'fee' ? 'is-amount' : ''}`}><thead><tr><th>客户</th>{months.map(month => <th key={month} className="is-num">{month.replace('-', '年')}月</th>)}</tr></thead><tbody>
|
||||
<tr className="is-total"><td>合计</td>{months.map((month, index) => { const current = totals.get(month) ?? 0; const previous = index > 0 ? totals.get(months[index - 1]) : undefined; return <td key={month} className={`is-num ${trendClass(current, previous)}`}>{formatNumber(current)}{trendMark(current, previous)}</td>; })}</tr>
|
||||
{shownCustomers.map(customer => <tr key={customer}><td title={customer}>{customer}</td>{months.map((month, index) => { const current = values.get(`${customer}\u0000${month}`) ?? 0; const previous = index > 0 ? values.get(`${customer}\u0000${months[index - 1]}`) : undefined; return <td key={month} className={`is-num ${trendClass(current, previous)}`}>{formatNumber(current)}{trendMark(current, previous)}</td>; })}</tr>)}
|
||||
</tbody></table></div>{visibleCustomers.length > 10 ? <button type="button" className="sd-more-btn" onClick={() => setExpanded(value => !value)}>{expanded ? '收起' : `更多(还有 ${visibleCustomers.length - 10} 条)`} <ChevronDown size={14} aria-hidden className={expanded ? 'rotate-180' : ''} /></button> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StationCashPanels() {
|
||||
return <section className="sd-dual sd-dual--cash">
|
||||
<section className="sd-panel sd-panel--grow">
|
||||
<div className="sd-panel__head-row"><h2 className="sd-panel__title">客户氢费收支汇总</h2></div>
|
||||
<div className="sd-table-scroll sd-table-scroll--cash-lines"><table className="sd-bi-table sd-bi-table--ledger"><thead><tr><th>客户</th><th className="is-num">充值/现金结算</th><th className="is-num">扣预付</th><th className="is-num">现结</th><th className="is-num">余额</th><th>备注</th></tr></thead><tbody><tr className="sd-pending-row"><td colSpan={6}>数据写入中,敬请期待。</td></tr></tbody></table></div>
|
||||
</section>
|
||||
<section className="sd-panel sd-panel--grow">
|
||||
<div className="sd-panel__head-row"><h2 className="sd-panel__title">氢费充值/现结进账明细</h2><span className="sd-panel__meta">数据写入中</span></div>
|
||||
<div className="sd-table-scroll sd-table-scroll--cash-lines"><table className="sd-bi-table sd-bi-table--ledger"><thead><tr><th>充值日期</th><th>客户</th><th>付款方式</th><th className="is-num">金额(元)</th></tr></thead><tbody><tr className="sd-pending-row"><td colSpan={4}>数据写入中,敬请期待。</td></tr></tbody></table></div>
|
||||
</section>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function CustomerMultiSelect({ options, value, onChange }: { options: string[]; value: string[]; onChange: (value: string[]) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const allSelected = value.length === 0 || value.length === options.length;
|
||||
const selectedLabel = allSelected ? '全部客户' : value.length <= 2 ? value.join('、') : `已选 ${value.length} 家`;
|
||||
const visibleOptions = search.trim() ? options.filter(option => option.includes(search.trim())) : options;
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (event: MouseEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); };
|
||||
document.addEventListener('mousedown', close);
|
||||
return () => document.removeEventListener('mousedown', close);
|
||||
}, [open]);
|
||||
const toggle = (customer: string) => {
|
||||
if (allSelected) { onChange([customer]); return; }
|
||||
if (value.includes(customer)) { const next = value.filter(item => item !== customer); onChange(next.length === 0 ? [] : next); return; }
|
||||
const next = [...value, customer];
|
||||
onChange(next.length === options.length ? [] : next);
|
||||
};
|
||||
return <div className={`sd-msel ${open ? 'is-open' : ''}`} ref={rootRef}><button type="button" className="sd-msel__trigger" aria-haspopup="listbox" aria-expanded={open} onClick={() => setOpen(current => !current)}><span className="sd-msel__label">客户</span><span className="sd-msel__value" title={selectedLabel}>{selectedLabel}</span><ChevronDown size={14} aria-hidden className="sd-msel__chev" /></button>{open ? <div className="sd-msel__panel" role="listbox" aria-multiselectable="true"><div className="sd-msel__search"><input type="search" value={search} onChange={event => setSearch(event.target.value)} placeholder="搜索客户" aria-label="搜索客户" /></div><div className="sd-msel__actions"><button type="button" onClick={() => onChange([])}>全选</button><button type="button" onClick={() => { onChange([]); setOpen(false); }}>重置</button></div><ul className="sd-msel__list">{visibleOptions.length === 0 ? <li className="sd-msel__empty">无匹配客户</li> : visibleOptions.map(option => { const on = allSelected || value.includes(option); return <li key={option}><button type="button" className={`sd-msel__opt ${on ? 'is-on' : ''}`} role="option" aria-selected={on} onClick={() => toggle(option)}><span className="sd-msel__check" aria-hidden>{on ? <Check size={12} /> : null}</span><span className="sd-msel__name">{option}</span></button></li>; })}</ul></div> : null}</div>;
|
||||
}
|
||||
|
||||
function exportStationEvidence(data: HydrogenStationBoardResponse, station: HydrogenStationBoardStation | null) {
|
||||
if (!station || !data.selected) return;
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const daily = [['日期','加氢量(Kg)','较昨日(Kg)','平均单价','加氢金额(元)','车次','现结金额(元)','现结笔数'], ...data.selected.daily.map(row => [row.date,row.kg,row.changeKg,row.avgPrice,row.fee,row.recordCount,row.paymentAmount,row.paymentCount])];
|
||||
const customers = [['月份','客户','加氢量(Kg)','加氢费(元)','车次'], ...data.selected.customerMonths.map(row => [row.month,row.customerName,row.kg,row.fee,row.recordCount])];
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(daily), '每日汇总');
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(customers), '客户月汇总');
|
||||
XLSX.writeFile(workbook, `${station.name}_${data.range.start}_${data.range.end}_取证.xlsx`);
|
||||
}
|
||||
|
||||
function StationBoardSkeleton() {
|
||||
return <div className="grid gap-3"><div className="h-28 animate-pulse rounded-xl bg-slate-200/70" /><div className="h-72 animate-pulse rounded-xl bg-slate-200/70" /></div>;
|
||||
}
|
||||
@@ -1,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,29 +87,6 @@ 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
|
||||
@@ -132,7 +104,6 @@ export default function DailyRangeControls({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{updatedAt ? <span className="font-mono text-[11px] text-slate-400">{updatedAt}</span> : null}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,46 +0,0 @@
|
||||
import { fetchJson } from '../../../auth/api-client';
|
||||
import type {
|
||||
H2BiDailyResponse,
|
||||
H2BiDailyTreeQuery,
|
||||
H2BiDailyTreeResponse,
|
||||
H2BiDrillQuery,
|
||||
H2BiDrillResponse,
|
||||
H2BiMetaResponse,
|
||||
H2BiOverviewResponse,
|
||||
H2BiQuery,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api/energy/h2/v2';
|
||||
|
||||
function queryString(query: Record<string, unknown>) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null && value !== '') params.set(key, String(value));
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function request<T>(path: string, query: object = {}) {
|
||||
const qs = queryString(query as Record<string, unknown>);
|
||||
return fetchJson<T>(`${BASE}/${path}${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export function fetchH2BiMeta() {
|
||||
return request<H2BiMetaResponse>('meta');
|
||||
}
|
||||
|
||||
export function fetchH2BiOverview(query: H2BiQuery) {
|
||||
return request<H2BiOverviewResponse>('overview', query);
|
||||
}
|
||||
|
||||
export function fetchH2BiDaily(query: H2BiQuery) {
|
||||
return request<H2BiDailyResponse>('daily', query);
|
||||
}
|
||||
|
||||
export function fetchH2BiDailyTree(date: string, query: H2BiDailyTreeQuery) {
|
||||
return request<H2BiDailyTreeResponse>('daily-tree', { date, ...query });
|
||||
}
|
||||
|
||||
export function fetchH2BiDrill(query: H2BiDrillQuery) {
|
||||
return request<H2BiDrillResponse>('drill', query);
|
||||
}
|
||||
@@ -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,869 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronLeft, Download, Search, Truck, X } from "lucide-react";
|
||||
import { fetchH2BiDrill, fetchH2BiMeta } from "./api";
|
||||
import { downloadExcelAoa } from "./prototype-download";
|
||||
import type {
|
||||
H2BiDrillGroupBy,
|
||||
H2BiDrillGroupRow,
|
||||
H2BiDrillResponse,
|
||||
H2BiAmountScope,
|
||||
H2BiMetaResponse,
|
||||
H2BiQuery,
|
||||
H2BiVehicleScope,
|
||||
} from "./types";
|
||||
|
||||
type DrillKind = "kpi" | "customer" | "station" | "records";
|
||||
|
||||
export interface PrototypeDrillModalProps {
|
||||
kind: DrillKind;
|
||||
label: string;
|
||||
query: H2BiQuery;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type DrillRootDimension = "station" | "customer";
|
||||
|
||||
type DrillState = Pick<
|
||||
H2BiQuery,
|
||||
| "stationId"
|
||||
| "customerId"
|
||||
| "customerName"
|
||||
| "plateNo"
|
||||
| "date"
|
||||
| "month"
|
||||
| "region"
|
||||
| "regionGranularity"
|
||||
> & {
|
||||
level: H2BiDrillGroupBy;
|
||||
rootDimension: DrillRootDimension;
|
||||
amountScope: H2BiAmountScope;
|
||||
stationName?: string;
|
||||
};
|
||||
|
||||
const formatNumber = (value: unknown, digits = 2) =>
|
||||
Number(value ?? 0).toLocaleString("zh-CN", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
const fleetScope = (value: "all" | "own" | "external"): H2BiVehicleScope =>
|
||||
value === "own" ? "lingniu" : value;
|
||||
const verifyLabel = (status: unknown) => {
|
||||
const value = String(status ?? "").trim().toLowerCase();
|
||||
return value === "verified" || value === "pass" || value === "已验证"
|
||||
? "已验证"
|
||||
: "未验证";
|
||||
};
|
||||
|
||||
const sourceLabel = (source: unknown) => {
|
||||
const value = String(source ?? "").trim().toLowerCase();
|
||||
if (value === "api") return "API接入";
|
||||
if (value === "station" || value === "station_report") return "站点上报";
|
||||
if (value === "lingniu" || value === "lingniu_report") return "羚牛上报";
|
||||
return String(source ?? "真实账本");
|
||||
};
|
||||
|
||||
const sourceClass = (source: unknown) => {
|
||||
const value = String(source ?? "").trim().toLowerCase();
|
||||
if (value === "api") return "ehb-tag--source-api";
|
||||
if (value === "station" || value === "station_report") return "ehb-tag--source-station";
|
||||
return "ehb-tag--source-lingniu";
|
||||
};
|
||||
|
||||
/** 原生 select 会出现双箭头且不支持长列表检索;统一为可搜索选择器。 */
|
||||
function DrillSearchSelect({
|
||||
ariaLabel,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
allLabel,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
allLabel: string;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
if (open) document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setKeyword("");
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}, [open]);
|
||||
|
||||
const selectedLabel =
|
||||
options.find((option) => option.value === value)?.label ?? allLabel;
|
||||
const matches = useMemo(() => {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return query
|
||||
? options.filter(
|
||||
(option) =>
|
||||
option.label.toLowerCase().includes(query) ||
|
||||
option.value.toLowerCase().includes(query),
|
||||
)
|
||||
: options;
|
||||
}, [keyword, options]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ehb-bi-search-select ${disabled ? "is-disabled" : ""}`}
|
||||
ref={rootRef}
|
||||
style={{ width: 180 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-bi-search-select__trigger ${open ? "is-open" : ""} ${value ? "has-value" : ""}`}
|
||||
onClick={() => !disabled && setOpen((shown) => !shown)}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
title={selectedLabel}
|
||||
>
|
||||
<span className="ehb-bi-search-select__label">{selectedLabel}</span>
|
||||
<ChevronDown size={13} className="ehb-bi-search-select__chevron" />
|
||||
</button>
|
||||
{open && !disabled ? (
|
||||
<div className="ehb-bi-search-select__dropdown">
|
||||
<label className="ehb-bi-search-select__search">
|
||||
<Search size={13} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={`搜索${ariaLabel}`}
|
||||
/>
|
||||
</label>
|
||||
<div className="ehb-bi-search-select__list">
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-bi-search-select__item ${value ? "" : "is-selected"}`}
|
||||
onClick={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{allLabel}
|
||||
</button>
|
||||
{matches.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`ehb-bi-search-select__item ${value === option.value ? "is-selected" : ""}`}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
title={option.label}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{matches.length === 0 ? (
|
||||
<div className="ehb-bi-search-select__empty">无匹配项</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function initialState(kind: DrillKind, label: string): DrillState {
|
||||
// Canonical labels use “2026年4月”. Accept the former “2026年2026-04”
|
||||
// form too so a legacy label can never silently drop the month filter.
|
||||
const monthParts =
|
||||
label.match(/(\d{4})年(\d{1,2})月/) ?? label.match(/(\d{4})-(\d{1,2})/);
|
||||
const regionMatch = label.match(/区域(市|省):(.+)/);
|
||||
const stationIdMatch = label.match(/stationId=(\d+)/);
|
||||
const cleanLabel = label.replace(/\|stationId=\d+$/, "");
|
||||
// 对客金额、对客成本和利润都只看客户承担订单;利润减法使用完全
|
||||
// 相同订单集。成本图的另外两段则按账本 settlement_type 定向下钻。
|
||||
const amountScope: H2BiAmountScope = /我司承担成本/.test(cleanLabel)
|
||||
? "company"
|
||||
: /其他成本/.test(cleanLabel)
|
||||
? "other"
|
||||
: /加氢利润|对客金额|对客收入|客户收入|客户承担成本|对客成本/.test(cleanLabel)
|
||||
? "customer"
|
||||
: "all";
|
||||
if (kind === "customer" || kind === "station")
|
||||
return { level: "date", rootDimension: "station", amountScope };
|
||||
if (kind === "records")
|
||||
return { level: "record", rootDimension: "station", amountScope };
|
||||
return {
|
||||
level: stationIdMatch ? "customer" : "station",
|
||||
rootDimension: "station",
|
||||
amountScope,
|
||||
stationId: stationIdMatch ? stationIdMatch[1] : undefined,
|
||||
month: monthParts
|
||||
? `${monthParts[1]}-${monthParts[2].padStart(2, "0")}`
|
||||
: undefined,
|
||||
region: regionMatch ? regionMatch[2] : undefined,
|
||||
regionGranularity: regionMatch
|
||||
? regionMatch[1] === "省"
|
||||
? "province"
|
||||
: "city"
|
||||
: undefined,
|
||||
// Keep this assignment solely to make the parsed label explicit in DevTools;
|
||||
// it is not sent as a business filter.
|
||||
customerName: cleanLabel.startsWith("客户:")
|
||||
? cleanLabel.slice(3)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function titleFor(level: H2BiDrillGroupBy) {
|
||||
return (
|
||||
{
|
||||
station: "加氢站",
|
||||
customer: "客户",
|
||||
date: "日期",
|
||||
vehicle: "车辆",
|
||||
record: "加氢记录",
|
||||
} as const
|
||||
)[level];
|
||||
}
|
||||
|
||||
function nextState(current: DrillState, row: H2BiDrillGroupRow): DrillState {
|
||||
if (current.level === "station") {
|
||||
if (current.rootDimension === "customer" && current.customerId !== undefined)
|
||||
return { ...current, level: "vehicle", stationId: row.id, stationName: row.name };
|
||||
return { ...current, level: "customer", stationId: row.id, stationName: row.name };
|
||||
}
|
||||
if (current.level === "customer") {
|
||||
if (current.rootDimension === "customer" && current.customerId === undefined)
|
||||
return {
|
||||
...current,
|
||||
level: "station",
|
||||
customerId: Number(row.id),
|
||||
customerName: row.name,
|
||||
};
|
||||
return {
|
||||
...current,
|
||||
level: "vehicle",
|
||||
customerId: Number(row.id),
|
||||
customerName: row.name,
|
||||
};
|
||||
}
|
||||
if (current.level === "date")
|
||||
return { ...current, level: "vehicle", date: row.id };
|
||||
if (current.level === "vehicle")
|
||||
return { ...current, level: "record", plateNo: row.name };
|
||||
return current;
|
||||
}
|
||||
|
||||
function useDrill(query: H2BiQuery, state: DrillState) {
|
||||
const [data, setData] = useState<H2BiDrillResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const key = JSON.stringify({ ...query, ...state });
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchH2BiDrill({
|
||||
...query,
|
||||
...state,
|
||||
groupBy: state.level,
|
||||
amountScope: state.amountScope,
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
})
|
||||
.then((result) => alive && setData(result))
|
||||
.catch(
|
||||
(reason: unknown) =>
|
||||
alive &&
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "下钻数据加载失败",
|
||||
),
|
||||
)
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [key]);
|
||||
return { data, error, loading };
|
||||
}
|
||||
|
||||
function GroupTable({
|
||||
state,
|
||||
data,
|
||||
onOpen,
|
||||
}: {
|
||||
state: DrillState;
|
||||
data: H2BiDrillResponse;
|
||||
onOpen: (row: H2BiDrillGroupRow) => void;
|
||||
}) {
|
||||
const customerBearingScope = data.amountScope === "customer";
|
||||
const costLabel =
|
||||
data.amountScope === "company"
|
||||
? "我司承担成本金额"
|
||||
: data.amountScope === "other"
|
||||
? "其他成本金额"
|
||||
: customerBearingScope
|
||||
? "对客成本金额"
|
||||
: "成本金额";
|
||||
const isProfit = state.amountScope === "customer";
|
||||
if (state.level === "record") {
|
||||
return (
|
||||
<table className="ehb-modal-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>加氢站 / 客户 / 车辆与凭证链路</th>
|
||||
<th>类型 / 归属</th>
|
||||
<th>数据来源及凭证号</th>
|
||||
<th>核对状态</th>
|
||||
<th style={{ textAlign: "right" }}>加氢笔数</th>
|
||||
<th style={{ textAlign: "right" }}>加氢总量 (Kg)</th>
|
||||
<th style={{ textAlign: "right" }}>{costLabel} (元)</th>
|
||||
<th style={{ textAlign: "right" }}>对客金额 (元)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.stationName ? (
|
||||
<tr className="ehb-tree-row-station">
|
||||
<td className="ehb-tree-cell-l1"><div className="ehb-tree-node-title"><span aria-hidden>▾</span><strong>{state.stationName}</strong></div></td>
|
||||
<td><span className="ehb-tree-node-sub">加氢站</span></td><td><span className="ehb-tree-node-sub">真实账本流水</span></td><td>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{state.customerName ? (
|
||||
<tr className="ehb-tree-row-customer">
|
||||
<td className="ehb-tree-cell-l2"><div className="ehb-tree-node-title"><span aria-hidden>▾</span><strong>└─ 客户:{state.customerName}</strong></div></td>
|
||||
<td><span className="ehb-tree-node-sub">客户</span></td><td><span className="ehb-tree-node-sub">真实账本流水</span></td><td>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{state.plateNo ? (
|
||||
<tr>
|
||||
<td className="ehb-tree-cell-l3"><div className="ehb-tree-node-title"><span aria-hidden>▾</span><strong>└─ {state.plateNo}</strong></div></td>
|
||||
<td><span className="ehb-tag ehb-tag--own-fleet">车辆</span></td><td><span className="ehb-tree-node-sub">真实账本流水</span></td><td>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td><td style={{ textAlign: "right" }}>—</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{data.records.map((record, index) => (
|
||||
<tr key={`${String(record.id)}-${index}`}>
|
||||
<td className="ehb-tree-cell-l4">
|
||||
<div className="ehb-tree-ord-block">
|
||||
<strong>
|
||||
订单编号 {String(record.orderNo || record.id || "—")}
|
||||
</strong>
|
||||
<span className="ehb-tree-ord-time">
|
||||
{String(record.time || "—")} · {String(record.stationName || "未关联站点")} · {String(record.customerName || "未关联客户")}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`ehb-tag ${record.vehicleScope === "lingniu" ? "ehb-tag--own-fleet" : "ehb-tag--ext-fleet"}`}
|
||||
>
|
||||
{record.vehicleScope === "lingniu" ? "羚牛车辆" : "外部车辆"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`ehb-tag ${sourceClass(record.source)}`}>
|
||||
{sourceLabel(record.source)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`ehb-tag ${verifyLabel(record.verifyStatus) === "已验证" ? "ehb-tag--verify-ok" : "ehb-tag--verify-warn"}`}
|
||||
>
|
||||
{verifyLabel(record.verifyStatus)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
1 笔
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>{formatNumber(record.kg)}</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
¥{formatNumber(record.cost)}
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
¥{formatNumber(record.revenue)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<table className="ehb-modal-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>加氢站 / 客户 / 车辆与凭证链路</th>
|
||||
<th>类型 / 归属</th>
|
||||
<th>数据来源及凭证号</th>
|
||||
<th>核对状态</th>
|
||||
<th style={{ textAlign: "right" }}>加氢笔数</th>
|
||||
<th style={{ textAlign: "right" }}>加氢总量 (Kg)</th>
|
||||
<th style={{ textAlign: "right" }}>{costLabel} (元)</th>
|
||||
<th style={{ textAlign: "right" }}>对客金额 (元)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.stationName ? (
|
||||
<tr className="ehb-tree-row-station">
|
||||
<td className="ehb-tree-cell-l1">
|
||||
<div className="ehb-tree-node-title"><span aria-hidden>▾</span><strong>{state.stationName}</strong></div>
|
||||
</td>
|
||||
<td><span className="ehb-tree-node-sub">加氢站</span></td>
|
||||
<td><span className="ehb-tree-node-sub">真实账本流水</span></td>
|
||||
<td>—</td>
|
||||
<td style={{ textAlign: "right" }}>—</td>
|
||||
<td style={{ textAlign: "right" }}>—</td>
|
||||
<td style={{ textAlign: "right" }}>—</td>
|
||||
<td style={{ textAlign: "right" }}>—</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{state.customerName && state.level !== "customer" ? (
|
||||
<tr className="ehb-tree-row-customer">
|
||||
<td className="ehb-tree-cell-l2">
|
||||
<div className="ehb-tree-node-title"><span aria-hidden>▾</span><strong>└─ 客户:{state.customerName}</strong></div>
|
||||
</td>
|
||||
<td><span className="ehb-tree-node-sub">客户</span></td>
|
||||
<td><span className="ehb-tree-node-sub">真实账本流水</span></td>
|
||||
<td>—</td>
|
||||
<td style={{ textAlign: "right" }}>—</td>
|
||||
<td style={{ textAlign: "right" }}>—</td>
|
||||
<td style={{ textAlign: "right" }}>—</td>
|
||||
<td style={{ textAlign: "right" }}>—</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{data.groups.map((row, index) => (
|
||||
<tr
|
||||
key={`${row.id}-${index}`}
|
||||
onClick={() => onOpen(row)}
|
||||
style={{ cursor: "pointer" }}
|
||||
title={`点击继续查看${titleFor(nextState(state, row).level)}`}
|
||||
>
|
||||
<td
|
||||
className={
|
||||
state.level === "station"
|
||||
? "ehb-tree-cell-l1"
|
||||
: state.level === "customer"
|
||||
? "ehb-tree-cell-l2"
|
||||
: state.level === "vehicle"
|
||||
? "ehb-tree-cell-l3"
|
||||
: "ehb-tree-cell-l1"
|
||||
}
|
||||
>
|
||||
<div className="ehb-tree-node-title">
|
||||
<span aria-hidden>{state.level === "station" ? "▸" : "└─"}</span>
|
||||
<strong>
|
||||
{state.level === "customer" ? "客户:" : ""}
|
||||
{row.name}
|
||||
</strong>
|
||||
{row.province ? <span className="ehb-tree-node-sub">{row.province}</span> : null}
|
||||
<span className="ehb-kpi-drill-hint">钻取 ›</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{state.level === "vehicle" ? (
|
||||
<span
|
||||
className={`ehb-tag ${row.lingniuKg > 0 ? "ehb-tag--own-fleet" : "ehb-tag--ext-fleet"}`}
|
||||
>
|
||||
{row.lingniuKg > 0 ? "羚牛车辆" : "外部车辆"}
|
||||
</span>
|
||||
) : state.level === "station" ? (
|
||||
<span className="ehb-tree-node-sub">
|
||||
覆盖 {formatNumber(row.customerCount, 0)} 家客户
|
||||
</span>
|
||||
) : (
|
||||
<span className="ehb-tree-node-sub">聚合</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="ehb-tree-node-sub">
|
||||
{state.level === "vehicle" ? "真实账本流水" : "全量自动归集"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="ehb-tree-node-sub">
|
||||
{state.level === "station" ? "汇总" : "点击展开"}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>{formatNumber(row.recordCount, 0)} 笔</td>
|
||||
<td style={{ textAlign: "right", fontWeight: 700 }}>
|
||||
{formatNumber(row.kg)}
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
¥{formatNumber(row.cost)}
|
||||
</td>
|
||||
<td style={{ textAlign: "right", color: isProfit ? "#10b981" : undefined }}>
|
||||
¥{formatNumber(row.revenue)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/** Preserves the prototype overlay/card/meta/table anatomy, but every row is live ledger data. */
|
||||
export function PrototypeDrillModal({
|
||||
kind,
|
||||
label,
|
||||
query,
|
||||
onClose,
|
||||
}: PrototypeDrillModalProps) {
|
||||
const [state, setState] = useState<DrillState>(() => ({
|
||||
...initialState(kind, label),
|
||||
...(kind === "customer" ? { customerName: label } : {}),
|
||||
}));
|
||||
const [history, setHistory] = useState<DrillState[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [meta, setMeta] = useState<H2BiMetaResponse | null>(null);
|
||||
const [fleetCategoryFilter, setFleetCategoryFilter] = useState<
|
||||
"all" | "own" | "external"
|
||||
>(() =>
|
||||
query.vehicleScope === "lingniu"
|
||||
? "own"
|
||||
: query.vehicleScope === "external"
|
||||
? "external"
|
||||
: "all",
|
||||
);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void fetchH2BiMeta().then((result) => alive && setMeta(result)).catch(() => {
|
||||
// The drill itself remains usable even when the optional select metadata is unavailable.
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
const liveQuery = useMemo(
|
||||
() => ({ ...query, vehicleScope: fleetScope(fleetCategoryFilter) }),
|
||||
[fleetCategoryFilter, query],
|
||||
);
|
||||
const live = useDrill(liveQuery, state);
|
||||
const data = useMemo(() => {
|
||||
if (!live.data || !search.trim()) return live.data;
|
||||
const term = search.trim().toLowerCase();
|
||||
return {
|
||||
...live.data,
|
||||
groups: live.data.groups.filter((row) =>
|
||||
JSON.stringify(row).toLowerCase().includes(term),
|
||||
),
|
||||
records: live.data.records.filter((row) =>
|
||||
JSON.stringify(row).toLowerCase().includes(term),
|
||||
),
|
||||
};
|
||||
}, [live.data, search]);
|
||||
const cleanLabel = label.replace(/\|stationId=\d+$/, "");
|
||||
const customerBearingScope = live.data?.amountScope === "customer";
|
||||
const profit =
|
||||
Number(live.data?.summary.revenue ?? 0) -
|
||||
Number(live.data?.summary.cost ?? 0);
|
||||
const coverageStationCount = Number(
|
||||
live.data?.summary.stationCount ??
|
||||
(state.stationId
|
||||
? 1
|
||||
: state.level === "station"
|
||||
? live.data?.groups.length ?? 0
|
||||
: 0),
|
||||
);
|
||||
const traceability = Number(live.data?.summary.recordCount ?? 0)
|
||||
? (Number(live.data?.summary.traceableRecordCount ?? 0) /
|
||||
Number(live.data?.summary.recordCount ?? 1)) *
|
||||
100
|
||||
: 0;
|
||||
const open = (row: H2BiDrillGroupRow) => {
|
||||
setHistory((items) => [...items, state]);
|
||||
setState(nextState(state, row));
|
||||
};
|
||||
const stationOptions = meta?.stations ?? [];
|
||||
const customerOptions =
|
||||
state.level === "customer"
|
||||
? live.data?.groups.map((row) => ({ id: row.id, name: row.name })) ?? []
|
||||
: state.customerId && state.customerName
|
||||
? [{ id: String(state.customerId), name: state.customerName }]
|
||||
: [];
|
||||
const vehicleOptions =
|
||||
state.level === "vehicle"
|
||||
? live.data?.groups.map((row) => ({ id: row.id, name: row.name })) ?? []
|
||||
: state.plateNo
|
||||
? [{ id: state.plateNo, name: state.plateNo }]
|
||||
: [];
|
||||
const resetToRoot = (overrides: Partial<DrillState> = {}) => {
|
||||
setHistory([]);
|
||||
setSearch("");
|
||||
setState({ ...initialState(kind, label), ...overrides });
|
||||
};
|
||||
const changeStation = (value: string) => {
|
||||
if (!value) {
|
||||
resetToRoot({ rootDimension: state.rootDimension, level: state.rootDimension });
|
||||
return;
|
||||
}
|
||||
resetToRoot({
|
||||
stationId: value,
|
||||
stationName: stationOptions.find((station) => String(station.id) === value)?.name,
|
||||
level: "customer",
|
||||
rootDimension: "station",
|
||||
});
|
||||
};
|
||||
const changeCustomer = (value: string) => {
|
||||
if (!value) {
|
||||
if (state.stationId) setState((current) => ({ ...current, level: "customer", customerId: undefined, plateNo: undefined }));
|
||||
return;
|
||||
}
|
||||
setHistory([]);
|
||||
setSearch("");
|
||||
setState((current) => ({
|
||||
...current,
|
||||
customerId: Number(value),
|
||||
customerName: customerOptions.find((customer) => customer.id === value)?.name,
|
||||
plateNo: undefined,
|
||||
level: "vehicle",
|
||||
}));
|
||||
};
|
||||
const changeVehicle = (value: string) => {
|
||||
if (!value) {
|
||||
setState((current) => ({ ...current, level: "vehicle", plateNo: undefined }));
|
||||
return;
|
||||
}
|
||||
setHistory([]);
|
||||
setSearch("");
|
||||
setState((current) => ({ ...current, plateNo: value, level: "record" }));
|
||||
};
|
||||
const exportCurrent = () => {
|
||||
if (!data) return;
|
||||
const rows: Array<Array<string | number>> = [
|
||||
[
|
||||
"加氢站 / 客户 / 车辆与凭证链路",
|
||||
"加氢量(Kg)",
|
||||
"成本金额(元)",
|
||||
"对客金额(元)",
|
||||
],
|
||||
];
|
||||
if (state.level === "record") {
|
||||
data.records.forEach((record) =>
|
||||
rows.push([
|
||||
`${String(record.stationName || "未关联站点")} / ${String(record.customerName || "未关联客户")} / ${String(record.plateNo || "无车牌")} / ${String(record.orderNo || record.id || "—")}`,
|
||||
Number(record.kg ?? 0),
|
||||
Number(record.cost ?? 0),
|
||||
Number(record.revenue ?? 0),
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
data.groups.forEach((row) =>
|
||||
rows.push([row.name, row.kg, row.cost, row.revenue]),
|
||||
);
|
||||
}
|
||||
downloadExcelAoa(rows, `${cleanLabel}_真实账本穿透.xlsx`, "真实账本穿透");
|
||||
};
|
||||
const switchRootDimension = (rootDimension: DrillRootDimension) => {
|
||||
if (kind !== "kpi" || state.rootDimension === rootDimension) return;
|
||||
setHistory([]);
|
||||
setSearch("");
|
||||
setState({
|
||||
...initialState(kind, label),
|
||||
rootDimension,
|
||||
level: rootDimension,
|
||||
});
|
||||
};
|
||||
const back = () => {
|
||||
const previous = history.at(-1);
|
||||
if (!previous) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setHistory((items) => items.slice(0, -1));
|
||||
setState(previous);
|
||||
};
|
||||
const scope =
|
||||
state.date ||
|
||||
state.month ||
|
||||
query.date ||
|
||||
query.month ||
|
||||
(query.startDate && query.endDate
|
||||
? `${query.startDate} 至 ${query.endDate}`
|
||||
: `${query.year} 年`);
|
||||
const displayTitle = /\d{4}年/.test(cleanLabel)
|
||||
? `${cleanLabel}明细`
|
||||
: `「${query.year}」${cleanLabel}明细`;
|
||||
return (
|
||||
<div
|
||||
className="ehb-modal-overlay"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className="ehb-modal-card"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="ehb-modal-head">
|
||||
<div className="ehb-modal-head__title-group">
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-modal-back-btn"
|
||||
onClick={back}
|
||||
title="返回"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
<span>{history.length ? "返回上级" : "返回"}</span>
|
||||
</button>
|
||||
<div>
|
||||
<div className="ehb-modal-head__title">{displayTitle}</div>
|
||||
<div className="ehb-modal-head__sub">真实账本下钻 · {scope}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-modal-head__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-modal-close-btn"
|
||||
onClick={onClose}
|
||||
title="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-modal-body">
|
||||
<div className="ehb-modal-meta-bar">
|
||||
<div className="ehb-modal-meta-item">
|
||||
<span className="ehb-modal-meta-label">数据归集总量</span>
|
||||
<span className="ehb-modal-meta-val">
|
||||
<span style={{ color: "#0284c7" }}>
|
||||
{formatNumber(Number(live.data?.summary.kg ?? 0) / 1000)} T
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="ehb-modal-meta-item">
|
||||
<span className="ehb-modal-meta-label">数据总金额</span>
|
||||
<span className="ehb-modal-meta-val">
|
||||
<span style={{ color: "#10b981" }}>
|
||||
¥{formatNumber(Number(live.data?.summary.cost ?? 0) / 10000)} 万元
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="ehb-modal-meta-item">
|
||||
<span className="ehb-modal-meta-label">覆盖加氢站数</span>
|
||||
<span className="ehb-modal-meta-val">
|
||||
{coverageStationCount} 站
|
||||
</span>
|
||||
</div>
|
||||
<div className="ehb-modal-meta-item">
|
||||
<span className="ehb-modal-meta-label">数据源可追溯率</span>
|
||||
<span className="ehb-modal-meta-val">
|
||||
<span style={{ color: "#f59e0b" }}>
|
||||
{formatNumber(traceability, 0)}%(含账本来源字段)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{cleanLabel === "加氢利润" && customerBearingScope ? (
|
||||
<div className="ehb-modal-hint-text" style={{ marginBottom: 10 }}>
|
||||
利润口径:客户承担订单的对客总价 ¥
|
||||
{formatNumber(live.data?.summary.revenue)} − 成本总价 ¥
|
||||
{formatNumber(live.data?.summary.cost)} = ¥{formatNumber(profit)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="ehb-modal-filter-row">
|
||||
<div className="ehb-modal-filter-group">
|
||||
<DrillSearchSelect
|
||||
ariaLabel="筛选加氢站"
|
||||
value={state.stationId ? String(state.stationId) : ""}
|
||||
onChange={changeStation}
|
||||
options={stationOptions.map((station) => ({
|
||||
value: String(station.id),
|
||||
label: station.name,
|
||||
}))}
|
||||
allLabel="全部加氢站"
|
||||
placeholder="搜索加氢站"
|
||||
/>
|
||||
<DrillSearchSelect
|
||||
ariaLabel="筛选客户"
|
||||
value={state.customerId ? String(state.customerId) : ""}
|
||||
onChange={changeCustomer}
|
||||
options={customerOptions.map((customer) => ({
|
||||
value: String(customer.id),
|
||||
label: customer.name,
|
||||
}))}
|
||||
allLabel="全部客户"
|
||||
placeholder="搜索客户"
|
||||
disabled={!state.stationId}
|
||||
/>
|
||||
<DrillSearchSelect
|
||||
ariaLabel="筛选车辆"
|
||||
value={state.plateNo ?? ""}
|
||||
onChange={changeVehicle}
|
||||
options={vehicleOptions.map((vehicle) => ({
|
||||
value: vehicle.name,
|
||||
label: vehicle.name,
|
||||
}))}
|
||||
allLabel="全部车辆"
|
||||
placeholder="搜索车牌"
|
||||
disabled={!state.customerId}
|
||||
/>
|
||||
<div className="ehb-fleet-segmented" role="radiogroup" aria-label="车辆归属">
|
||||
{([
|
||||
["all", "全部车辆"],
|
||||
["own", "仅羚牛车辆"],
|
||||
["external", "仅外部车辆"],
|
||||
] as const).map(([value, text]) => (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={fleetCategoryFilter === value}
|
||||
className={`ehb-fleet-btn ${fleetCategoryFilter === value ? "is-active" : ""}`}
|
||||
onClick={() => setFleetCategoryFilter(value)}
|
||||
key={value}
|
||||
>
|
||||
{value !== "all" ? <Truck size={14} aria-hidden /> : null}
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="ehb-btn ehb-btn--outline ehb-export-btn" onClick={exportCurrent}>
|
||||
<Download size={14} aria-hidden />
|
||||
导出 Excel 穿透账单
|
||||
</button>
|
||||
{kind === "kpi" && history.length === 0 ? (
|
||||
<div className="ehb-pill-tabs" role="group" aria-label="下钻首层维度">
|
||||
<button type="button" className={`ehb-pill-btn ${state.rootDimension === "station" ? "is-active" : ""}`} onClick={() => switchRootDimension("station")}>加氢站 → 客户</button>
|
||||
<button type="button" className={`ehb-pill-btn ${state.rootDimension === "customer" ? "is-active" : ""}`} onClick={() => switchRootDimension("customer")}>客户 → 加氢站</button>
|
||||
</div>
|
||||
) : null}
|
||||
<span className="ehb-modal-hint-text">提示:点击表格行可四级层层展开【加氢站 → 客户 → 车牌 → 单笔订单与核对明细】</span>
|
||||
</div>
|
||||
<label className="ehb-modal-search-input">
|
||||
<Search size={14} />
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索当前下钻结果" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="ehb-h5-scroll-hint">‹ 左右滑动查看完整指标列 ›</div>
|
||||
<div className="ehb-modal-table-wrap is-v-scroll">
|
||||
{live.loading ? (
|
||||
<div className="ehb-empty">正在加载真实下钻数据…</div>
|
||||
) : live.error ? (
|
||||
<div className="ehb-empty">{live.error}</div>
|
||||
) : data &&
|
||||
(state.level === "record"
|
||||
? data.records.length
|
||||
: data.groups.length) > 0 ? (
|
||||
<GroupTable state={state} data={data} onOpen={open} />
|
||||
) : (
|
||||
<div className="ehb-empty">当前筛选范围暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function prototypeFleetScope(value: "all" | "own" | "external") {
|
||||
return fleetScope(value);
|
||||
}
|
||||
@@ -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,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,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/, '')}月`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { fetchJson } from '../../../auth/api-client';
|
||||
import type {
|
||||
H2BiDailyResponse,
|
||||
H2BiDailyTreeQuery,
|
||||
H2BiDailyTreeResponse,
|
||||
H2BiDrillReadOptions,
|
||||
H2BiFullDrillOptions,
|
||||
H2BiFullDrillResponse,
|
||||
H2BiDrillQuery,
|
||||
H2BiDrillResponse,
|
||||
H2BiMetaResponse,
|
||||
H2BiOverviewResponse,
|
||||
H2BiQuery,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api/energy/h2/v2';
|
||||
|
||||
function queryString(query: Record<string, unknown>) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null && value !== '') params.set(key, String(value));
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function request<T>(path: string, query: object = {}, options?: RequestInit) {
|
||||
const qs = queryString(query as Record<string, unknown>);
|
||||
return fetchJson<T>(`${BASE}/${path}${qs ? `?${qs}` : ''}`, options);
|
||||
}
|
||||
|
||||
export function fetchH2BiMeta() {
|
||||
return request<H2BiMetaResponse>('meta');
|
||||
}
|
||||
|
||||
export function fetchH2BiOverview(query: H2BiQuery) {
|
||||
return request<H2BiOverviewResponse>('overview', query);
|
||||
}
|
||||
|
||||
export function fetchH2BiDaily(query: H2BiQuery) {
|
||||
return request<H2BiDailyResponse>('daily', query).catch(async () => {
|
||||
// The date-group drill is backed by the same read-only ledger and has a
|
||||
// simpler query plan. Keep the date view usable when the aggregate daily
|
||||
// endpoint times out/fails, without substituting mock data.
|
||||
const drill = await request<H2BiDrillResponse>('drill', {
|
||||
...query,
|
||||
groupBy: 'date',
|
||||
pageSize: 400,
|
||||
});
|
||||
const startDate = query.startDate ?? `${query.year}-01-01`;
|
||||
const endDate = query.endDate ?? new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date());
|
||||
const byDate = new Map(drill.groups.map((row) => [row.name, row]));
|
||||
const days = [] as H2BiDailyResponse['trend'];
|
||||
for (let cursor = new Date(`${startDate}T00:00:00Z`); cursor <= new Date(`${endDate}T00:00:00Z`); cursor.setUTCDate(cursor.getUTCDate() + 1)) {
|
||||
const date = cursor.toISOString().slice(0, 10);
|
||||
const row = byDate.get(date);
|
||||
days.push({
|
||||
date,
|
||||
kg: Number(row?.kg ?? 0),
|
||||
lingniuKg: Number(row?.lingniuKg ?? 0),
|
||||
externalKg: Number(row?.externalKg ?? 0),
|
||||
cost: Number(row?.cost ?? 0),
|
||||
recordCount: Number(row?.recordCount ?? 0),
|
||||
});
|
||||
}
|
||||
const totalKg = days.reduce((sum, row) => sum + row.kg, 0);
|
||||
const totalCost = days.reduce((sum, row) => sum + row.cost, 0);
|
||||
return {
|
||||
range: { startDate, endDate },
|
||||
watermark: { ledgerAt: null, paymentAt: null },
|
||||
filters: query,
|
||||
kpis: {
|
||||
totalKg,
|
||||
totalCost,
|
||||
averageDailyKg: totalKg / Math.max(1, days.length),
|
||||
stationCount: Number(drill.summary.stationCount ?? 0),
|
||||
activeDays: days.filter((row) => row.kg > 0).length,
|
||||
},
|
||||
trend: days,
|
||||
days: [...days].reverse(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchH2BiDailyTree(date: string, query: H2BiDailyTreeQuery) {
|
||||
return request<H2BiDailyTreeResponse>('daily-tree', { date, ...query });
|
||||
}
|
||||
|
||||
export async function fetchH2BiDrill(
|
||||
query: H2BiDrillQuery,
|
||||
options?: H2BiDrillReadOptions,
|
||||
) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = Math.min(Math.max(1, query.pageSize ?? 100), 200);
|
||||
const response = await request<H2BiDrillResponse>(
|
||||
'drill',
|
||||
{ ...query, page, pageSize },
|
||||
options,
|
||||
);
|
||||
const itemCount = query.groupBy === 'record'
|
||||
? response.records.length
|
||||
: response.groups.length;
|
||||
// Older servers only set hasMore for record pages. A full page is therefore
|
||||
// also treated as potentially incomplete; the final empty/short page proves
|
||||
// completion without silently dropping grouped rows.
|
||||
return {
|
||||
...response,
|
||||
page: {
|
||||
...response.page,
|
||||
page,
|
||||
pageSize,
|
||||
itemCount,
|
||||
hasMore: Boolean(response.page?.hasMore) || itemCount === pageSize,
|
||||
},
|
||||
} satisfies H2BiDrillResponse;
|
||||
}
|
||||
|
||||
function abortError() {
|
||||
const error = new Error('已取消全量读取');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every page only after a caller explicitly asks for a complete result.
|
||||
* It never returns a partial collection: server errors, cancellation, and the
|
||||
* page guard all reject before an export can be created.
|
||||
*/
|
||||
export async function fetchAllH2BiDrill(
|
||||
query: Omit<H2BiDrillQuery, 'page' | 'pageSize'>,
|
||||
options: H2BiFullDrillOptions = {},
|
||||
): Promise<H2BiFullDrillResponse> {
|
||||
const pageSize = Math.min(Math.max(1, options.pageSize ?? 200), 200);
|
||||
const maxPages = Math.max(1, options.maxPages ?? 250);
|
||||
const maxRows = Math.max(1, options.maxRows ?? 50_000);
|
||||
const groups: H2BiDrillResponse['groups'] = [];
|
||||
const records: H2BiDrillResponse['records'] = [];
|
||||
let first: H2BiDrillResponse | null = null;
|
||||
|
||||
for (let page = 1; page <= maxPages; page += 1) {
|
||||
if (options.signal?.aborted) throw abortError();
|
||||
const result = await fetchH2BiDrill(
|
||||
{ ...query, page, pageSize },
|
||||
{ signal: options.signal },
|
||||
);
|
||||
if (!first) first = result;
|
||||
groups.push(...result.groups);
|
||||
records.push(...result.records);
|
||||
const itemCount = query.groupBy === 'record' ? records.length : groups.length;
|
||||
if (itemCount > maxRows)
|
||||
throw new Error(`全量读取超过 ${maxRows} 条保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
|
||||
if (!result.page.hasMore) {
|
||||
return {
|
||||
...first,
|
||||
groups,
|
||||
records,
|
||||
page: {
|
||||
...result.page,
|
||||
page: 1,
|
||||
pageSize,
|
||||
itemCount,
|
||||
hasMore: false,
|
||||
pagesRead: page,
|
||||
complete: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new Error(`全量读取超过 ${maxPages} 页保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
|
||||
}
|
||||
|
||||
/** Reusable contract for station-detail consumers that need every raw record. */
|
||||
export function fetchAllH2BiDrillRecords(
|
||||
query: Omit<H2BiDrillQuery, 'groupBy' | 'page' | 'pageSize'>,
|
||||
options?: H2BiFullDrillOptions,
|
||||
) {
|
||||
return fetchAllH2BiDrill({ ...query, groupBy: 'record' }, options);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# 移动端经营总览布局决策
|
||||
|
||||
## 问题
|
||||
|
||||
- 顶部筛选区域占用首屏过多。
|
||||
- 累计加氢量与累计加氢费使用两个大卡片并排,数字和承担结构拥挤。
|
||||
- 利润卡片与本月、本日卡片高度不一致,形成大面积无效留白。
|
||||
- 移动端需要先回答经营结果,再提供费用结构与近期指标。
|
||||
|
||||
## 用户选择
|
||||
|
||||
- 仅优化移动端布局,保留现有数据、筛选和下钻交互。
|
||||
- 使用克制、专业、低饱和的视觉方向。
|
||||
- 以管理层快速查看经营结果、费用结构和近期表现为核心任务。
|
||||
|
||||
## 最终设计决策
|
||||
|
||||
1. 新增单个移动端「经营总览」容器,集中展示累计加氢量、累计加氢费及我司承担、客户承担、待核准三行对照数据。
|
||||
2. 桌面端继续使用原有 KPI 栅格,移动端隐藏原累计量费双卡,避免重复信息。
|
||||
3. 加氢利润改为全宽紧凑卡,本月加氢量与今日加氢量并排呈现。
|
||||
4. 压缩移动端页头、筛选容器和范围提示的垂直空间,不改变筛选状态与即时生效逻辑。
|
||||
5. 保持 44px 最小触控热区、等宽数字及 375px/390px 视口无横向溢出。
|
||||
|
||||
## 参考稿细化确认
|
||||
|
||||
用户追加确认以参考截图优化移动端首屏:
|
||||
|
||||
1. 顶部只保留年份、视图、车辆范围和筛选四项快捷入口,订单范围收进展开筛选。
|
||||
2. 累计经营概览增加我司、客户、待核准三段加氢量构成条,并展示吨数与占比。
|
||||
3. 增加「查看构成」入口,继续复用累计加氢量明细。
|
||||
4. 利润卡改为左侧利润、右侧收入与成本的横向结构。
|
||||
5. 本月与今日指标使用等宽双卡,经营诊断延后至趋势内容之后。
|
||||
|
||||
## 单站页与累计明细补充确认
|
||||
|
||||
1. 单站页把站点数、统计加氢总量、车次、统计金额和现结金额收进一张经营概览,不再把桌面端四卡压成手机两列。
|
||||
2. 日期范围与更新时间保留在同一块紧凑查询区;各站概况改为纵向卡片,竖屏不展示无必要的横屏入口。
|
||||
3. 累计明细顶部改为双主指标:数据归集总量、数据总金额;覆盖站点数和来源完整度降为一行辅助信息。
|
||||
4. 累计明细筛选默认收起为范围摘要,点击后展开完整筛选;竖屏只保留左上返回,不再重复提供关闭和横屏入口。
|
||||
5. 层级数据优先保证站点、客户、车辆与订单摘要在首列可读;详细字段继续在表格内部横向查看,不允许撑宽整页。
|
||||
6. 移动端竖屏明细页头保留左侧返回;站点与客户宽表明细同时保留右侧横屏图标,但不显示重复关闭按钮。业务标题按内容增高并换行,任何入口不得覆盖站点名、客户名或统计时间。
|
||||
@@ -0,0 +1,151 @@
|
||||
# 能源氢费经营看板 · 产品需求说明(PRD)
|
||||
|
||||
> 原型路径:`src/prototypes/energy-h2-bi-board`
|
||||
> 宿主嵌入:`https://bi-next.lnh2e.com/energy#hydrogen/overview`
|
||||
> 宿主视觉单源:`src/resources/prd/energy-bi-host-ref/`
|
||||
> 方案底稿:`src/resources/prd/energy-board-plan-20260806.md`
|
||||
> 口令:`lingniu`(轻门禁 · 本会话记住)
|
||||
> 拍板:嵌入总览 · 我司成本三维度 · 站月/客户汇总表 · **禁 OneOS V2** · D1 无自动解读
|
||||
> **2026-08-12**:顶栏白卡右上角 **全局 / 单站**;单站嵌加氢站日报(起止查询日期自管);单站模式**不显示**看板标题旁时间芯片;现结登记仍独立 OneOS 模块
|
||||
> **2026-08-13**:全局视图**去掉**「加氢站日报 / 站日现结登记」关联入口卡;站日报仅经顶栏「单站」;现结登记走独立模块,本页不再挂跳转摘要
|
||||
|
||||
> 二期:双视角全路径 · 充耗存侧卡 · **预充值能源账户扣款(体系 B)**
|
||||
> 作者:OneOS
|
||||
|
||||
---
|
||||
|
||||
## 0. 嵌入与设计约定
|
||||
|
||||
| 项 | 口径 |
|
||||
|---|---|
|
||||
| 口令 | `lingniu`(轻门禁;本会话 `sessionStorage` 记住) |
|
||||
| 宿主页 | `#hydrogen/overview` |
|
||||
| 功能 | 独立嵌入块「我司成本」:三维度 + 两张汇总表 + 订单明细;**单站**嵌加氢站日报 |
|
||||
| 设计 | 只跟 bi-next 能源 BI;禁止 `V2*`;字体对齐 V2 §2.2 |
|
||||
|
||||
### 视图决策
|
||||
|
||||
| 决策 | 结论 | 理由 |
|
||||
|---|---|---|
|
||||
| 顶栏范围 | **全局 / 单站**(白卡右上角) | 单站回嵌经营看板 |
|
||||
| 全局内视图 | `按日` / `总览` | 原看板能力保留 |
|
||||
| 总览筛选 | 默认**全部车辆**;可切**仅羚牛车辆** / **仅外部车辆**;与年份、全量订单/仅已核对联动 | 外层筛选与 KPI / 图表 / 钻取同一口径 |
|
||||
| 承担方式 | **我司承担 / 客户承担 / 待核准**三类;「自行」并入「客户承担」 | 首页拆分、穿透筛选、明细列和导出口径一致 |
|
||||
| 核心 KPI | PC 展示全部 5 项;移动端前 2 项突出,其余 3 项紧凑保留且均可点击穿透 | 不隐藏旧版指标能力 |
|
||||
| 单站形态 | 加氢站日报:起止查询日期 + KPI 钻取 + 各站单卡(行内加氢量占比 · 按量降序) | 一眼可查 |
|
||||
| 单站时间 | **不展示**看板标题旁「统计时间范围」芯片 | 与全局统计维度不同 |
|
||||
| 单站明细 | 日汇总、区间趋势、客户月量/费、收支、现结;导出取证 `.xlsx` | 查看与取证 |
|
||||
| 现结登记 | **独立** `energy-spot-cash-intake`;本页不办理 | 1C 手维入口唯一 |
|
||||
| 预充值能源账户 | **二期 · 体系 B** | 与现结完全独立 |
|
||||
|
||||
### 关联独立模块
|
||||
|
||||
| 模块 | 路径 | 本看板角色 |
|
||||
|---|---|---|
|
||||
| 加氢站日报 | `energy-h2-station-daily` | 顶栏「单站」嵌入;可独立外链;**不**再挂全局关联卡 |
|
||||
| 站日现结登记 | `energy-spot-cash-intake` | 独立模块办理;本页**不**挂跳转入口与近窗摘要 |
|
||||
| 加氢客户(外部) | `energy-h2-external-customer` | 外部客户主数据;与租赁客户隔离 |
|
||||
| 外部车辆明细 | `energy-h2-external-vehicle` | 外部车牌主数据 + 导入;API 分流「仅外部」数据源 |
|
||||
|
||||
> **2026-08-13 分流口径:** 南海站 API 流水命中外部车辆台账 → `fleetCategory=external`,**只进本看板/站日报**,不进车辆氢费明细;命中自有/租赁车队 → 进氢费明细并可核对。主数据种子车牌与 `mockDaily` 外部牌对齐(见 `src/common/energy-h2-external-fleet`)。
|
||||
|
||||
---
|
||||
|
||||
## 0.1 现结 ≠ 预充值(双体系硬隔离)
|
||||
|
||||
| | **体系 A · 现结** | **体系 B · 预充值(以后)** |
|
||||
|---|---|---|
|
||||
| 一句话 | 到站加完氢 → 当场在线支付 | 预充进账户 → 加氢扣款 |
|
||||
| 本期 | 独立模块手维;看板/站日报只读 | **不做** |
|
||||
| 禁止 | 「账户充值」指现结;订单金额冒充现结 | 用现结台账冒充账户流水 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 名称 | 能源氢费经营看板(嵌入:我司成本) |
|
||||
| 核心任务 | 全局:三维度结构 → 站月/客户汇总 → 订单解释;单站:站日经营读数与取证 |
|
||||
| 不做 | 现结登记表单;体系 B |
|
||||
|
||||
---
|
||||
|
||||
## 2. 我司成本三维度(仅全局)
|
||||
|
||||
| 维度 | 二级 |
|
||||
|---|---|
|
||||
| 租赁成本 | 我司承担 · 包氢项目 |
|
||||
| 物流成本 | — |
|
||||
| 运维成本 | 异动 · 调拨 |
|
||||
|
||||
客户承担不进三维度。核对 ≠ 对账。
|
||||
|
||||
---
|
||||
|
||||
## 3. 用户故事
|
||||
|
||||
### 经营看板 · 全局
|
||||
|
||||
- **起点:** 打开看板(口令后)→ 顶栏「全局」→ 按日 / 总览。
|
||||
- **怎么运作:** 看我司成本与汇总;站日报经顶栏「单站」进入。
|
||||
- **闭环:** 本页解释成本结构。
|
||||
|
||||
### 经营看板 · 单站
|
||||
|
||||
- **起点:** 顶栏切「单站」→ 驾驶舱各站卡片。
|
||||
- **怎么运作:** 点站点进入汇报口径明细(日汇总/趋势/客户月量费/收支/车辆/现结);可导出取证 Excel。
|
||||
- **闭环:** 数字可查、明细可追到车与现结笔;登记仍去现结模块。
|
||||
|
||||
---
|
||||
|
||||
## 4. KPI 穿透表 · 类型标签口径
|
||||
|
||||
穿透钻取表(加氢站 → 客户 → 车辆):
|
||||
|
||||
表头支持「**按加氢站**」与「**按客户**」两种组织方式;切换只改变层级顺序,均可下钻到车辆和单笔订单。
|
||||
|
||||
| 层级 | 类型 / 归属列 |
|
||||
|---|---|
|
||||
| 加氢站 | **不展示**自用消费 / 对外销售(站侧不再分自用与对外) |
|
||||
| 客户 | **不展示**内部客户 / 外部客户标签 |
|
||||
| 车辆 | 无法识别车牌 → 名称统一「无车牌」(与有牌车辆同级),标签「外部车辆」;能识别且为羚牛车队 → 标签「羚牛车辆」;能识别的外部车 → 标签「外部车辆」 |
|
||||
| 车辆下订单行 | 展示标签「订单编号」+ 单号;字段口径为**加氢订单编号**(导出列亦用「订单编号」) |
|
||||
| 加氢站核对状态 | 按下属羚牛车辆汇总:**已核对**(全部已核)/ **部分核对**(有已核也有未核,或任一带部分)/ **未核对**(一条未核);无参与核对车辆时显示 `-` |
|
||||
| 穿透筛 | **加氢站 / 客户 / 车辆**可搜索选择器(级联)+ **车辆归属**单选按钮组(全部 / 仅羚牛 / 仅外部)+ **承担方式**(全部 / 我司承担 / 客户承担 / 待核准);禁 V2 |
|
||||
| 加氢利润穿透 | 关键列展示**收入 / 成本 / 利润**;顶栏汇总亦为收入·成本·利润 |
|
||||
| 本月加氢穿透 | 关键列展示**本月加氢量 / 本月加氢费 / 加氢费占年比** |
|
||||
| 本日加氢穿透 | 关键列展示**本日加氢量 / 本日加氢费 / 加氢费占月比** |
|
||||
| 月度柱钻取 | 点月度加氢量柱 → 该月**各站**内部客户加氢总量 / 外部客户加氢总量 / 合计 |
|
||||
| 月度收支柱钻取 | 点**客户收入**柱 → 该月各站客户收入;点**成本支出**柱 → 该月各站成本支出 |
|
||||
| Top5 站横条 | 点条 → 该站**内部/外部客户加氢总量**(表可竖滚) |
|
||||
| 区域环图/图例 | 点市或省 → 该区域**各站加氢总量与占比**(表可竖滚) |
|
||||
| 站汇总表钻取 | 默认按日展示:**日期 / 车辆数与加氢笔数 / 加氢量 / 较前日增减 / 氢费收入 / 平均单价**;展开后展示客户、车牌、车辆归属、加氢时间和单笔明细 |
|
||||
| 客户汇总表钻取 | 关键字段:**承担方 / 加氢量 / 成本支出 / 应收 / 已收 / 未收**(按日展开) |
|
||||
| 头部加氢站占比 | 点击展开**加氢量排名**下拉(高→低、可滚动);点站可进穿透 |
|
||||
| 按日经营指标 | 默认展示**区间加氢量 / 日均加氢量 / 较上一周期 / 活跃加氢站**;活跃站点按“当前有记录站点 / 全网络站点”展示并给出覆盖率;车辆构成降级为筛选区间说明,不占主指标卡 |
|
||||
| 全局时间范围 | 每个页面、列表、全屏明细与钻取弹窗必须展示明确的开始与结束时间;**经营汇总到天、当日实时到分钟、订单与加氢流水到秒**;“本日 / 本月 / 年度”等业务名称不可替代具体时间范围 |
|
||||
| 穿透标签提示 | 车辆归属 / 数据来源 / 核对状态标签均有悬浮说明 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 验收
|
||||
|
||||
1. 顶栏白卡右上角有 **全局 / 单站**;全局下仍有按日/总览。
|
||||
2. 单站:站点卡片 → 明细含汇报 Excel 对应区块;导出 `.xlsx`。
|
||||
3. 空态不填 0;现结 ≠ 预充值。
|
||||
4. 口令 `lingniu`;标注壳在门外。
|
||||
5. 禁 OneOS V2 控件。
|
||||
6. KPI 穿透:站/客户无自用·内外标签;无车牌归「无车牌」+「外部车辆」。
|
||||
7. 总览顶栏:默认全部车辆;切仅羚牛/仅外部/年份/核对后,KPI、图表、钻取数同步变化。
|
||||
8. 承担方式仅有我司承担、客户承担、待核准三类;不再单列「自行」,且首页、筛选、明细、导出一致。
|
||||
9. KPI 穿透可按加氢站或客户组织,两种方式都可追到车辆和单笔订单。
|
||||
10. PC 可见 5 项核心 KPI;移动端 5 项均可见、可点击,前 2 项保持主视觉层级。
|
||||
|
||||
---
|
||||
|
||||
## 6. 明确不做(本期)
|
||||
|
||||
- 体系 B 预充值账户
|
||||
- 现结在本页办理
|
||||
- 云效建单(默认不上)
|
||||
@@ -0,0 +1,30 @@
|
||||
# 氢能经营看板开发交付说明
|
||||
|
||||
## 页面入口
|
||||
|
||||
- 本地地址:`http://127.0.0.1:51720/prototypes/energy-h2-bi-board`
|
||||
- 访问口令:`lingniu`
|
||||
- 启动方式:在项目目录执行 `npm ci`,再执行 `npm run dev`
|
||||
|
||||
## 本次交付范围
|
||||
|
||||
- PC 与移动端全局经营总览、日期视图、单站视角。
|
||||
- 年份、日期区间、车辆归属、订单范围、站点、客户等筛选交互。
|
||||
- KPI 下钻、表格逐级展开、Tab 切换、更多数据、横屏查看与退出。
|
||||
- 单站经营明细(日加氢、客户月度、收支、进账)与日期查询。
|
||||
- Excel 导出入口及前端下载逻辑。
|
||||
|
||||
## 数据与接口边界
|
||||
|
||||
- 当前为可运行原型,经营数据来自 `data/mockBoard.ts`、`data/mockDaily.ts` 和单站模块的 `data/mockStationDaily.ts`。
|
||||
- 正式开发需将模拟数据替换为接口返回值,但应保留现有筛选、空状态、下钻层级、双端响应式布局与横屏交互。
|
||||
- 查询条件为即时生效;日期弹层点击“确定”后更新页面统计范围。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
npx vitest run tests/energy-h2-bi-board.ui.test.ts tests/energy-h2-bearer-model.test.ts tests/energy-h2-dual-end-interaction.test.ts tests/energy-h2-mobile-kpi-tone.test.ts tests/energy-h2-mobile-layout-polish.test.ts tests/energy-bi-board.visual.test.ts tests/common/EnergyBiBoardMobileLayout.test.ts tests/common/MobileListFullscreenButton.test.tsx
|
||||
ENTRY_KEY=prototypes/energy-h2-bi-board npx vite build
|
||||
```
|
||||
|
||||
交付前结果:8 个测试文件、97 项检查全部通过,目标页面生产构建通过。
|
||||
File diff suppressed because it is too large
Load Diff
+10
-6
@@ -5,7 +5,7 @@ import type {
|
||||
LeaseKind,
|
||||
OpsKind,
|
||||
} from '../types';
|
||||
import { COST_DIM_LABEL, LEASE_KIND_LABEL, OPS_KIND_LABEL } from '../types';
|
||||
import { BORNE_BY_LABEL, BORNE_BY_ORDER, COST_DIM_LABEL, LEASE_KIND_LABEL, OPS_KIND_LABEL } from '../types';
|
||||
|
||||
export function filterOrders(
|
||||
rows: H2OrderRow[],
|
||||
@@ -187,10 +187,8 @@ export function customerAttrAgg(rows: H2OrderRow[]): CustomerAttrRow[] {
|
||||
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 = '客户';
|
||||
const activeBorneTypes = BORNE_BY_ORDER.filter((borneBy) => list.some((x) => x.borneBy === borneBy));
|
||||
const borneLabel = activeBorneTypes.length === 1 ? BORNE_BY_LABEL[activeBorneTypes[0]] : '混合';
|
||||
return {
|
||||
customerId,
|
||||
customerName: list[0].customerName,
|
||||
@@ -218,9 +216,11 @@ export function computeHostKpi(
|
||||
totalKgT: number;
|
||||
companyKgT: number;
|
||||
customerKgT: number;
|
||||
pendingKgT: number;
|
||||
totalFeeWan: number;
|
||||
companyFeeWan: number;
|
||||
customerFeeWan: number;
|
||||
pendingFeeWan: number;
|
||||
profitWan: number;
|
||||
incomeWan: number;
|
||||
costWan: number;
|
||||
@@ -240,9 +240,11 @@ export function computeHostKpi(
|
||||
|
||||
const companyKg = sumKg(filtered.filter((r) => r.borneBy === 'company'));
|
||||
const customerKg = sumKg(filtered.filter((r) => r.borneBy === 'customer'));
|
||||
const split = companyKg + customerKg || 1;
|
||||
const pendingKg = sumKg(filtered.filter((r) => r.borneBy === 'pending'));
|
||||
const split = companyKg + customerKg + pendingKg || 1;
|
||||
const companyShare = companyKg / split;
|
||||
const customerShare = customerKg / split;
|
||||
const pendingShare = pendingKg / split;
|
||||
|
||||
const monthRows = filtered.filter((r) => r.occurredAt.startsWith(`${year}-08`));
|
||||
const dayRows = filtered.filter((r) => r.occurredAt.startsWith(`${year}-08-08`));
|
||||
@@ -266,9 +268,11 @@ export function computeHostKpi(
|
||||
totalKgT,
|
||||
companyKgT: round2(totalKgT * companyShare),
|
||||
customerKgT: round2(totalKgT * customerShare),
|
||||
pendingKgT: round2(totalKgT * pendingShare),
|
||||
totalFeeWan,
|
||||
companyFeeWan: round2(totalFeeWan * companyShare),
|
||||
customerFeeWan: round2(totalFeeWan * customerShare),
|
||||
pendingFeeWan: round2(totalFeeWan * pendingShare),
|
||||
profitWan,
|
||||
incomeWan,
|
||||
costWan,
|
||||
+19
-10
@@ -80,18 +80,25 @@ function generate200MockOrders(): H2OrderRow[] {
|
||||
const quantityKg = Math.round((18 + (i * 3.7 % 85)) * 100) / 100;
|
||||
const amount = Math.round(quantityKg * unitPrice);
|
||||
|
||||
// 成本维度与费用承担
|
||||
let borneBy: 'company' | 'customer' = 'company';
|
||||
// 成本维度与费用承担(三类:自行结算并入客户承担)
|
||||
let borneBy: 'company' | 'customer' | 'pending' = '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') {
|
||||
const borneSlot = i % 10;
|
||||
if (customer.id === 'c-ext-a' || customer.id === 'c-ext-d' || borneSlot === 7 || borneSlot === 8) {
|
||||
borneBy = 'customer';
|
||||
} else if (borneSlot === 9) {
|
||||
borneBy = 'pending';
|
||||
}
|
||||
|
||||
const dimType = i % 5;
|
||||
if (dimType === 0) {
|
||||
if (borneBy !== 'company') {
|
||||
costDim = 'pending';
|
||||
leaseKind = undefined;
|
||||
opsKind = undefined;
|
||||
} else if (dimType === 0) {
|
||||
costDim = 'lease';
|
||||
leaseKind = 'company_borne';
|
||||
} else if (dimType === 1) {
|
||||
@@ -160,16 +167,18 @@ export const MOCK_PREPAID: StationPrepaid[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** 宿主总览 KPI(与 zip content 同量级示意,只读壳) */
|
||||
/** 宿主总览 KPI(三类承担口径,自行结算已并入客户承担) */
|
||||
export const HOST_KPI = {
|
||||
totalKgT: 697.16,
|
||||
companyKgT: 468.28,
|
||||
customerKgT: 228.88,
|
||||
companyKgT: 598.01,
|
||||
customerKgT: 80,
|
||||
pendingKgT: 19.15,
|
||||
totalFeeWan: 2093.71,
|
||||
companyFeeWan: 1362.85,
|
||||
customerFeeWan: 730.87,
|
||||
companyFeeWan: 1795.95,
|
||||
customerFeeWan: 240,
|
||||
pendingFeeWan: 57.76,
|
||||
profitWan: 13.01,
|
||||
incomeWan: 743.88,
|
||||
incomeWan: 2106.72,
|
||||
costWan: 2093.71,
|
||||
monthKgT: 16.67,
|
||||
monthFeeWan: 50.36,
|
||||
+46
-46
@@ -98,10 +98,10 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 1100.0,
|
||||
amountYuan: 33000.0,
|
||||
vehicles: [
|
||||
{ id: 'v-101', time: '08:15', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 45.2, unitPrice: 30, amountYuan: 1356, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-102', time: '09:30', plateNo: '浙A66666F', fleetCategory: 'own', quantityKg: 54.8, unitPrice: 30, amountYuan: 1644, source: 'lingniu_report', verifyStatus: 'verified' },
|
||||
{ id: 'v-103', time: '11:20', plateNo: '浙A33333F', fleetCategory: 'own', quantityKg: 40.0, unitPrice: 30, amountYuan: 1200, source: 'station_report', verifyStatus: 'unverified' },
|
||||
{ id: 'v-104', time: '14:00', plateNo: '浙A99999F', fleetCategory: 'own', quantityKg: 960.0, unitPrice: 30, amountYuan: 28800, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-101', time: '08:15:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 45.2, unitPrice: 30, amountYuan: 1356, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-102', time: '09:30:00', plateNo: '浙A66666F', fleetCategory: 'own', quantityKg: 54.8, unitPrice: 30, amountYuan: 1644, source: 'lingniu_report', verifyStatus: 'verified' },
|
||||
{ id: 'v-103', time: '11:20:00', plateNo: '浙A33333F', fleetCategory: 'own', quantityKg: 40.0, unitPrice: 30, amountYuan: 1200, source: 'station_report', verifyStatus: 'unverified' },
|
||||
{ id: 'v-104', time: '14:00:00', plateNo: '浙A99999F', fleetCategory: 'own', quantityKg: 960.0, unitPrice: 30, amountYuan: 28800, source: 'api', verifyStatus: 'verified' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -110,8 +110,8 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 700.0,
|
||||
amountYuan: 21000.0,
|
||||
vehicles: [
|
||||
{ id: 'v-201', time: '10:10', plateNo: '粤B12345D', fleetCategory: 'external', quantityKg: 350.0, unitPrice: 30, amountYuan: 10500, source: 'station_report', verifyStatus: null },
|
||||
{ id: 'v-202', time: '15:45', plateNo: null, fleetCategory: 'external', quantityKg: 350.0, unitPrice: 30, amountYuan: 10500, source: 'api', verifyStatus: null },
|
||||
{ id: 'v-201', time: '10:10:00', plateNo: '粤B12345D', fleetCategory: 'external', quantityKg: 350.0, unitPrice: 30, amountYuan: 10500, source: 'station_report', verifyStatus: null },
|
||||
{ id: 'v-202', time: '15:45:00', plateNo: null, fleetCategory: 'external', quantityKg: 350.0, unitPrice: 30, amountYuan: 10500, source: 'api', verifyStatus: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -130,7 +130,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 500.0,
|
||||
amountYuan: 19000.0,
|
||||
vehicles: [
|
||||
{ id: 'v-300', time: '06:50', plateNo: '粤B88666D', fleetCategory: 'own', quantityKg: 500.0, unitPrice: 38, amountYuan: 19000, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-300', time: '06:50:00', plateNo: '粤B88666D', fleetCategory: 'own', quantityKg: 500.0, unitPrice: 38, amountYuan: 19000, source: 'api', verifyStatus: 'verified' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -139,7 +139,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 300.0,
|
||||
amountYuan: 11400.0,
|
||||
vehicles: [
|
||||
{ id: 'v-301', time: '07:40', plateNo: '粤E88111D', fleetCategory: 'external', quantityKg: 300.0, unitPrice: 38, amountYuan: 11400, source: 'station_report', verifyStatus: null },
|
||||
{ id: 'v-301', time: '07:40:00', plateNo: '粤E88111D', fleetCategory: 'external', quantityKg: 300.0, unitPrice: 38, amountYuan: 11400, source: 'station_report', verifyStatus: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -148,7 +148,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 539.12,
|
||||
amountYuan: 20486.56,
|
||||
vehicles: [
|
||||
{ id: 'v-401', time: '16:00', plateNo: '粤A99888D', fleetCategory: 'external', quantityKg: 539.12, unitPrice: 38, amountYuan: 20486.56, source: 'api', verifyStatus: null },
|
||||
{ id: 'v-401', time: '16:00:00', plateNo: '粤A99888D', fleetCategory: 'external', quantityKg: 539.12, unitPrice: 38, amountYuan: 20486.56, source: 'api', verifyStatus: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -177,8 +177,8 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 181.78,
|
||||
amountYuan: 5453.4,
|
||||
vehicles: [
|
||||
{ id: 'v-501', time: '09:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 81.78, unitPrice: 30, amountYuan: 2453.4, source: 'lingniu_report', verifyStatus: 'verified' },
|
||||
{ id: 'v-502', time: '14:30', plateNo: '浙A66666F', fleetCategory: 'own', quantityKg: 100.0, unitPrice: 30, amountYuan: 3000.0, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-501', time: '09:00:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 81.78, unitPrice: 30, amountYuan: 2453.4, source: 'lingniu_report', verifyStatus: 'verified' },
|
||||
{ id: 'v-502', time: '14:30:00', plateNo: '浙A66666F', fleetCategory: 'own', quantityKg: 100.0, unitPrice: 30, amountYuan: 3000.0, source: 'api', verifyStatus: 'verified' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -187,7 +187,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 500.0,
|
||||
amountYuan: 15000.0,
|
||||
vehicles: [
|
||||
{ id: 'v-503', time: '16:10', plateNo: null, fleetCategory: 'external', quantityKg: 500.0, unitPrice: 30, amountYuan: 15000, source: 'station_report', verifyStatus: null },
|
||||
{ id: 'v-503', time: '16:10:00', plateNo: null, fleetCategory: 'external', quantityKg: 500.0, unitPrice: 30, amountYuan: 15000, source: 'station_report', verifyStatus: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -206,7 +206,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 500.0,
|
||||
amountYuan: 15000.0,
|
||||
vehicles: [
|
||||
{ id: 'v-504', time: '11:00', plateNo: '粤B99111D', fleetCategory: 'external', quantityKg: 500.0, unitPrice: 30, amountYuan: 15000, source: 'api', verifyStatus: null },
|
||||
{ id: 'v-504', time: '11:00:00', plateNo: '粤B99111D', fleetCategory: 'external', quantityKg: 500.0, unitPrice: 30, amountYuan: 15000, source: 'api', verifyStatus: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -235,8 +235,8 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 850.0,
|
||||
amountYuan: 25500.0,
|
||||
vehicles: [
|
||||
{ id: 'v-601', time: '10:00', plateNo: '浙A55555F', fleetCategory: 'own', quantityKg: 450.0, unitPrice: 30, amountYuan: 13500, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-602', time: '16:00', plateNo: '浙A33333F', fleetCategory: 'own', quantityKg: 400.0, unitPrice: 30, amountYuan: 12000, source: 'lingniu_report', verifyStatus: 'unverified' },
|
||||
{ id: 'v-601', time: '10:00:00', plateNo: '浙A55555F', fleetCategory: 'own', quantityKg: 450.0, unitPrice: 30, amountYuan: 13500, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-602', time: '16:00:00', plateNo: '浙A33333F', fleetCategory: 'own', quantityKg: 400.0, unitPrice: 30, amountYuan: 12000, source: 'lingniu_report', verifyStatus: 'unverified' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -245,7 +245,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 400.0,
|
||||
amountYuan: 12000.0,
|
||||
vehicles: [
|
||||
{ id: 'v-603', time: '17:30', plateNo: '粤B33888D', fleetCategory: 'external', quantityKg: 400.0, unitPrice: 30, amountYuan: 12000, source: 'station_report', verifyStatus: null },
|
||||
{ id: 'v-603', time: '17:30:00', plateNo: '粤B33888D', fleetCategory: 'external', quantityKg: 400.0, unitPrice: 30, amountYuan: 12000, source: 'station_report', verifyStatus: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -264,7 +264,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 300.0,
|
||||
amountYuan: 11400.0,
|
||||
vehicles: [
|
||||
{ id: 'v-700', time: '09:10', plateNo: '浙A11222F', fleetCategory: 'own', quantityKg: 300.0, unitPrice: 38, amountYuan: 11400, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-700', time: '09:10:00', plateNo: '浙A11222F', fleetCategory: 'own', quantityKg: 300.0, unitPrice: 38, amountYuan: 11400, source: 'api', verifyStatus: 'verified' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -273,7 +273,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 700.0,
|
||||
amountYuan: 26600.0,
|
||||
vehicles: [
|
||||
{ id: 'v-701', time: '11:30', plateNo: null, fleetCategory: 'external', quantityKg: 700.0, unitPrice: 38, amountYuan: 26600, source: 'station_report', verifyStatus: null },
|
||||
{ id: 'v-701', time: '11:30:00', plateNo: null, fleetCategory: 'external', quantityKg: 700.0, unitPrice: 38, amountYuan: 26600, source: 'station_report', verifyStatus: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -302,8 +302,8 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 1720.0,
|
||||
amountYuan: 51600.0,
|
||||
vehicles: [
|
||||
{ id: 'v-801', time: '08:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 860.0, unitPrice: 30, amountYuan: 25800, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-802', time: '15:20', plateNo: '浙A99999F', fleetCategory: 'own', quantityKg: 860.0, unitPrice: 30, amountYuan: 25800, source: 'lingniu_report', verifyStatus: 'verified' },
|
||||
{ id: 'v-801', time: '08:00:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 860.0, unitPrice: 30, amountYuan: 25800, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-802', time: '15:20:00', plateNo: '浙A99999F', fleetCategory: 'own', quantityKg: 860.0, unitPrice: 30, amountYuan: 25800, source: 'lingniu_report', verifyStatus: 'verified' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -312,7 +312,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 1000.0,
|
||||
amountYuan: 30000.0,
|
||||
vehicles: [
|
||||
{ id: 'v-803', time: '18:00', plateNo: '粤E99999D', fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'api', verifyStatus: null },
|
||||
{ id: 'v-803', time: '18:00:00', plateNo: '粤E99999D', fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'api', verifyStatus: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -341,7 +341,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 1000.0,
|
||||
amountYuan: 30000.0,
|
||||
vehicles: [
|
||||
{ id: 'v-900', time: '07:30', plateNo: '浙A77777F', fleetCategory: 'own', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-900', time: '07:30:00', plateNo: '浙A77777F', fleetCategory: 'own', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'api', verifyStatus: 'verified' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -350,8 +350,8 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 2150.0,
|
||||
amountYuan: 64500.0,
|
||||
vehicles: [
|
||||
{ id: 'v-901', time: '09:10', plateNo: '粤B88888D', fleetCategory: 'external', quantityKg: 1075.0, unitPrice: 30, amountYuan: 32250, source: 'station_report', verifyStatus: null },
|
||||
{ id: 'v-902', time: '16:40', plateNo: '粤B99999D', fleetCategory: 'external', quantityKg: 1075.0, unitPrice: 30, amountYuan: 32250, source: 'api', verifyStatus: null },
|
||||
{ id: 'v-901', time: '09:10:00', plateNo: '粤B88888D', fleetCategory: 'external', quantityKg: 1075.0, unitPrice: 30, amountYuan: 32250, source: 'station_report', verifyStatus: null },
|
||||
{ id: 'v-902', time: '16:40:00', plateNo: '粤B99999D', fleetCategory: 'external', quantityKg: 1075.0, unitPrice: 30, amountYuan: 32250, source: 'api', verifyStatus: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -380,7 +380,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 2100.0,
|
||||
amountYuan: 63000.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1001', time: '10:30', plateNo: '沪A88111F', fleetCategory: 'own', quantityKg: 2100.0, unitPrice: 30, amountYuan: 63000, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-1001', time: '10:30:00', plateNo: '沪A88111F', fleetCategory: 'own', quantityKg: 2100.0, unitPrice: 30, amountYuan: 63000, source: 'api', verifyStatus: 'verified' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -389,7 +389,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 1000.0,
|
||||
amountYuan: 30000.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1002', time: '15:10', plateNo: null, fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'station_report', verifyStatus: null },
|
||||
{ id: 'v-1002', time: '15:10:00', plateNo: null, fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'station_report', verifyStatus: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -418,8 +418,8 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 2000.0,
|
||||
amountYuan: 60000.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1101', time: '08:45', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'lingniu_report', verifyStatus: 'verified' },
|
||||
{ id: 'v-1102', time: '14:15', plateNo: '浙A66666F', fleetCategory: 'own', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-1101', time: '08:45:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'lingniu_report', verifyStatus: 'verified' },
|
||||
{ id: 'v-1102', time: '14:15:00', plateNo: '浙A66666F', fleetCategory: 'own', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'api', verifyStatus: 'verified' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -428,7 +428,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 950.0,
|
||||
amountYuan: 28500.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1103', time: '17:00', plateNo: null, fleetCategory: 'external', quantityKg: 950.0, unitPrice: 30, amountYuan: 28500, source: 'station_report', verifyStatus: null },
|
||||
{ id: 'v-1103', time: '17:00:00', plateNo: null, fleetCategory: 'external', quantityKg: 950.0, unitPrice: 30, amountYuan: 28500, source: 'station_report', verifyStatus: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -457,7 +457,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 2000.0,
|
||||
amountYuan: 60000.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1201', time: '09:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2000.0, unitPrice: 30, amountYuan: 60000, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-1201', time: '09:00:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2000.0, unitPrice: 30, amountYuan: 60000, source: 'api', verifyStatus: 'verified' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -476,7 +476,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 1620.0,
|
||||
amountYuan: 48600.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1202', time: '11:20', plateNo: '粤B33333D', fleetCategory: 'external', quantityKg: 1620.0, unitPrice: 30, amountYuan: 48600, source: 'station_report', verifyStatus: null },
|
||||
{ id: 'v-1202', time: '11:20:00', plateNo: '粤B33333D', fleetCategory: 'external', quantityKg: 1620.0, unitPrice: 30, amountYuan: 48600, source: 'station_report', verifyStatus: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -505,7 +505,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 2580.0,
|
||||
amountYuan: 77400.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1301', time: '10:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2580.0, unitPrice: 30, amountYuan: 77400, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-1301', time: '10:00:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2580.0, unitPrice: 30, amountYuan: 77400, source: 'api', verifyStatus: 'verified' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -514,7 +514,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 1000.0,
|
||||
amountYuan: 30000.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1302', time: '16:00', plateNo: '粤B55555D', fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'api', verifyStatus: null },
|
||||
{ id: 'v-1302', time: '16:00:00', plateNo: '粤B55555D', fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'api', verifyStatus: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -543,7 +543,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 3750.0,
|
||||
amountYuan: 112500.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1401', time: '08:30', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 3750.0, unitPrice: 30, amountYuan: 112500, source: 'lingniu_report', verifyStatus: 'verified' },
|
||||
{ id: 'v-1401', time: '08:30:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 3750.0, unitPrice: 30, amountYuan: 112500, source: 'lingniu_report', verifyStatus: 'verified' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -572,7 +572,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 2450.0,
|
||||
amountYuan: 73500.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1501', time: '13:00', plateNo: '浙A66666F', fleetCategory: 'own', quantityKg: 2450.0, unitPrice: 30, amountYuan: 73500, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-1501', time: '13:00:00', plateNo: '浙A66666F', fleetCategory: 'own', quantityKg: 2450.0, unitPrice: 30, amountYuan: 73500, source: 'api', verifyStatus: 'verified' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -581,7 +581,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 1000.0,
|
||||
amountYuan: 30000.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1502', time: '17:40', plateNo: '浙A99111D', fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'station_report', verifyStatus: null },
|
||||
{ id: 'v-1502', time: '17:40:00', plateNo: '浙A99111D', fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'station_report', verifyStatus: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -610,7 +610,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 2980.2,
|
||||
amountYuan: 89406.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1601', time: '15:10', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2980.2, unitPrice: 30, amountYuan: 89406, source: 'lingniu_report', verifyStatus: 'verified' },
|
||||
{ id: 'v-1601', time: '15:10:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2980.2, unitPrice: 30, amountYuan: 89406, source: 'lingniu_report', verifyStatus: 'verified' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -639,7 +639,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 2120.0,
|
||||
amountYuan: 63600.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1701', time: '11:00', plateNo: '沪A88111F', fleetCategory: 'own', quantityKg: 2120.0, unitPrice: 30, amountYuan: 63600, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-1701', time: '11:00:00', plateNo: '沪A88111F', fleetCategory: 'own', quantityKg: 2120.0, unitPrice: 30, amountYuan: 63600, source: 'api', verifyStatus: 'verified' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -648,7 +648,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 1000.0,
|
||||
amountYuan: 30000.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1702', time: '16:20', plateNo: null, fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'station_report', verifyStatus: null },
|
||||
{ id: 'v-1702', time: '16:20:00', plateNo: null, fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'station_report', verifyStatus: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -677,7 +677,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 2340.5,
|
||||
amountYuan: 70215.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1801', time: '14:20', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2340.5, unitPrice: 30, amountYuan: 70215, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-1801', time: '14:20:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2340.5, unitPrice: 30, amountYuan: 70215, source: 'api', verifyStatus: 'verified' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -706,7 +706,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 2183.0,
|
||||
amountYuan: 65490.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1901', time: '09:30', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2183.0, unitPrice: 30, amountYuan: 65490, source: 'api', verifyStatus: 'verified' },
|
||||
{ id: 'v-1901', time: '09:30:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2183.0, unitPrice: 30, amountYuan: 65490, source: 'api', verifyStatus: 'verified' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -725,7 +725,7 @@ export const MOCK_DAILY_15DAYS: DailyItem[] = [
|
||||
quantityKg: 1500.0,
|
||||
amountYuan: 45000.0,
|
||||
vehicles: [
|
||||
{ id: 'v-1902', time: '16:00', plateNo: '粤B12345D', fleetCategory: 'external', quantityKg: 1500.0, unitPrice: 30, amountYuan: 45000, source: 'station_report', verifyStatus: null },
|
||||
{ id: 'v-1902', time: '16:00:00', plateNo: '粤B12345D', fleetCategory: 'external', quantityKg: 1500.0, unitPrice: 30, amountYuan: 45000, source: 'station_report', verifyStatus: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -805,7 +805,7 @@ export function getDailyDataForRange(normStartStr: string, normEndStr: string):
|
||||
vehicles: [
|
||||
{
|
||||
id: `v-gen1-${dateStr}`,
|
||||
time: '08:30',
|
||||
time: '08:30:00',
|
||||
plateNo: `浙A${(seed % 89999) + 10000}F`,
|
||||
fleetCategory: 'own',
|
||||
quantityKg: Math.round(ownKg * 0.65 * 10) / 10,
|
||||
@@ -816,7 +816,7 @@ export function getDailyDataForRange(normStartStr: string, normEndStr: string):
|
||||
},
|
||||
{
|
||||
id: `v-gen2-${dateStr}`,
|
||||
time: '14:20',
|
||||
time: '14:20:00',
|
||||
plateNo: `浙A${(seed % 79999) + 10000}F`,
|
||||
fleetCategory: 'own',
|
||||
quantityKg: Math.round(ownKg * 0.35 * 10) / 10,
|
||||
@@ -847,7 +847,7 @@ export function getDailyDataForRange(normStartStr: string, normEndStr: string):
|
||||
vehicles: [
|
||||
{
|
||||
id: `v-gen3-${dateStr}`,
|
||||
time: '10:15',
|
||||
time: '10:15:00',
|
||||
plateNo: `粤B${(seed % 89999) + 10000}D`,
|
||||
fleetCategory: 'external',
|
||||
quantityKg: Math.round(extKg * 0.7 * 10) / 10,
|
||||
@@ -858,7 +858,7 @@ export function getDailyDataForRange(normStartStr: string, normEndStr: string):
|
||||
},
|
||||
{
|
||||
id: `v-gen4-${dateStr}`,
|
||||
time: '16:40',
|
||||
time: '16:40:00',
|
||||
plateNo: null,
|
||||
fleetCategory: 'external',
|
||||
quantityKg: Math.round(extKg * 0.3 * 10) / 10,
|
||||
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -10,7 +10,8 @@ export type LeaseKind = 'company_borne' | 'package_h2';
|
||||
export type OpsKind = 'abnormal' | 'transfer';
|
||||
|
||||
export type VerifyStatus = 'verified' | 'unverified';
|
||||
export type BorneBy = 'company' | 'customer';
|
||||
/** 氢费承担口径:自行结算并入客户承担,未明确归属的订单进入待核准。 */
|
||||
export type BorneBy = 'company' | 'customer' | 'pending';
|
||||
export type FleetScope = 'own' | 'external' | 'all';
|
||||
/** 按日 | 总览(站日报 / 现结登记已拆独立模块) */
|
||||
export type HostView = 'daily' | 'overview';
|
||||
@@ -62,3 +63,11 @@ export const OPS_KIND_LABEL: Record<OpsKind, string> = {
|
||||
abnormal: '异动',
|
||||
transfer: '调拨',
|
||||
};
|
||||
|
||||
export const BORNE_BY_LABEL: Record<BorneBy, string> = {
|
||||
company: '我司承担',
|
||||
customer: '客户承担',
|
||||
pending: '待核准',
|
||||
};
|
||||
|
||||
export const BORNE_BY_ORDER: BorneBy[] = ['company', 'customer', 'pending'];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './types';
|
||||
export * from './store';
|
||||
@@ -0,0 +1,569 @@
|
||||
/**
|
||||
* 体系 A · 站日现结进账 Mock + localStorage
|
||||
* 仅本尊提供 Excel 实站;禁造站。
|
||||
* - 南海:截止8.11 收支汇总 → 现结
|
||||
* - 东鹏大道:加氢记录-20260802 汇总进账明细 → 现结
|
||||
*/
|
||||
import type {
|
||||
CashIntakeChangeLog,
|
||||
StationCashIntakeDay,
|
||||
StationCashIntakeLine,
|
||||
} from './types';
|
||||
|
||||
export const CASH_INTAKE_STORAGE_KEY = 'oneos-energy-spot-cash-intake-v4';
|
||||
const LEGACY_STORAGE_KEYS = [
|
||||
'oneos-energy-spot-cash-intake-v3',
|
||||
'oneos-energy-spot-cash-intake-v2',
|
||||
];
|
||||
|
||||
function seedDays(): StationCashIntakeDay[] {
|
||||
return [
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-02',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-02',
|
||||
totalAmount: 2530.42,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-02-0', customerName: "东展供应链(广州)有限公司", amount: 655.12, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-02-1', customerName: "广东开鸿氢能科技有限公司", amount: 503.88, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-02-2', customerName: "现代氢能科技有限公司", amount: 760.38, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-02-3', customerName: "广东氢动力科技服务有限公司", amount: 296.78, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-02-4', customerName: "羚牛氢能科技(广东)有限公司", amount: 314.26, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-02 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-03',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-03',
|
||||
totalAmount: 2193.36,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-03-0', customerName: "现代氢能科技有限公司", amount: 1259.7, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-03-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 634.22, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-03-2', customerName: "东展供应链(广州)有限公司", amount: 299.44, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-03 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-04',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-04',
|
||||
totalAmount: 5216.16,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-04-0', customerName: "广东沣开科技有限公司", amount: 3000.0, payMethod: 'bank_transfer' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-04-1', customerName: "现代氢能科技有限公司", amount: 1024.1, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-04-2', customerName: "羚牛氢能科技(广东)有限公司", amount: 473.86, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-04-3', customerName: "广东氢动力科技服务有限公司", amount: 368.6, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-04-4', customerName: "东展供应链(广州)有限公司", amount: 163.78, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-04-5', customerName: "外省过路车", amount: 185.82, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-04 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-05',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-05',
|
||||
totalAmount: 3469.78,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-05-0', customerName: "现代氢能科技有限公司", amount: 1155.58, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-05-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 608.0, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-05-2', customerName: "广东氢动力科技服务有限公司", amount: 530.86, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-05-3', customerName: "广东开鸿氢能科技有限公司", amount: 315.78, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-05-4', customerName: "东展供应链(广州)有限公司", amount: 859.56, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-05 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-06',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-06',
|
||||
totalAmount: 1006.24,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-06-0', customerName: "现代氢能科技有限公司", amount: 478.8, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-06-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 527.44, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-06 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-07',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-07',
|
||||
totalAmount: 22094.18,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-07-0', customerName: "东展供应链(广州)有限公司", amount: 201.02, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-07-1', customerName: "现代氢能科技有限公司", amount: 1410.18, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-07-2', customerName: "羚牛氢能科技(广东)有限公司", amount: 482.98, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-07-3', customerName: "羚牛氢能科技(广东)有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-07 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-08',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-08',
|
||||
totalAmount: 2845.06,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-08-0', customerName: "东展供应链(广州)有限公司", amount: 745.94, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-08-1', customerName: "现代氢能科技有限公司", amount: 1371.8, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-08-2', customerName: "羚牛氢能科技(广东)有限公司", amount: 727.32, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-08 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-09',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-09',
|
||||
totalAmount: 2485.58,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-09-0', customerName: "现代氢能科技有限公司", amount: 775.2, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-09-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 987.62, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-09-2', customerName: "广东氢动力科技服务有限公司", amount: 250.42, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-09-3', customerName: "东展供应链(广州)有限公司", amount: 472.34, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-09 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-10',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-10',
|
||||
totalAmount: 22823.78,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-10-0', customerName: "现代氢能科技有限公司", amount: 1582.7, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-10-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 757.34, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-10-2', customerName: "广东氢动力科技服务有限公司", amount: 186.2, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-10-3', customerName: "东展供应链(广州)有限公司", amount: 297.54, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-10-4', customerName: "广东瀚清能源有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-10 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-fs-nanhai-2026-08-11',
|
||||
stationId: 'st-fs-nanhai',
|
||||
stationName: "佛山南海羚牛加氢站",
|
||||
bizDate: '2026-08-11',
|
||||
totalAmount: 2301.04,
|
||||
lines: [
|
||||
{ id: 'line-st-fs-nanhai-2026-08-11-0', customerName: "现代氢能科技有限公司", amount: 755.06, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-11-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 596.36, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-11-2', customerName: "广东开鸿氢能科技有限公司", amount: 599.26, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-fs-nanhai-2026-08-11-3', customerName: "东展供应链(广州)有限公司", amount: 350.36, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "杨凤娥",
|
||||
updatedAt: '2026-08-11 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-06-26',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-06-26',
|
||||
totalAmount: 10000.0,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-06-26-0', customerName: "广州市梅洛特物流有限公司", amount: 10000.0, payMethod: 'bank_transfer' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-06-26 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-01',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-01',
|
||||
totalAmount: 1506.75,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-01-0', customerName: "广州新运多租赁有限公司", amount: 1506.75, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-01 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-02',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-02',
|
||||
totalAmount: 596.4,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-02-0', customerName: "广州中味餐饮服务有限公司", amount: 96.25, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-02-1', customerName: "广州新运多租赁有限公司", amount: 500.15, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-02 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-03',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-03',
|
||||
totalAmount: 918.4,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-03-0', customerName: "广州新运多租赁有限公司", amount: 918.4, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-03 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-04',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-04',
|
||||
totalAmount: 349.3,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-04-0', customerName: "广州新运多租赁有限公司", amount: 349.3, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-04 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-07',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-07',
|
||||
totalAmount: 20640.15,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-07-0', customerName: "广州市梅洛特物流有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-07-1', customerName: "广州中味餐饮服务有限公司", amount: 362.25, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-07-2', customerName: "广州新运多租赁有限公司", amount: 277.9, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-07 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-08',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-08',
|
||||
totalAmount: 896.35,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-08-0', customerName: "广州新运多租赁有限公司", amount: 896.35, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-08 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-09',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-09',
|
||||
totalAmount: 902.65,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-09-0', customerName: "广州中味餐饮服务有限公司", amount: 211.4, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-09-1', customerName: "广州新运多租赁有限公司", amount: 691.25, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-09 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-10',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-10',
|
||||
totalAmount: 1198.05,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-10-0', customerName: "广州中味餐饮服务有限公司", amount: 314.65, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-10-1', customerName: "广州新运多租赁有限公司", amount: 883.4, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-10 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-14',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-14',
|
||||
totalAmount: 266.7,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-14-0', customerName: "广州中味餐饮服务有限公司", amount: 266.7, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-14 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-15',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-15',
|
||||
totalAmount: 562.8,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-15-0', customerName: "广州中味餐饮服务有限公司", amount: 262.5, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-15-1', customerName: "广州新运多租赁有限公司", amount: 300.3, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-15 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-16',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-16',
|
||||
totalAmount: 497.7,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-16-0', customerName: "广州中味餐饮服务有限公司", amount: 200.9, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-16-1', customerName: "广州新运多租赁有限公司", amount: 296.8, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-16 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-17',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-17',
|
||||
totalAmount: 911.4,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-17-0', customerName: "广州新运多租赁有限公司", amount: 911.4, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-17 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-18',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-18',
|
||||
totalAmount: 870.45,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-18-0', customerName: "广州新运多租赁有限公司", amount: 870.45, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-18 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-19',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-19',
|
||||
totalAmount: 468.65,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-19-0', customerName: "广州中味餐饮服务有限公司", amount: 181.3, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-19-1', customerName: "广州新运多租赁有限公司", amount: 287.35, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-19 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-20',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-20',
|
||||
totalAmount: 708.75,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-20-0', customerName: "广州新运多租赁有限公司", amount: 480.55, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-20-1', customerName: "广州中味餐饮服务有限公司", amount: 228.2, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-20 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-21',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-21',
|
||||
totalAmount: 234.85,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-21-0', customerName: "广州新运多租赁有限公司", amount: 234.85, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-21 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-25',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-25',
|
||||
totalAmount: 20686.0,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-25-0', customerName: "广州新运多租赁有限公司", amount: 686.0, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-25-1', customerName: "广州福满华冷链物流有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-25 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-27',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-27',
|
||||
totalAmount: 213.85,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-27-0', customerName: "广州新运多租赁有限公司", amount: 213.85, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-27 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-28',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-28',
|
||||
totalAmount: 20872.2,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-28-0', customerName: "广州中味餐饮服务有限公司", amount: 480.9, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-28-1', customerName: "广州新运多租赁有限公司", amount: 391.3, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-28-2', customerName: "广州市梅洛特物流有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-28 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-29',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-29',
|
||||
totalAmount: 814.8,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-29-0', customerName: "广州中味餐饮服务有限公司", amount: 84.0, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-29-1', customerName: "广州新运多租赁有限公司", amount: 730.8, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-29 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-30',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-30',
|
||||
totalAmount: 1450.4,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-30-0', customerName: "广州中味餐饮服务有限公司", amount: 393.05, payMethod: 'wechat_scan' },
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-30-1', customerName: "广州新运多租赁有限公司", amount: 1057.35, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-30 18:00',
|
||||
},
|
||||
{
|
||||
id: 'day-st-dp-dongpeng-2026-07-31',
|
||||
stationId: 'st-dp-dongpeng',
|
||||
stationName: "东鹏大道甲醇制氢一体站",
|
||||
bizDate: '2026-07-31',
|
||||
totalAmount: 587.65,
|
||||
lines: [
|
||||
{ id: 'line-st-dp-dongpeng-2026-07-31-0', customerName: "广州新运多租赁有限公司", amount: 587.65, payMethod: 'wechat_scan' },
|
||||
],
|
||||
updatedBy: "金可鹏",
|
||||
updatedAt: '2026-07-31 18:00',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeDay(d: StationCashIntakeDay): StationCashIntakeDay {
|
||||
return {
|
||||
...d,
|
||||
changeLogs: Array.isArray(d.changeLogs) ? d.changeLogs : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function loadCashIntakeDays(): StationCashIntakeDay[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(CASH_INTAKE_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as StationCashIntakeDay[];
|
||||
if (Array.isArray(parsed) && parsed.length) return parsed.map(normalizeDay);
|
||||
}
|
||||
for (const key of LEGACY_STORAGE_KEYS) {
|
||||
const legacy = localStorage.getItem(key);
|
||||
if (!legacy) continue;
|
||||
const parsed = JSON.parse(legacy) as StationCashIntakeDay[];
|
||||
if (Array.isArray(parsed) && parsed.length) {
|
||||
const next = parsed.map(normalizeDay);
|
||||
saveCashIntakeDays(next);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return seedDays().map(normalizeDay);
|
||||
}
|
||||
|
||||
export function saveCashIntakeDays(days: StationCashIntakeDay[]): void {
|
||||
try {
|
||||
localStorage.setItem(CASH_INTAKE_STORAGE_KEY, JSON.stringify(days));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getCashIntakeForStationRange(
|
||||
days: StationCashIntakeDay[],
|
||||
stationId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): StationCashIntakeDay[] {
|
||||
return days
|
||||
.filter((d) => d.stationId === stationId && d.bizDate >= startDate && d.bizDate <= endDate)
|
||||
.slice()
|
||||
.sort((a, b) => a.bizDate.localeCompare(b.bizDate));
|
||||
}
|
||||
|
||||
export function appendChangeLog(
|
||||
day: StationCashIntakeDay,
|
||||
entry: Omit<CashIntakeChangeLog, 'id'> & { id?: string },
|
||||
): StationCashIntakeDay {
|
||||
const log = {
|
||||
id: entry.id || `log-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
at: entry.at,
|
||||
by: entry.by,
|
||||
action: entry.action,
|
||||
summary: entry.summary,
|
||||
};
|
||||
const prev = Array.isArray(day.changeLogs) ? day.changeLogs : [];
|
||||
return { ...day, changeLogs: [log, ...prev].slice(0, 80) };
|
||||
}
|
||||
|
||||
export function upsertCashIntakeDay(
|
||||
days: StationCashIntakeDay[],
|
||||
next: StationCashIntakeDay,
|
||||
): StationCashIntakeDay[] {
|
||||
const idx = days.findIndex((d) => d.stationId === next.stationId && d.bizDate === next.bizDate);
|
||||
const copy = days.slice();
|
||||
if (idx >= 0) copy[idx] = { ...next, changeLogs: next.changeLogs || copy[idx].changeLogs || [] };
|
||||
else copy.push({ ...next, changeLogs: next.changeLogs || [] });
|
||||
return copy.sort((a, b) => b.bizDate.localeCompare(a.bizDate));
|
||||
}
|
||||
|
||||
export function deleteCashIntakeDay(
|
||||
days: StationCashIntakeDay[],
|
||||
stationId: string,
|
||||
bizDate: string,
|
||||
): StationCashIntakeDay[] {
|
||||
return days.filter((d) => !(d.stationId === stationId && d.bizDate === bizDate));
|
||||
}
|
||||
|
||||
export function sumLines(lines: StationCashIntakeLine[]): number {
|
||||
return lines.reduce((s, l) => s + (Number(l.amount) || 0), 0);
|
||||
}
|
||||
|
||||
export function summarizeCashIntake(
|
||||
days: StationCashIntakeDay[],
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
): { dayCount: number; totalAmount: number; stationCount: number } {
|
||||
const filtered = days.filter((d) => {
|
||||
if (startDate && d.bizDate < startDate) return false;
|
||||
if (endDate && d.bizDate > endDate) return false;
|
||||
return true;
|
||||
});
|
||||
const stations = new Set(filtered.map((d) => d.stationId));
|
||||
return {
|
||||
dayCount: filtered.length,
|
||||
totalAmount: filtered.reduce((s, d) => s + d.totalAmount, 0),
|
||||
stationCount: stations.size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 站日现结进账类型(共享)
|
||||
* 禁止与预充值能源账户混用(口径见 PRD,不进 UI)。
|
||||
*/
|
||||
|
||||
export type SpotPayMethod = 'wechat_scan' | 'bank_transfer' | 'other';
|
||||
|
||||
export const SPOT_PAY_METHOD_LABEL: Record<SpotPayMethod, string> = {
|
||||
wechat_scan: '微信扫码',
|
||||
bank_transfer: '对公转账',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
export function parsePayMethodLabel(raw: string): SpotPayMethod | null {
|
||||
const t = String(raw || '').trim();
|
||||
if (!t) return null;
|
||||
const entry = (Object.keys(SPOT_PAY_METHOD_LABEL) as SpotPayMethod[]).find(
|
||||
(k) => SPOT_PAY_METHOD_LABEL[k] === t || k === t,
|
||||
);
|
||||
return entry || null;
|
||||
}
|
||||
|
||||
export interface StationCashIntakeLine {
|
||||
id: string;
|
||||
customerName: string;
|
||||
amount: number;
|
||||
payMethod: SpotPayMethod;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export type CashIntakeChangeAction = 'create' | 'update' | 'delete' | 'import';
|
||||
|
||||
export interface CashIntakeChangeLog {
|
||||
id: string;
|
||||
at: string;
|
||||
by: string;
|
||||
action: CashIntakeChangeAction;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/** 唯一键 stationId + bizDate(业务字段仍用 bizDate;UI 称「登记日期」) */
|
||||
export interface StationCashIntakeDay {
|
||||
id: string;
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
bizDate: string; // YYYY-MM-DD · 登记日期
|
||||
totalAmount: number;
|
||||
remark?: string;
|
||||
lines: StationCashIntakeLine[];
|
||||
updatedBy: string;
|
||||
updatedAt: string;
|
||||
changeLogs?: CashIntakeChangeLog[];
|
||||
}
|
||||
|
||||
export const CASH_INTAKE_STATIONS = [
|
||||
{ id: 'st-fs-nanhai', name: '佛山南海羚牛加氢站' },
|
||||
{ id: 'st-dp-dongpeng', name: '东鹏大道甲醇制氢一体站' },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_CASH_STATION_ID = 'st-fs-nanhai';
|
||||
|
||||
/** Make / 本地预览跳转 */
|
||||
export const PROTO_PATH_SPOT_CASH = '/prototypes/energy-spot-cash-intake';
|
||||
export const PROTO_PATH_STATION_DAILY = '/prototypes/energy-h2-station-daily';
|
||||
export const PROTO_PATH_ENERGY_BI = '/prototypes/energy-h2-bi-board';
|
||||
@@ -0,0 +1,385 @@
|
||||
.mobile-list-fullscreen-trigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
[data-mobile-fullscreen-list] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-list-fullscreen-trigger {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
min-width: 124px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #dbe4f1;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: #fff;
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 5px 14px rgb(37 99 235 / 20%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-list-fullscreen-trigger.is-inline {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
z-index: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mobile-list-fullscreen-trigger__label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-list-fullscreen-trigger::before {
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
content: '';
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list] > h2,
|
||||
[data-mobile-fullscreen-list] > .sd-section-head,
|
||||
[data-mobile-fullscreen-list] > .sd-panel__head-row,
|
||||
[data-mobile-fullscreen-list] > .ehb-sum-table-card__head,
|
||||
[data-mobile-fullscreen-list] > .ehb-mobile-detail-tabs-head,
|
||||
[data-mobile-fullscreen-list] > .ehb-daily-table-head {
|
||||
box-sizing: border-box;
|
||||
padding-inline-end: 112px !important;
|
||||
}
|
||||
|
||||
/* 复合标题区的 Tab 在第二行,只让第一行标题避让横屏按钮。 */
|
||||
[data-mobile-fullscreen-list] > .ehb-mobile-detail-tabs-head {
|
||||
padding-inline-end: 14px !important;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list] > .ehb-sum-table-card__head .ehb-sum-table-card__meta,
|
||||
[data-mobile-fullscreen-list] > .sd-section-head .sd-panel__meta,
|
||||
[data-mobile-fullscreen-list] > .sd-panel__head-row .sd-panel__meta {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list]:fullscreen,
|
||||
[data-mobile-fullscreen-list].is-mobile-list-fullscreen,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] {
|
||||
box-sizing: border-box;
|
||||
z-index: 200;
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
border-radius: 0;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list].is-mobile-list-fullscreen,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] > .mobile-list-fullscreen-trigger,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] > * > .mobile-list-fullscreen-trigger {
|
||||
position: fixed !important;
|
||||
top: 10px !important;
|
||||
right: 10px !important;
|
||||
z-index: 260 !important;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list]:fullscreen .ehb-sum-table-wrap,
|
||||
[data-mobile-fullscreen-list]:fullscreen .ehb-table-wrap,
|
||||
[data-mobile-fullscreen-list]:fullscreen .sd-table-scroll,
|
||||
[data-mobile-fullscreen-list].is-mobile-list-fullscreen .ehb-sum-table-wrap,
|
||||
[data-mobile-fullscreen-list].is-mobile-list-fullscreen .ehb-table-wrap,
|
||||
[data-mobile-fullscreen-list].is-mobile-list-fullscreen .sd-table-scroll,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table-wrap,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-table-wrap,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .sd-table-scroll {
|
||||
max-height: none;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* 宽表只切换阅读布局:竖屏保持原方向并由表格横向滚动。 */
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] {
|
||||
box-sizing: border-box;
|
||||
position: fixed !important;
|
||||
z-index: 200 !important;
|
||||
inset: 0 !important;
|
||||
overflow: auto !important;
|
||||
padding: 10px !important;
|
||||
border-radius: 0 !important;
|
||||
background: #f4f7fb !important;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger {
|
||||
position: fixed !important;
|
||||
z-index: 260 !important;
|
||||
top: 10px !important;
|
||||
right: 10px !important;
|
||||
display: inline-flex !important;
|
||||
width: auto;
|
||||
min-width: 92px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #dbe4f1;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: #4f6f9f;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table-wrap,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-table-wrap,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .sd-table-scroll {
|
||||
max-height: none !important;
|
||||
overflow: auto !important;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table {
|
||||
width: max-content !important;
|
||||
min-width: 100% !important;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table th:first-child,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table td:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
background: #fff;
|
||||
box-shadow: 5px 0 9px -9px #0f172a;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table thead th:first-child {
|
||||
z-index: 7;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
/* 汇总表的第一列只是序号;继续冻结第二列站点名称,横向滚动时保留行身份。 */
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table th:first-child,
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table td:first-child {
|
||||
width: 42px;
|
||||
min-width: 42px;
|
||||
max-width: 42px;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table th:nth-child(2),
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table td:nth-child(2) {
|
||||
position: sticky;
|
||||
left: 42px;
|
||||
z-index: 5;
|
||||
min-width: 190px;
|
||||
background: #fff;
|
||||
box-shadow: 7px 0 10px -10px #0f172a;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table thead th:nth-child(2) {
|
||||
z-index: 7;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"]::after {
|
||||
position: fixed;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 250;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid #dbe4f1;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 92%);
|
||||
color: #64748b;
|
||||
content: '左右滑动查看更多列';
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgb(15 23 42 / 8%);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) and (orientation: portrait) {
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"],
|
||||
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"].is-mobile-list-fallback {
|
||||
transform: none !important;
|
||||
transform-origin: initial !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 下钻弹层最终移动端约束:本文件最后加载,避免旧版宽表规则反向撑开页面。 */
|
||||
@media (max-width: 767px) {
|
||||
.ehb-drill-modal--unified .ehb-modal-body {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
padding: 12px !important;
|
||||
gap: 10px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-bar {
|
||||
flex: 0 0 auto !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
gap: 6px !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-meta-item {
|
||||
min-width: 0 !important;
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-real-drill-filter-summary,
|
||||
.ehb-drill-modal--unified .ehb-drill-filter-summary-row {
|
||||
display: flex !important;
|
||||
flex: 0 0 auto !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.ehb-real-drill-primary-actions {
|
||||
display: grid !important;
|
||||
flex: 0 0 auto;
|
||||
grid-template-columns: minmax(0, 1fr) 132px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ehb-real-drill-primary-actions .ehb-real-drill-filter-summary,
|
||||
.ehb-real-drill-primary-actions .mobile-list-fullscreen-trigger {
|
||||
position: static !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
height: 48px !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.ehb-real-drill-primary-actions .ehb-real-drill-filter-summary {
|
||||
display: grid !important;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.ehb-real-drill-primary-actions .ehb-real-drill-filter-summary strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-real-drill-filter-summary {
|
||||
min-height: 48px !important;
|
||||
padding: 0 14px !important;
|
||||
border: 1px solid #d9e3f0 !important;
|
||||
border-radius: 12px !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table-wrap {
|
||||
flex: 1 1 auto !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
overflow: auto !important;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-drill-filter-summary-row {
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 1fr) auto !important;
|
||||
align-items: stretch !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-drill-filter-summary-row .mobile-list-fullscreen-trigger {
|
||||
position: static !important;
|
||||
min-width: 124px !important;
|
||||
height: 48px !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.ehb-drill-usage-guide {
|
||||
display: grid !important;
|
||||
flex: 0 0 auto;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ehb-drill-usage-guide > span {
|
||||
min-width: 0;
|
||||
padding: 7px 8px;
|
||||
border-radius: 8px;
|
||||
background: #eaf2ff;
|
||||
color: #52637b;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ehb-drill-usage-guide strong { color: #1f5fe0; }
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table tr[aria-expanded] > td:first-child {
|
||||
position: sticky !important;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table tr[aria-expanded] > td:first-child::after {
|
||||
display: inline-flex;
|
||||
margin-left: 8px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: #e8f1ff;
|
||||
color: #2563eb;
|
||||
content: '展开';
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ehb-drill-modal--unified .ehb-modal-table tr[aria-expanded="true"] > td:first-child::after {
|
||||
background: #e7f8f1;
|
||||
color: #07875f;
|
||||
content: '收起';
|
||||
}
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-body {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-meta-bar {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||
}
|
||||
|
||||
html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-table-wrap {
|
||||
flex: 1 1 auto !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { isPhoneUserAgent, phoneRotation } from './phone-viewport.js';
|
||||
test('仅手机 UA 在竖屏宽表模式旋转,不影响平板和桌面', () => {
|
||||
for (const ua of ['Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)', 'Mozilla/5.0 (Linux; Android 14) Mobile Safari/537.36']) {
|
||||
assert.equal(isPhoneUserAgent(ua), true);
|
||||
assert.equal(phoneRotation(ua, 390, 844), true);
|
||||
assert.equal(phoneRotation(ua, 844, 390), false);
|
||||
}
|
||||
for (const ua of ['Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X)', 'Mozilla/5.0 (Macintosh; Intel Mac OS X)', 'Mozilla/5.0 (Linux; Android 14) Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)']) {
|
||||
assert.equal(phoneRotation(ua, 390, 844), false);
|
||||
}
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user