revert: restore v1.1.14 (37a9f303)
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
shishengliang
2026-09-16 09:29:30 +08:00
parent c49a02e3d9
commit 6ea1b3d5dd
210 changed files with 4448 additions and 36179 deletions
Vendored
BIN
View File
Binary file not shown.
-18
View File
@@ -1,18 +0,0 @@
# Copy to .env.local; never commit real credentials. Environment values win.
HYDROGEN_DB_HOST=
HYDROGEN_DB_PORT=3306
HYDROGEN_DB_USER=
HYDROGEN_DB_PASSWORD=
HYDROGEN_DB_NAME=
# 单站与外部客户数据租户;仅由服务端设置,不接受网页传入。
HYDROGEN_TENANT_ID=000000
# Optional legacy/electric business database; use a database-level read-only user.
DB_HOST=
DB_PORT=3306
DB_USER=
DB_PASSWORD=
DB_NAME=
# Local development uses the public OneOS endpoint; the ECS private IP is not reachable locally.
ONEOS_MILEAGE_API_BASE_URL=https://open.d.lnoneos.com
ONEOS_MILEAGE_API_KEY=
ONEOS_MILEAGE_API_TIMEOUT_MS=20000
-3
View File
@@ -1,7 +1,4 @@
node_modules node_modules
dist dist
.env .env
.env.local
.worktrees .worktrees
.DS_Store
scripts-tmp
+3 -5
View File
@@ -2,7 +2,7 @@ FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --registry=https://registry.npmmirror.com/ RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
@@ -10,16 +10,14 @@ FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev --registry=https://registry.npmmirror.com/ RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY src/server ./src/server COPY src/server ./src/server
COPY src/shared ./src/shared COPY src/shared ./src/shared
COPY tsconfig.json ./ COPY tsconfig.json ./
EXPOSE 3001 EXPOSE 3001
ENV NODE_ENV=production
ENV SERVER_PORT=3001 ENV SERVER_PORT=3001
ENV EXTERNAL_API_BASE=https://lnh2e.com ENV EXTERNAL_API_BASE=https://lnh2e.com
# JWT_SECRET 不再写入镜像:镜像层里的默认密钥等同于公开密钥,任何人可离线伪造令牌。 ENV JWT_SECRET=ln-bi-jwt-prod-secret
# 必须在运行环境注入(至少 32 字符),缺失时服务会拒绝启动(见 src/server/config.ts)。
CMD ["npm", "run", "start"] CMD ["npm", "run", "start"]
-87
View File
@@ -1,87 +0,0 @@
# 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:8115API 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/ 跨模块 UIShell、反馈、通用控件)
├── 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`;后者会绕过全部认证。
-47
View File
@@ -1,47 +0,0 @@
# Energy Hydrogen Board Design QA
- Source of truth: `/tmp/energy-h2-bi-board-prototype-0818/index.html`
- Prototype archive: `/Users/kkfluous/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/wxid_1704407055112_3af8/msg/file/2026-08/energy-h2-bi-board-html.zip`
- Implementation: `http://localhost:3000/energy#hydrogen/overview`
- Desktop viewport: 1280 x 720 CSS px
- Mobile viewport checked for the daily flow: 390 x 844 CSS px
## Compared States
| State | Prototype | Implementation | Combined comparison |
| --- | --- | --- | --- |
| Global overview | `/var/tmp/proto-global-overview.png` | `/var/tmp/impl-global-overview-final.png` | `/var/tmp/compare-global-overview-final.png` |
| Global daily | `/var/tmp/proto-global-daily.png` | `/var/tmp/impl-global-daily-final.png` | `/var/tmp/compare-global-daily-final.png` |
| All stations | `/var/tmp/proto-station.png` | `/var/tmp/impl-station-final.png` | `/var/tmp/compare-station-final.png` |
| Station detail | `/var/tmp/proto-station-detail.png` | `/var/tmp/impl-station-detail-final.png` | `/var/tmp/compare-station-detail-final.png` |
All desktop comparisons use the same viewport, pixel dimensions, navigation state, and side-by-side source/implementation input.
## Interaction Evidence
- Global overview KPI, month, region, station, customer, and insight entries open real ledger drill-down data.
- Global daily supports date presets, custom dates, all/Lingniu/external vehicle scopes, chart-to-date focus, and date -> station -> customer -> vehicle/source drill-down.
- Daily and station evidence tables provide Excel export where the prototype exposes an export command.
- All-station mode supports all/single station display, station selection, clickable daily KPI dialogs, and zero-activity stations.
- Station detail includes a continuous 10-day series with zero-filled late days, recent 7-day table, range trend, and 12-month customer quantity/fee matrices.
- Entering and leaving a station detail resets the page scroll position so the header and notice remain visible.
- A clean browser tab loaded the final overview with no console warnings or errors.
- Daily mobile flow has no body-level horizontal overflow; wide analytical tables retain intentional internal horizontal scrolling.
## Iteration History
1. P1: Station detail inherited the long station-list scroll position. Added deterministic scroll reset on enter and return.
2. P1: Daily drill stopped at station level. Implemented the prototype's full four-level drill and real ledger rows.
3. P1: Station KPI cards were static. Added the three prototype daily detail dialogs using continuous server-side daily summaries.
4. P2: Prototype state matrix was incomplete. Added global overview, global daily, all-station, single-station selector, and station detail states.
5. P2: Icons differed from the prototype. Replaced approximate icons with matching Lucide Fuel, Zap, Wallet, Calendar, Truck, Refresh, Download, and navigation icons.
6. P2: Recharts could measure a transitioning container at a negative size. Added minimum and initial dimensions and verified a clean reload.
7. P2: Station detail omitted dates without records. Zero-filled the requested range so delayed or absent reporting is visible rather than silently skipped.
## Residual P3
- Prototype screenshots contain fixed mock dates and values; implementation screenshots use the current read-only database values.
- The prototype warning says opening balances are still calibrating. The implementation uses a truthful traceability notice because the live ledger does not expose that calibration state.
- Native date-picker popovers vary by operating system; the closed controls match the prototype hierarchy and icon roles.
final result: passed
+17 -30
View File
@@ -1,29 +1,25 @@
version: '3.8' version: '3.8'
# 凭据一律通过 Portainer / 环境变量注入,不再写入本文件。
# 注意:此前提交到 Git 的数据库口令与 JWT 密钥已视为泄漏,必须轮换后再使用。
# JWT_SECRET 至少要 32 个字符,缺失时服务会拒绝启动(src/server/config.ts)。
services: services:
ln-bi: ln-bi:
image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.2.0 image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0
network_mode: host network_mode: host
environment: environment:
NODE_ENV: "production" DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com"
# 生产必须保持为 0:该开关会绕过全部认证。 DB_PORT: "3306"
DEV_BYPASS_AUTH: "0" DB_USER: "oneos_db_prod"
DB_HOST: "${DB_HOST:?请注入主业务库地址}" DB_PASSWORD: "adASHJcviqwjkbn23ngt1"
DB_PORT: "${DB_PORT:-3306}" DB_NAME: "ln_asset_management"
DB_USER: "${DB_USER:?请注入主业务库只读账号}" HYDROGEN_DB_HOST: "47.99.185.173"
DB_PASSWORD: "${DB_PASSWORD:?请注入主业务库口令}" HYDROGEN_DB_PORT: "3306"
DB_NAME: "${DB_NAME:-ln_asset_management}" HYDROGEN_DB_USER: "root"
# 氢能库未配置时复用主库,因此默认不再单独注入;如需独立只读库再配置。 HYDROGEN_DB_PASSWORD: "lnMysql."
HYDROGEN_DB_HOST: "${HYDROGEN_DB_HOST:-}" HYDROGEN_DB_NAME: "ln_asset_management"
HYDROGEN_DB_PORT: "${HYDROGEN_DB_PORT:-3306}" MILEAGE_DB_HOST: "101.133.130.65"
HYDROGEN_DB_USER: "${HYDROGEN_DB_USER:-}" MILEAGE_DB_PORT: "3306"
HYDROGEN_DB_PASSWORD: "${HYDROGEN_DB_PASSWORD:-}" MILEAGE_DB_USER: "bi_reader_02"
HYDROGEN_DB_NAME: "${HYDROGEN_DB_NAME:-}" MILEAGE_DB_PASSWORD: "bi_reader_02_Pass"
HYDROGEN_TENANT_ID: "${HYDROGEN_TENANT_ID:-000000}" MILEAGE_DB_NAME: "hydrogen_energy"
# ECS production accesses OneOS over the private network. Configure the key # ECS production accesses OneOS over the private network. Configure the key
# as a Portainer stack environment variable; never commit it to this file. # as a Portainer stack environment variable; never commit it to this file.
ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}" ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}"
@@ -31,9 +27,7 @@ services:
ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}" ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}"
SERVER_PORT: "8111" SERVER_PORT: "8111"
EXTERNAL_API_BASE: "https://lnh2e.com" EXTERNAL_API_BASE: "https://lnh2e.com"
BI_AUTH_MODE: "${BI_AUTH_MODE:-sso}" JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7"
BI_AUTH_PASSWORD: "${BI_AUTH_PASSWORD:-}"
JWT_SECRET: "${JWT_SECRET:?请注入至少 32 字符的随机签名密钥}"
AMAP_WEB_KEY: "${AMAP_WEB_KEY}" AMAP_WEB_KEY: "${AMAP_WEB_KEY}"
AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}" AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}"
HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}" HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}"
@@ -42,13 +36,6 @@ services:
HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}" HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}"
HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}" HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}"
HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}" 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: deploy:
replicas: 1 replicas: 1
restart_policy: restart_policy:
BIN
View File
Binary file not shown.
-165
View File
@@ -1,165 +0,0 @@
# 架构与分层约定
本文是这个仓库的结构契约。**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/ authJWT → 注入 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 kBgzip 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 缺失拒绝启动已实测 |
-46
View File
@@ -1,46 +0,0 @@
# 登录方式与 Portainer 配置
不设置 `BI_AUTH_MODE` 时默认 `sso`,沿用业务系统 jumpToken 登录。
设置 `BI_AUTH_MODE=password` 时只开放固定密码登录,关闭 SSO 换票入口。
浏览器从 `/api/auth/config` 获取模式,密码不会打包到前端,不使用 VITE_ 密码变量。
## Portainer Stack
在现有 BI 服务的 `environment` 中合并以下配置,保留原镜像、端口、数据库等配置:
```yaml
environment:
BI_AUTH_MODE: ${BI_AUTH_MODE:-sso}
BI_AUTH_PASSWORD: ${BI_AUTH_PASSWORD:-}
JWT_SECRET: ${JWT_SECRET:?必须配置独立的随机签名密钥}
DEV_BYPASS_AUTH: "0"
NODE_ENV: production
```
在 Stack 的 Environment variables 区域录入以下名称和值:
| 名称 | 固定密码模式 | SSO 模式 |
| --- | --- | --- |
| BI_AUTH_MODE | password | sso |
| BI_AUTH_PASSWORD | 非空密码,允许短密码,推荐独立随机密码 | 留空 |
| JWT_SECRET | 独立随机密钥,至少 32 字符 | 保留部署专用密钥 |
不要把真实密码提交进 Git。Portainer 中填写的 Stack 变量必须通过上面的 environment 映射才能进入容器。
更新 Stack 并重新创建容器;单纯 Restart 不会更新容器环境变量。
容器方式部署:Duplicate/Edit → Advanced container settings → Env,填写同样变量,再重新部署。
需要先构建包含本功能的新镜像;旧镜像不认识这些变量。
## 权限、安全和验证
- 固定密码身份是共享只读身份,具有全量看板数据读取范围及能源访问权限,无调度/反馈管理角色;服务端拒绝写入方法。
- 会话有效期 8 小时。更换密码、JWT_SECRET 或切换模式后,原密码会话失效。
- 每个服务实例 15 分钟最多 20 次密码验证请求;多副本需在网关配置共享限流。共享限流可能被恶意请求耗尽,建议只在内网或受控网络开放。
- 外网必须使用 HTTPS,避免明文传输密码和令牌。密码/环境变量对拥有容器管理权限的人可见。
- 密码为空或全为空白,或签名密钥少于 32 字符时拒绝登录;无默认访问密码。不要沿用镜像中的旧默认 JWT_SECRET。
- 短密码容易被猜中,仅建议在内网或受限访问环境使用;登录限流保持开启。
- DB_READ_ONLY=1 时密码登录仍可用,业务写入仍被禁止。
- 测试未登录访问、错误密码、正确密码、刷新恢复会话;切回 sso 后检查跳转登录。
- 开发免登录仅 SSO 开发模式生效,生产必须保持 DEV_BYPASS_AUTH=0。
Portainer 官方说明:https://docs.portainer.io/user/docker/containers/advanced
以及 https://docs.portainer.io/sts/user/docker/stacks/add
-671
View File
@@ -1,671 +0,0 @@
# LN-BI 项目移交交接说明
> 文档版本:1.0
> 更新日期:2026-08-21
> 对应代码版本:`1.1.15`
> 对应分支 / 提交:`main` / `2518d9ee5440c39138427ce76d1f6e0faf8d9f29`
> 项目仓库:`https://gitea.lnh2e.com/shishengliang/ln-bi.git`
## 1. 文档目的与交接边界
本文用于帮助新的研发、测试、运维和产品人员快速接管 LN-BI 项目,覆盖:
- 系统定位、代码结构和运行拓扑;
- 登录认证、角色鉴权和数据权限;
- 本地开发与免登录调试;
- 前后端技术栈;
- 数据对接方、数据库、外部服务及对应 BI / 报表;
- 当前功能、后台任务、接口边界和部署流程;
- 已知技术债、安全风险和交接检查清单。
本文依据当前仓库代码和配置编写,不记录数据库密码、JWT 密钥、API Key、OSS 密钥或 CI 仓库凭据。此类信息应通过密码管理器、Portainer / Docker 环境变量或 CI Secret 单独移交。
## 2. 项目概览
### 2.1 系统定位
LN-BI 是羚牛业务数据的统一 BI Web 应用。目前包含两组主入口:
| 主入口 | 主要模块 | 默认路由 |
| --- | --- | --- |
| 资产 BI | 资产管理、里程管理、车辆 / 加氢热力图、智能调度 | `/asset` |
| 能源 BI | 氢能、电能、ETC | `/energy` |
应用还保留两个不在主导航中展示的后台入口:
| 隐藏入口 | 用途 | 路由 |
| --- | --- | --- |
| 充电记录导入 | 上传并管理电费 XLSX 数据 | `/ele/import` |
| 用户反馈管理 | 查看、回复和推进用户反馈 | `/admin/feedback` |
页面内部主要使用 Hash 保存模块和子页面状态,例如 `/energy#hydrogen/overview`。入口兼容逻辑位于 `src/app/routing.ts`
### 2.2 总体架构
```mermaid
flowchart LR
A[羚牛业务系统] -->|一次性 jumpToken| B[LN-BI React 前端]
B -->|/api/auth/exchange| C[Hono API]
C -->|校验 jumpToken / 获取用户角色| A
C -->|签发 8 小时本地 JWT| B
B -->|Bearer JWT| C
C --> D[(主业务 MySQL / 跨 Schema)]
C --> E[(氢能 MySQL 连接\n默认复用主库)]
C --> F[(车辆位置 PostgreSQL)]
C --> G[OneOS 里程 API]
C --> H[阿里云 OSS]
B --> I[高德地图 JS API]
```
生产环境由一个 Node.js 进程同时提供 REST API 和 Vite 构建后的静态页面。开发环境中,Vite 前端和 Hono 后端分别运行,由 Vite 将 `/api` 代理到后端。
### 2.3 关键目录
| 路径 | 说明 |
| --- | --- |
| `src/App.tsx` | 应用入口、认证门禁、主路由和隐藏页面装配 |
| `src/app/modules.ts` | 资产 / 能源导航模块注册与角色可见性 |
| `src/auth/` | 前端认证状态、JWT 注入、未授权页面 |
| `src/shared/auth/` | 前后端共用角色常量和模块访问判断 |
| `src/server/` | Hono 服务、认证中间件、数据库连接、API 路由和后台任务 |
| `src/modules/` | 各 BI 页面和交互实现 |
| `src/modules/energy/hydrogen-bi-v2/` | 当前氢能 BI 原型迁入版、真实数据适配器和下钻实现 |
| `docs/` | 接口、重构和交接文档 |
| `Dockerfile` | 前端构建 + Node 运行时镜像 |
| `docker-compose.yml` | 当前容器部署参数样例 |
| `woodpecker.yml` | Woodpecker CI 构建、测试、镜像推送流程 |
### 2.4 当前质量基线
2026-08-21 在当前提交和本地依赖环境完成以下验证:
| 检查 | 结果 |
| --- | --- |
| `npm run lint` | 通过 |
| `npm test` | 113 项通过,0 项失败 |
| `npm run build` | 通过,Vite 成功生成生产构建 |
测试和构建会输出 Node.js `DEP0205``module.register()` 已弃用)警告,目前不阻断运行;后续升级 `tsx` / Node 工具链时应处理。
## 3. 登录认证方式
### 3.1 正常登录链路
项目自身没有用户名 / 密码登录表单,正常入口依赖羚牛业务系统单点跳转:
1. 用户先登录羚牛业务系统。
2. 业务系统跳转到 LN-BI,并在 URL 中携带一次性 `jumpToken`
3. 前端调用 `GET /api/auth/exchange?jumpToken=...`
4. LN-BI 后端调用业务系统的 `issueTokenByJump` 接口校验一次性令牌并取得用户、部门编码和角色。
5. 后端根据角色计算数据权限级别,并通过主业务库 `tab_department` 补全部门名称。
6. 后端使用 `JWT_SECRET` 签发有效期 8 小时的 LN-BI JWT。
7. 前端将 JWT 和用户信息保存到当前标签页的 `sessionStorage`
- `bi_jwt`
- `bi_user`
8. 前端从地址栏移除 `jumpToken`,之后所有受保护 API 均发送 `Authorization: Bearer <JWT>`
9. API 返回 `401` 时,前端清空本地会话并显示“会话已过期”。
关键实现:
- 前端:`src/auth/AuthProvider.tsx`
- Token 注入:`src/auth/api-client.ts`
- Token 换取:`src/server/auth/login.ts`
- API 认证:`src/server/auth/middleware.ts`
### 3.2 公开接口与受保护接口
不要求 JWT 的接口:
- `/api/health`
- `/api/auth/*`
其余 `/api/*` 默认经过统一认证中间件。能源、智能调度、加氢热力图和反馈管理在统一认证之上还有模块级或接口级角色校验。
### 3.3 会话特征
| 项目 | 当前实现 |
| --- | --- |
| Token 类型 | HS256 JWT`jsonwebtoken` 默认签名算法) |
| 有效期 | 8 小时 |
| 浏览器存储 | `sessionStorage`,关闭标签页后失效 |
| 用户来源 | 羚牛业务系统 jumpToken 换取 |
| 部门名称来源 | 主业务库 `tab_department` |
| 退出方式 | 当前无独立退出接口;清除标签页会话或收到 401 后清理 |
## 4. 角色鉴权与数据权限
权限分为两层,接手时必须分别理解:
1. **模块访问权限**:决定能否进入能源、智能调度或反馈后台。
2. **数据范围权限**:决定用户可查看全量、部门或个人负责的数据。
### 4.1 数据范围角色
| 业务系统角色 | JWT `permissionLevel` | 数据范围 |
| --- | --- | --- |
| `所有权限``数智中心``BI-Leader` | `full` | 全量数据 |
| `BI-Leader-Dep` | `department` | `departmentName / department = user.depName` |
| 其他角色或无上述角色 | `personal` | `managerId = user.userId` |
数据过滤和客户名称脱敏由 `src/server/auth/permissions.ts` 提供。目前明确接入该过滤的主要范围包括:
- 资产车辆统计与列表;
- 里程实时监控;
- 里程考核车辆明细;
- 智能调度建议。
注意:数据权限不是数据库行级安全,而是路由读取数据后在服务端过滤。新增 API 时必须主动调用统一过滤逻辑或实现等价的 SQL 过滤,不能只依赖前端隐藏。
### 4.2 模块访问角色
| 模块 / 能力 | 允许角色 | 前端控制 | 后端控制 |
| --- | --- | --- | --- |
| 资产管理、里程管理、车辆热力图 | 已认证用户 | 导航默认展示 | 统一 JWT 中间件 |
| 能源 BI(氢能 / 电能 / ETC) | `BI-LEADER-ENERGY``所有权限` | `/energy` 门禁 | `/api/energy/*` 守卫 |
| 加氢热力图 | `BI-LEADER-ENERGY``所有权限` | 无权限时不出现在热力图子导航 | `/api/hydrogen-heatmap/*` 守卫 |
| 智能调度 | `BI-SCHEDULE-OPT` | 无角色时不显示模块 | `/api/scheduling/*` 守卫 |
| 反馈管理 | `BI-ADMIN-FEEDBACK` 或任一全量数据角色 | 隐藏页面,需直接访问 | 管理列表与更新接口校验 |
| 用户反馈提交 / 我的反馈 | 已认证用户 | 页面反馈入口 | 统一 JWT 中间件 |
角色常量和判断位于 `src/shared/auth/roles.ts`
### 4.3 容易误解的权限边界
- `full` 只表示数据范围,不代表自动拥有所有模块权限。
- `数智中心``BI-Leader` 虽会获得全量数据,但当前不会自动进入能源模块;能源仅额外接受 `所有权限`
- 智能调度当前仅接受 `BI-SCHEDULE-OPT`,不会因为拥有 `full` 数据权限自动放行。
- 前端隐藏不是安全措施;任何新增受限能力都必须增加后端守卫。
## 5. 本地免登录调试环境
### 5.1 环境要求
- Node.js 22Docker 和 CI 均使用 Node 22);
- npm,依赖版本以 `package-lock.json` 为准;
- 能访问所需数据库和外部 API 的网络环境;
- 本地配置文件 `.env`。该文件已被 `.gitignore` 忽略,禁止提交。
### 5.2 最小启动步骤
```bash
npm ci
npm run dev
```
启动后:
| 服务 | 地址 | 说明 |
| --- | --- | --- |
| Vite 前端 | `http://localhost:3000` | 对局域网开放,`/api` 代理到 3001 |
| Hono 后端 | `http://localhost:3001` | `SERVER_PORT` 未设置时的默认端口 |
| 健康检查 | `http://localhost:3001/api/health` | 只验证进程可访问,不验证数据库 |
### 5.3 免登录开关
本地前后端需要同时开启免登录:
```dotenv
# 仅用于本地开发,生产环境严禁设置为 1
VITE_DEV_BYPASS_AUTH=1
DEV_BYPASS_AUTH=1
```
- `VITE_DEV_BYPASS_AUTH=1`:前端直接构造“本地开发”全权限用户,仅在 Vite dev 模式生效。
- `DEV_BYPASS_AUTH=1`:后端认证中间件注入本地开发用户,否则前端虽然进入页面,API 仍会返回 401。
- 本地用户包含 `所有权限``BI-SCHEDULE-OPT``BI-ADMIN-FEEDBACK``BI-LEADER-ENERGY`
- 免登录只绕过认证,不会替代数据库、OneOS API、高德地图或 OSS 配置。
### 5.4 `.env` 配置模板
以下仅列变量名和占位符,不得把真实值写入本文或提交到 Git:
```dotenv
# 认证
EXTERNAL_API_BASE=https://<业务系统域名>
JWT_SECRET=<高强度随机密钥>
DEV_BYPASS_AUTH=1
VITE_DEV_BYPASS_AUTH=1
# 主业务 MySQL
DB_HOST=<host>
DB_PORT=3306
DB_USER=<user>
DB_PASSWORD=<password>
DB_NAME=<database>
# 氢能 MySQL;不填时复用 DB_*
HYDROGEN_DB_HOST=<host>
HYDROGEN_DB_PORT=3306
HYDROGEN_DB_USER=<user>
HYDROGEN_DB_PASSWORD=<password>
HYDROGEN_DB_NAME=<database>
# 历史里程 MySQL 连接配置
# 当前 src/server/mileage-db.ts 未被运行时代码引用;不要误认为修改后会影响里程页面
MILEAGE_DB_HOST=<host>
MILEAGE_DB_PORT=3306
MILEAGE_DB_USER=<user>
MILEAGE_DB_PASSWORD=<password>
MILEAGE_DB_NAME=<database>
# 车辆位置 PostgreSQL
HEATMAP_DB_HOST=<host>
HEATMAP_DB_PORT=5432
HEATMAP_DB_USER=<read-only-user>
HEATMAP_DB_PASSWORD=<password>
HEATMAP_DB_NAME=<database>
HEATMAP_DB_SSL=false
# OneOS 里程 API
ONEOS_MILEAGE_API_BASE_URL=https://<api-host>
ONEOS_MILEAGE_API_KEY=<api-key>
ONEOS_MILEAGE_API_TIMEOUT_MS=20000
# 高德地图
AMAP_WEB_KEY=<web-key>
AMAP_SECURITY_JS_CODE=<security-code>
# 用户反馈截图 OSS
OSS_REGION=<region>
OSS_ENDPOINT=<endpoint>
OSS_BUCKET=<bucket>
OSS_ACCESS_KEY_ID=<access-key-id>
OSS_ACCESS_KEY_SECRET=<access-key-secret>
OSS_BASE_DIR=<directory>
# 运行参数
SERVER_PORT=3001
MILEAGE_REPORT_AUTO_ARCHIVE=0
```
本地调试建议将 `MILEAGE_REPORT_AUTO_ARCHIVE=0`,避免开发进程在 06:30 自动生成正式日报快照。
## 6. 技术栈
### 6.1 前端
| 分类 | 技术 |
| --- | --- |
| 语言 | TypeScript、TSX、CSS |
| 框架 | React 19 |
| 构建工具 | Vite 6 |
| 样式 | Tailwind CSS 4 + 模块专用 CSS |
| 图表 | Recharts 3;氢能原型包含自定义 DOM / CSS 图表 |
| 动效 | Motion for React |
| 图标 | Lucide React |
| Excel | SheetJS `xlsx` |
| 地图 | 高德地图 JS API |
| 路由 | 浏览器 Path + Hash 自研轻量路由,无 React Router |
### 6.2 后端
| 分类 | 技术 |
| --- | --- |
| 语言 / 运行时 | TypeScript、Node.js 22、ES Module |
| Web 框架 | Hono 4 + `@hono/node-server` |
| TypeScript 运行 | `tsx` |
| 认证 | `jsonwebtoken` |
| MySQL | `mysql2/promise` |
| PostgreSQL | `pg` |
| 对象存储 | `ali-oss` |
| 配置 | `dotenv` / 容器环境变量 |
| 测试 | Node.js 内置 Test Runner |
### 6.3 构建与部署
- `npm run lint`TypeScript 静态检查;
- `npm test`:运行 `src/**/*.test.ts`
- `npm run build`:生成 Vite `dist`
- `npm run start`Node 直接通过 `tsx` loader 启动服务;
- DockerNode 22 Alpine 多阶段构建;
- CIWoodpecker 执行安装、静态检查、测试、构建、Docker 镜像构建和 Harbor 推送;
- 镜像标签:`<分支名>-<package.json version>`
- 当前 Compose 使用 host 网络,容器服务端口由 `SERVER_PORT` 注入,现有部署样例为 `8111`
## 7. 数据对接与数据源映射
### 7.1 运行时数据源总表
| 数据源 / 对接方 | 连接方式 | 配置入口 | 主要数据 | 消费模块 |
| --- | --- | --- | --- | --- |
| 羚牛业务系统认证服务 | HTTPS API | `EXTERNAL_API_BASE` | jumpToken、用户、部门编码、角色 | 登录认证 |
| 主业务 MySQL | MySQL | `DB_*` | 车辆、合同、客户、部门、资产流转、电费、ETC、反馈、调度记录等 | 资产、里程关联信息、调度、电能、ETC、反馈 |
| 氢能业务库 | MySQL | `HYDROGEN_DB_*`;未配置则复用 `DB_*` | 加氢流水、加氢站、付款 / 结算、站点余额等 | 氢能 BI、单站、按日、下钻 |
| 里程考核数据 | 主业务 MySQL / 跨 Schema 查询 | 当前实际使用 `DB_*` | 考核目标、考核车辆、日报快照 | 里程统计、日报、智能调度 |
| OneOS 里程服务 | HTTPS API | `ONEOS_MILEAGE_API_*` | 按日 / 区间车辆里程、来源协议 | 里程监控、日报、调度 |
| 车辆位置分析库 | PostgreSQL 只读 | `HEATMAP_DB_*` | 每车每日首个有效定位点 | 车辆热力图 |
| 高德地图 | 浏览器 JS API | `AMAP_WEB_KEY``AMAP_SECURITY_JS_CODE` | 地图底图、空间展示 | 车辆 / 加氢热力图 |
| 阿里云 OSS | OSS SDK | `OSS_*` | 用户反馈截图 | 反馈提交、反馈管理 |
| 本地地区映射 | JSON | `src/server/routes/mileage/region-map.json` | 城市到运营区域映射 | 里程筛选和区域聚合 |
`src/server/mileage-db.ts` 仍定义了一组 `MILEAGE_DB_*` MySQL 连接,但当前没有任何运行时代码导入该连接池。因此它属于历史遗留配置,不计入当前有效运行时数据源;接手人不应通过修改 `MILEAGE_DB_*` 来排查现有里程页面。
### 7.2 主业务 MySQL 的主要表 / Schema
当前部署样例中 `DB_NAME` 指向业务库,同时代码存在 `lingniu_prod.*` 跨 Schema 查询。数据库账号必须具备实际所需 Schema 的最小权限。
| 数据域 | 代表表 | 用途 |
| --- | --- | --- |
| 车辆主数据 | `vehicle_info``vehicle_status``vehicle_model` | 资产、车辆归属、车型、运营状态 |
| 租赁 / 客户 | `vehicle_lease_order_record``vehicle_lease_contract_info``customer_info` | 客户、部门、经理、项目 |
| 资产流转 | `delivery_vehicle``return_vehicle_task``vehicle_replacement` | 交车、退车、换车周统计及明细 |
| 实时车辆 | `tab_truck_remote_sync_realtime_info` | 当日里程、车辆省份 |
| 里程考核 | `lingniu_prod.tab_mileage_assessment_target``lingniu_prod.tab_mileage_assessment_vehicle` | 目标、车辆、完成率 |
| 里程日报 | `lingniu_prod.tab_mileage_daily_report` | 每日归档快照 |
| 电费 | `bi_ele_charge_record` | XLSX 导入后的充电记录 |
| ETC | `etc_toll_record``energy_etc_bill` | 通行明细、应收和已收 |
| 调度 | `tab_scheduling_notifications` | 调度干预、执行状态和历史 |
| 用户反馈 | `bi_user_feedback` | 反馈、截图地址、回复和状态 |
### 7.3 氢能 BI 主要数据表
| 表 | 用途 |
| --- | --- |
| `hydrogen_fuel_ledger` | 加氢主流水,统计加氢量、成本、对客金额、车辆、客户、核对状态和数据来源 |
| `hydrogen_station` | 历史加氢站主数据 |
| `new_hydrogen_site` | 当前业务站点主数据及省市区等属性 |
| `hydrogen_station_payment` | 加氢站付款 / 结算记录 |
| `new_hydrogen_site_balance_record` | 站点预充值余额记录 |
| `tab_outside_hydrogen_site` | 外部站点与内部站点、坐标的映射,用于热力图 |
| `common_district` | 行政区划名称映射 |
氢能 V2 API 统一提供总览、按日树、下钻等视图。旧 `/hydrogen/*` 接口仍保留用于兼容和回滚,但当前氢能页面入口只装配 `hydrogen-bi-v2/PrototypeBoard.tsx`
### 7.4 数据口径注意事项
- 氢能“车辆归属”和“成本承担方”是两个独立维度,不得互相替代。
- 氢能成本承担方按业务字段归纳为“我司承担、客户承担、其他”;利润口径是客户承担订单的“对客总价 - 等量订单成本总价”。
- 电能导入按 `order_no` 去重,并根据车辆信息匹配内部 / 外部 / 未知车辆。
- ETC 金额、应收和已收直接读取台账,不做推算;没有数据时前端展示空状态。
- 里程按自然日和上海时区处理;OneOS API 支持单日和区间查询,并按协议优先级归一化来源。
- 客户名称会根据数据权限在服务端进行脱敏。
## 8. 当前功能清单
### 8.1 资产管理 BI
| 功能域 | 当前能力 |
| --- | --- |
| 资产总览 | 总资产、运营、库存、待交付、周交车 / 退车 / 换车 |
| 车型分析 | 按车辆类型、车型、批次逐级统计和展开 |
| 部门分析 | 按部门 / 经理统计、展开车辆明细 |
| 区域分析 | 按大区、省市、客户筛选与统计 |
| 客户分析 | 客户多选、品牌、部门、经理、区域筛选 |
| 库存分析 | 按区域 / 车型切换,支持多级展开和筛选 |
| 资产流转 | 自定义日期范围,交车 / 退车 / 换车趋势与明细下钻 |
| 车辆明细 | 车牌、车型、批次、客户、状态、位置等筛选和弹窗 |
| 导出 | 资产相关列表和明细 Excel 导出 |
| 自动刷新 | 主页面数据每 60 秒刷新 |
### 8.2 里程管理 BI
| 子页面 | 当前能力 |
| --- | --- |
| 实时监控 | 当日 / 指定日 / 区间里程,来源协议优先级、部门、客户、项目、主体、状态、区域、品牌、里程区间等筛选 |
| 实时监控 | 当日 / 累计 / 统计时间排序,异常和在线状态识别,车辆详情 |
| 统计报表 | 考核目标、目标车辆、累计完成率、当年完成率、日均要求、趋势下钻 |
| 每日汇报 | 运营 / 库存车辆、当日里程、环比、车型与地区分组、近 7 日趋势、历史快照 |
| 导出 | 监控区间和车辆汇总 Excel 导出 |
| 缓存 | 服务启动立即刷新,之后每分钟刷新实时监控缓存 |
| 自动归档 | 上海时间每天 06:30 归档上一自然日日报,可通过环境变量关闭 |
### 8.3 智能调度
| 功能 | 当前能力 |
| --- | --- |
| 建议生成 | 根据考核里程、剩余天数、车辆状态和车型生成高低里程调度建议 |
| 筛选 | 按建议类型、部门、车型等条件筛选和搜索 |
| 详情 | 查看当前车辆、候选车辆及差距 |
| 操作 | 单条 / 批量登记调度干预 |
| 历史 | 通知 / 干预记录查询、状态更新、取消或完成 |
| 导出 | 建议列表 CSV 导出 |
### 8.4 车辆热力图
- 按日期范围、车牌 / VIN、考核批次筛选;
- 定位活跃度 / 车辆覆盖度切换;
- 网格聚合、全国概览、区域下钻和附近车辆;
- 地图和详情面板可独立展开,支持全屏;
- PostgreSQL 连接配置为只读事务默认值。
### 8.5 加氢热力图
- 按日期、站点搜索、承担维度筛选;
- 加氢量、加氢频次、车辆覆盖三种热力指标;
- 加氢站排名、区域下钻、附近站点和 GPS 覆盖提示;
- 仅对有有效坐标的数据进行地图聚合;
- 受能源角色控制。
### 8.6 氢能经营 BI(当前 V2 页面)
| 页面 / 维度 | 当前能力 |
| --- | --- |
| 全局总览 | 年份、核对状态、车辆归属筛选;累计加氢量、累计成本、利润、本月、本日 KPI |
| 趋势 | 月度加氢量、月度收支、站点 Top5、区域省 / 市占比 |
| 汇总 | 加氢站汇总、客户账单汇总、列表折叠、排序和筛选 |
| KPI 下钻 | 支持先站点后客户或先客户后站点,再到车辆 / 流水 |
| 图表下钻 | 月份、站点、区域、客户等上下文穿透 |
| 账单下钻 | 客户 → 日期 → 车辆流水;站点 → 日期 → 客户 / 流水 |
| 按日页面 | 日期范围、车辆归属、核对状态;日期 → 站点 → 客户 → 车辆 / 数据源多层展开 |
| 单站页面 | 站点日期筛选、日汇总、趋势、流水和导出 |
| 导出 | KPI 穿透、客户账单、站点账单、每日明细 Excel |
| 接口 | `/api/energy/h2/v2/meta``overview``daily``daily-tree``drill` |
说明:原型源码中仍有演示常量和兼容组件;真实运行数据应以 `prototype-adapter.ts``prototype-real-daily.tsx``prototype-real-drills.tsx` 和 V2 API 返回为准。修改页面时必须同时检查 Web 与移动端,不能只以测试通过代替原型截图验收。
### 8.7 电能经营 BI
| 页面 / 能力 | 当前实现 |
| --- | --- |
| 按日 | 日充电量、费用、订单、内部 / 外部车辆和趋势 |
| 总览 | 汇总 KPI、月份趋势、车辆归属统计 |
| 数据导入 | 隐藏入口上传 `.xlsx`,按订单号去重、批次管理、记录搜索和归属汇总 |
| 数据表 | `bi_ele_charge_record`,接口首次调用时自动确保建表 |
### 8.8 ETC 看板
- 通行明细笔数;
- 涉及车辆去重数;
- 通行费金额;
- ETC 账单数、应收和已收;
- 最新通行时间;
- 无台账时展示“暂无数据”,不生成模拟数据。
### 8.9 用户反馈闭环
- 普通用户提交新维度、Bug、体验或其他反馈;
- 支持最多 6 张截图,单张最大 5 MB,上传至 OSS;
- 用户查看自己的反馈历史;
- 管理员按状态查看、回复和更新为待处理 / 处理中 / 已完成 / 已忽略;
- 反馈表由接口首次调用时自动创建并兼容补列。
## 9. API 路由概览
| 路由前缀 | 用途 |
| --- | --- |
| `/api/auth` | jumpToken 换 JWT、查看当前 JWT 用户 |
| `/api/vehicles` | 资产总览、车型 / 部门 / 区域 / 客户 / 库存、流转、车辆明细 |
| `/api/mileage` | 实时监控、目标、趋势、车辆近期里程、日报及历史 |
| `/api/scheduling` | 调度建议、通知和执行记录 |
| `/api/energy` | 氢能 V2 / 兼容接口、电能经营、ETC |
| `/api/ele` | 电费 XLSX 导入、列表、批次、聚合 |
| `/api/vehicle-heatmap` | 车辆位置热力图配置、元数据、点位和附近车辆 |
| `/api/hydrogen-heatmap` | 加氢热力图配置、元数据、点位和附近站点 |
| `/api/feedback` | 反馈提交、截图上传、我的反馈、管理列表和更新 |
## 10. 后台任务与自动建表
### 10.1 服务启动动作
`src/server/bootstrap.ts` 在正式服务进程启动时执行:
1. 确保 `tab_scheduling_notifications` 存在;
2. 立即刷新里程监控缓存;
3. 每 60 秒刷新里程监控缓存;
4. 启动里程日报 06:30 自动归档调度器。
### 10.2 按需自动建表
以下接口会在首次使用时执行 `CREATE TABLE IF NOT EXISTS`
- 电费:`bi_ele_charge_record`
- 用户反馈:`bi_user_feedback`
- 里程日报:`lingniu_prod.tab_mileage_daily_report`
- 智能调度:`tab_scheduling_notifications`(服务启动时)。
生产数据库账号若严格只读,这些功能会启动或调用失败。交接时应明确“读数据账号”和“应用写表账号”的权限边界。
## 11. 开发、测试和发布流程
### 11.1 常用命令
```bash
# 安装锁定依赖
npm ci
# 前后端开发模式
npm run dev
# 仅后端 / 仅前端
npm run dev:server
npm run dev:client
# 质量检查
npm run lint
npm test
npm run build
# 生产式本地启动(需先 build
npm run start
```
### 11.2 发版步骤
1. 确认工作区只包含本次变更,排除 `.DS_Store`、临时脚本和本地数据文件。
2. 执行 `npm run lint && npm test && npm run build`
3. 按语义版本更新 `package.json``package-lock.json`
4. 提交并推送到目标分支。
5. Woodpecker 根据分支和版本生成镜像标签并推送 Harbor。
6. 在 Portainer / Compose 中更新镜像版本,保留原镜像标签用于回滚。
7. 验证健康检查、登录跳转、关键 API、Web 页面和移动端页面。
版本只在 Git 中更新不代表已部署;必须分别确认:
- 代码已推送;
- CI 已通过;
- 镜像已推送;
- 部署已更新;
- 页面和数据已验收。
## 12. 日志与排障入口
| 现象 | 优先检查 |
| --- | --- |
| 无法从业务系统登录 | 浏览器 URL 是否有 `jumpToken``/api/auth/exchange` 响应;`EXTERNAL_API_BASE`;后端认证日志 |
| 页面能进但 API 401 | 前后端免登录是否同时开启;`bi_jwt` 是否存在;JWT_SECRET 是否一致 |
| 页面 403 | 业务系统角色、JWT 中 `roles`、模块白名单,不要只看 `permissionLevel` |
| 健康检查正常但页面无数据 | `/api/health` 不检查数据库;继续检查具体 API、数据库连接和 SQL 权限 |
| 里程无数据 | OneOS API 地址 / Key、网络、traceId、协议兼容降级日志、主库车辆关联信息 |
| 热力图无底图 | 高德 Key 和安全码、域名白名单、浏览器控制台 |
| 车辆热力图无点位 | PostgreSQL 连接、只读权限、日期范围、`is_heatmap_eligible` |
| 氢能数据慢 | V2 API 查询耗时、筛选范围、数据库索引、是否误走兼容接口 |
| 反馈图片失败 | OSS 配置、Bucket 权限、文件类型和 5 MB 限制 |
| 日报未归档 | `MILEAGE_REPORT_AUTO_ARCHIVE`、容器时区、06:30 日志、日报表写权限 |
## 13. 已知风险与优先整改项
### P0:凭据管理
1. 当前仓库的部署配置和部分数据库连接代码存在硬编码凭据或默认口令。交接后应立即:
- 将数据库、JWT、Harbor、OSS、API Key 全部迁移到 CI Secret / Portainer Secret / 环境变量;
- 删除代码和配置中的真实 fallback;
- 对已经进入 Git 历史的凭据执行轮换;
- 不要只删除当前文件内容而忽略历史提交。
2. `Dockerfile` 和认证代码存在可预测的 JWT 默认密钥。生产必须显式注入随机 `JWT_SECRET`,并在缺失时让应用拒绝启动。
### P1:认证与权限
1. 前端当前用公开的 `/api/health` 判断缓存 JWT 是否有效,因此该请求并未真正校验 Token;过期 Token 会在第一次访问受保护 API 时才被清理。应改为调用 `/api/auth/me`
2. `jumpToken` 通过查询参数传递,可能进入代理访问日志;业务条件允许时应改为 POST body,并限制日志记录。
3. `cors()` 当前未限制来源;生产应配置允许域名白名单。
4. `full` 数据角色与模块角色不是同一套白名单,容易产生“能看全量数据但进不了模块”的误解。变更角色策略前应由产品 / 管理员确认。
5. 隐藏页面不是授权控制。充电导入接口目前仅依赖“已登录”,若属于管理能力,应补独立角色和后端守卫。
### P1:数据与运行稳定性
1. 主库、跨 Schema 查询和专用数据库连接并存,部署账号权限和 Schema 默认值需形成正式清单。
2. `src/server/mileage-db.ts` 是当前未被引用的历史连接文件,`docker-compose.yml` 仍保留相应变量,容易误导排障;确认无外部依赖后应删除或重新接入。
3. 应用启动和部分接口会自动建表;数据库权限收紧前需先迁移为正式数据库变更脚本。
4. 氢能兼容接口和 V2 接口同时存在,新增功能应明确修改当前 V2 路径,避免只修旧接口。
5. 氢能原型目录仍包含演示常量;新增展示必须确认数据来自真实 API,禁止把演示数据带入经营口径。
6. 车辆和加氢热力图的默认日期目前为代码常量,长期运行应改为根据数据水位或当前日期计算。
### P2:工程治理
1. 仓库缺少统一根 README,本交接文档可作为后续 README 的基础。
2. 本地 `.DS_Store` 容易进入工作区,建议补充全局 / 项目忽略规则。
3. 目前使用 Path + Hash 自研路由;新增页面时必须同时验证直达、刷新、前进后退和移动端底栏状态。
4. 氢能页面属于严格原型复刻范围,CSS / DOM 调整必须做桌面与移动端截图对比验收。
## 14. 交接资料与权限清单
以下内容不应写入 Git,需要由原负责人通过安全渠道单独移交:
- Gitea 项目成员权限及分支保护规则;
- Woodpecker 项目、流水线和 Secret 管理权限;
- Harbor 项目及镜像拉取 / 推送账号;
- Portainer / ECS / Docker 服务管理权限;
- 主业务、氢能、里程和位置数据库的只读 / 读写账号;
- OneOS 里程 API 地址、API Key、调用方白名单和联系人;
- 羚牛业务系统认证接口联系人和 jumpToken 协议;
- 高德地图 Key、安全码及域名白名单;
- OSS Bucket、RAM 用户和目录权限;
- 生产域名、反向代理、证书和 DNS 管理权限;
- 数据口径负责人:资产、里程、调度、氢能、电能、ETC 各一名。
## 15. 接手验收清单
### 15.1 代码与环境
- [ ] 能拉取 `main` 并确认目标版本;
- [ ] Node.js 22、`npm ci`、lint、test、build 均通过;
- [ ] 已获得不含明文传播的本地 / 测试环境变量;
- [ ] 本地前后端和免登录调试正常;
- [ ] `.env`、数据库导出、截图和临时文件不会进入 Git。
### 15.2 认证与权限
- [ ] 从业务系统 jumpToken 跳转登录成功;
- [ ] 普通、部门、全量三类数据权限分别验收;
- [ ] 能源、智能调度、反馈管理员角色分别验收;
- [ ] 无权限用户前端不可见且后端返回 403;
- [ ] Token 过期后能正确退出并重新登录。
### 15.3 数据与功能
- [ ] 资产、里程、调度、氢能、电能、ETC 的主要 API 都能访问真实数据;
- [ ] BI 汇总值与最小粒度明细守恒;
- [ ] 氢能成本承担方、对客金额、成本金额和利润口径已由业务负责人确认;
- [ ] 里程 OneOS API 的单日、区间和来源协议正常;
- [ ] 车辆 / 加氢热力图可加载地图、点位和详情;
- [ ] Excel / CSV 导入导出可用;
- [ ] 用户反馈和截图上传可用;
- [ ] 06:30 日报归档在测试环境完成一次验证。
### 15.4 发布与回滚
- [ ] Woodpecker 流水线通过;
- [ ] Harbor 中存在对应版本镜像;
- [ ] Portainer / Compose 使用目标镜像标签和正确 Secret;
- [ ] 部署后验证健康检查、登录、API、Web 和移动端;
- [ ] 记录上一稳定镜像标签、数据库变更和回滚步骤。
## 16. 维护原则
1. **先确认真实数据口径,再改页面**:经营指标必须能从汇总下钻到真实流水。
2. **前端隐藏不代替后端鉴权**:任何管理或敏感能力都要有 API 守卫。
3. **配置与凭据分离**:代码只保留变量名和安全默认行为,不保留真实密码。
4. **原型页面以截图验收**:氢能模块必须对照原型验证布局、样式、交互、下钻层级和移动端。
5. **变更必须可回滚**:发版保留旧镜像,数据库变更先备份、再迁移、再核对。
6. **区分可达与可用**:健康检查正常不代表数据库、外部 API 和经营数据正常。
-13
View File
@@ -1,13 +0,0 @@
# 本地只读预览
使用 Node.js 22+ 和 npm,在本项目内执行 `npm ci`,避免依赖其他工作目录的 node_modules 软链接。
`.env.local.example` 复制为 `.env.local` 并填写连接配置,或通过进程环境变量提供配置。使用数据库侧只读账号;不要提交真实凭据。
执行 `npm run dev:local`,浏览器访问 http://127.0.0.1:8115/energy#hydrogen 。前后端固定绑定本机 8115 / 3001;端口占用时退出,不自动换端口。停止终端进程即可停止服务;此命令不配置开机自启。
本地入口关闭 mock、启用本地免登录,禁用后台建表与定时归档,并拒绝 API 的 POST / PUT / PATCH / DELETE。请勿把免登录预览暴露到公网。氢能查询启用只读 SQL 检查;这不能替代数据库账号的只读权限。
`npm run dev` / `npm start` 保留原部署方式;设置 `DB_READ_ONLY=1` 时同样不启动后台任务,且 API 禁止写入。生产登录流程需要写请求,因此不要把本地只读模式当作生产部署配置。
验证:`npm test``npm run lint``npm run build`;健康检查为 `GET http://127.0.0.1:3001/api/health`。健康检查成功仅代表进程可用,还需检查氢能 meta / overview 的真实数据响应。
BIN
View File
Binary file not shown.
@@ -43,7 +43,7 @@ const mileagePool = mysql.createPool({
host: '101.133.130.65', host: '101.133.130.65',
port: 3306, port: 3306,
user: 'bi_reader_02', user: 'bi_reader_02',
password: process.env.MILEAGE_DB_PASSWORD, password: 'bi_reader_02_Pass',
database: 'hydrogen_energy', database: 'hydrogen_energy',
waitForConnections: true, waitForConnections: true,
connectionLimit: 5, connectionLimit: 5,
@@ -20,7 +20,7 @@
### 数据库 2hydrogen_energy(新增连接) ### 数据库 2hydrogen_energy(新增连接)
- 连接信息:`101.133.130.65:3306`,用户 `bi_reader_02`,密码 `<见环境变量 MILEAGE_DB_PASSWORD>`,库名 `hydrogen_energy` - 连接信息:`101.133.130.65:3306`,用户 `bi_reader_02`,密码 `bi_reader_02_Pass`,库名 `hydrogen_energy`
- `v_vehicle_daily_stats` — 1004 辆车的每日里程明细(plate, vin, stat_date, daily_km, total_km, day_hydrogen, daily_run_secs, source - `v_vehicle_daily_stats` — 1004 辆车的每日里程明细(plate, vin, stat_date, daily_km, total_km, day_hydrogen, daily_run_secs, source
## 架构 ## 架构
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"version": "1.2.0", "version": "1.1.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ln-bi", "name": "ln-bi",
"version": "1.2.0", "version": "1.1.14",
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1", "@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0", "@hono/node-server": "^1.13.0",
+2 -5
View File
@@ -1,19 +1,16 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"private": true, "private": true,
"version": "1.2.0", "version": "1.1.14",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev:local": "concurrently -k -n server,client \"npm run dev:local:server\" \"npm run dev:local:client\"",
"dev:local:server": "node --import tsx src/server/local.ts",
"dev:local:client": "DEV_MOCK_API=0 VITE_DEV_BYPASS_AUTH=1 vite --host 127.0.0.1 --port 8115 --strictPort",
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
"dev:server": "tsx watch src/server/index.ts", "dev:server": "tsx watch src/server/index.ts",
"dev:client": "vite --port=3000 --host=0.0.0.0", "dev:client": "vite --port=3000 --host=0.0.0.0",
"build": "vite build", "build": "vite build",
"start": "node --import tsx src/server/index.ts", "start": "node --import tsx src/server/index.ts",
"lint": "tsc --noEmit", "lint": "tsc --noEmit",
"test": "node --import tsx --test $(find src -type f \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print | sort)", "test": "node --import tsx --test $(find src -type f -name '*.test.ts' -print | sort)",
"test:mileage-report": "node --import tsx --test src/server/routes/mileage/daily-report-model.test.ts" "test:mileage-report": "node --import tsx --test src/server/routes/mileage/daily-report-model.test.ts"
}, },
"dependencies": { "dependencies": {
-43
View File
@@ -1,43 +0,0 @@
<svg width="150" height="36" viewBox="0 0 150 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.0459 4.69253L24.4084 4.6875L17.3427 16.9334C16.0108 19.2369 14.67 21.5335 13.357 23.8492C12.8279 24.6836 12.7967 25.5367 14.0379 25.5663C17.6611 25.6526 21.3268 25.5356 24.9495 25.6042C24.4224 26.7246 23.2874 28.5534 22.6381 29.6706L21.6984 31.3061L13.8032 31.3074C11.2823 31.3084 7.42295 31.774 6.15339 28.9623C5.10923 26.6498 6.73472 24.2701 7.86608 22.3109L10.1604 18.3408L18.0459 4.69253Z" fill="#2F2828"/>
<path d="M30.7029 4.69293L38.1664 4.6931C40.8704 4.68656 45.5224 4.1044 46.4284 7.56236C47.0408 9.9001 45.3543 12.286 44.21 14.218C42.2951 14.125 39.809 14.1965 37.8457 14.1908C38.3321 13.3838 40.2747 10.8182 38.8199 10.5324C37.4531 10.2639 35.1947 10.4548 33.7653 10.4209C32.0072 13.3847 30.3349 16.4036 28.5844 19.3723C28.4964 19.5215 28.3602 19.7716 28.257 19.9038L21.9204 19.9023L30.7029 4.69293Z" fill="#2F2828"/>
<path d="M89.1232 4.67032C89.7835 4.62775 90.9099 4.66078 91.6053 4.65867L90.978 7.75816L98.7808 7.75999C98.6607 8.46499 98.5297 9.16816 98.3877 9.86921L90.5769 9.86774C90.1934 11.0614 89.853 13.7315 89.4731 15.1689L97.9522 15.1851C97.8321 15.8946 97.6872 16.5697 97.5364 17.2721C94.9175 17.1811 91.7297 17.2538 89.0954 17.2677C88.594 19.3603 88.1541 21.9308 87.7303 24.0635C86.9061 24.0335 86.0812 24.0264 85.2563 24.0421C85.7525 22.0904 86.18 19.3243 86.6177 17.2638L85.2372 17.257L77.0092 17.2648C77.1446 16.5758 77.2727 15.8857 77.3957 15.1944C79.8624 15.1112 82.3687 15.2077 84.839 15.1714C85.5417 15.161 86.2942 15.1588 86.9939 15.2057C87.447 13.5537 87.6608 11.5742 88.1087 9.87872C86.2986 9.82772 84.3142 9.86591 82.4909 9.86569C81.7604 11.226 81.0028 12.4816 80.2657 13.8163L77.5362 13.8114C79.6816 10.4975 80.6808 8.44223 82.2311 4.86287C83.0545 4.83579 84.0075 4.85801 84.8405 4.85771C84.4152 5.82928 83.998 6.81499 83.512 7.75669C85.1516 7.77967 86.8351 7.76247 88.4769 7.76452C88.7163 6.8407 88.9417 5.61995 89.1232 4.67032Z" fill="#2F2828"/>
<path d="M70.2503 4.65824L73.7308 4.65625C74.7336 7.08713 76.1309 9.57515 77.5099 11.8054C76.5818 11.7752 75.527 11.798 74.5887 11.7967C73.5397 9.99962 72.6069 8.13727 71.7957 6.22117C71.2298 6.89947 70.7149 7.63591 70.1079 8.35496L72.1324 8.35415C72.359 9.56198 72.4332 11.2339 72.4619 12.4558L70.2398 12.4478C70.2372 11.0171 70.1331 9.90113 70.0025 8.48344C69.1539 9.57259 67.962 10.814 66.9868 11.7877L65.7182 11.7932L64.2958 11.7963C64.2202 12.4282 64.0755 13.0643 63.9444 13.6881L60.6789 13.6869C60.5363 14.5168 60.3766 15.3436 60.1998 16.1668L63.9171 16.1664L63.5281 18.0826C62.277 18.0555 60.9445 18.0784 59.687 18.0782C58.9474 20.2403 57.828 22.2532 56.3813 24.0224C55.6509 24.0768 54.3913 24.03 53.5917 24.0442C55.3706 21.7397 56.0946 20.7987 57.3125 18.0802L53.6008 18.0731C53.7608 17.396 53.882 16.8655 53.9824 16.1718C55.2848 16.1587 56.5873 16.1591 57.8898 16.1732L58.3652 13.6896L54.9231 13.6853C55.1139 13.0172 55.1958 12.5027 55.3015 11.8164C56.2519 11.7504 57.7795 11.7881 58.7587 11.8109C58.8945 11.0981 59.037 10.3867 59.1861 9.67657C57.9106 9.6642 56.6351 9.66427 55.3597 9.67657C55.4544 9.05996 55.5544 8.44415 55.66 7.8293C57.5354 7.80764 59.4484 7.82827 61.3266 7.83003C61.9933 6.86852 62.6551 5.74668 63.0825 4.66341L65.5379 4.65888C64.9966 5.88817 64.5907 6.67983 63.9019 7.83288L65.5427 7.82645C65.4452 8.4301 65.2987 9.07927 65.1744 9.68205C63.9717 9.64854 62.7013 9.67152 61.4934 9.67532C61.3775 10.3714 61.2146 11.1007 61.0712 11.7947L63.9987 11.7913C66.5788 9.20798 68.0943 7.61586 70.2503 4.65824Z" fill="#2F2828"/>
<path d="M122.532 11.8166C125.798 11.7231 129.441 11.7977 132.734 11.7984C132.466 13.5695 131.978 15.624 131.64 17.4038C131.42 18.5691 130.997 20.9151 130.604 21.9327C130.313 22.7085 129.869 23.4177 129.299 24.0182C128.475 24.0895 127.218 24.0195 126.289 24.0612C126.736 23.701 127.014 23.4316 127.42 23.0228C128.176 22.1519 128.359 21.6669 128.744 20.5722C126.97 20.5048 124.822 20.5601 123.018 20.5584C122.945 21.3158 122.507 23.1882 122.339 24.0641C121.646 24.031 120.793 24.0186 120.111 24.0547C120.247 23.0506 120.522 21.8389 120.724 20.8256L121.799 15.4428C121.945 14.7076 122.299 12.3722 122.532 11.8166ZM129.104 18.8694C129.21 18.2708 129.304 17.7024 129.465 17.1139C127.646 17.0946 125.48 17.1746 123.726 17.1053C123.609 17.6915 123.487 18.2767 123.359 18.8607C125.251 18.8633 127.22 18.8364 129.104 18.8694ZM124.059 15.3809C124.989 15.3848 129.088 15.5282 129.805 15.3507C129.899 14.7602 130.003 14.261 130.135 13.6774C128.223 13.6522 126.311 13.6515 124.399 13.6752C124.302 14.1834 124.187 14.895 124.059 15.3809Z" fill="#2F2828"/>
<path d="M28.257 19.9042C30.3408 19.8373 32.5067 19.9107 34.6003 19.8799C33.5304 21.7989 32.4375 23.705 31.3217 25.5977L37.6027 25.587C37.1076 26.6547 36.2454 28.0061 35.655 29.0623C35.3804 29.5534 34.6405 30.8847 34.3403 31.3077L28.0049 31.3022C28.8125 29.6956 29.8532 28.1298 30.7172 26.5471C30.8889 26.2323 31.0858 25.9059 31.2849 25.6084C29.1922 25.6072 27.049 25.5816 24.9604 25.61C25.6026 24.4466 26.26 23.2918 26.9324 22.1456C27.3144 21.4843 27.8407 20.5151 28.257 19.9042Z" fill="#007143"/>
<path d="M99.7206 10.1076C106.203 9.98573 113.088 10.0971 119.599 10.1018C118.777 13.8389 118.005 17.7955 117.477 21.5875C117.37 22.3581 118.045 23.5899 118.033 24.042C117.125 24.0338 116.218 24.0361 115.311 24.0488C114.92 21.1109 115.519 19.0866 116.068 16.2425C116.357 14.7439 116.652 13.209 116.981 11.7202L99.4205 11.7137C99.5633 11.1642 99.6343 10.6691 99.7206 10.1076Z" fill="#2F2828"/>
<path d="M64.5255 12.945C68.4291 12.8445 72.7488 12.9376 76.6835 12.9394C76.5327 13.651 76.4317 14.5237 75.9691 15.0919C75.4018 15.7882 74.7928 16.4582 74.197 17.1312L70.7419 21.0305L69.7438 21.059C70.2176 22.0474 70.6693 23.0462 71.0985 24.0548C70.1752 24.0352 69.2515 24.0344 68.3282 24.0525C67.9964 23.2947 67.6583 22.5397 67.3138 21.7875C66.4548 19.874 65.5159 17.9972 64.5 16.1621C65.3591 16.1484 66.2391 16.156 67.1001 16.1542C67.8294 17.3343 68.5019 18.5485 69.1155 19.7926C70.6881 18.1842 72.2203 16.5366 73.7103 14.8514L64.1427 14.8496C64.2745 14.2155 64.402 13.5807 64.5255 12.945Z" fill="#2F2828"/>
<path d="M100.218 12.4635C104.946 12.4535 109.674 12.4727 114.401 12.5212C114.273 12.9715 114.112 13.3776 113.946 13.814C112.657 14.4639 111.179 15.1061 109.857 15.7056C111.599 15.949 113.275 15.9778 115.027 16.028C114.844 16.6716 114.645 17.3102 114.427 17.943C112.64 17.957 110.455 17.6924 108.705 17.3225C108.285 17.2407 107.508 16.991 107.072 16.8654C104.084 18.0184 101.851 17.949 98.7192 17.9316C98.791 17.2822 98.9052 16.6738 99.0208 16.0327C102.633 15.9161 105.599 15.8462 108.933 14.123C106.178 14.2266 102.668 14.1367 99.8604 14.135C99.9731 13.5764 100.092 13.0192 100.218 12.4635Z" fill="#2F2828"/>
<path d="M98.7625 18.3504C100.425 18.3036 102.282 18.3451 103.963 18.3452L114.077 18.3459C113.979 18.982 113.891 19.4244 113.733 20.0481C111.681 19.9793 109.274 20.0318 107.196 20.0488C107.14 20.5854 106.873 21.7822 106.759 22.3601C108.872 22.2731 111.751 22.3361 113.883 22.3568C113.718 22.9049 113.8 23.5534 113.504 23.9553C113.406 24.0871 113.154 24.042 112.96 24.0414C107.731 23.9823 102.283 24.0035 97.0497 24.0421C97.15 23.4629 97.2678 22.9333 97.3996 22.3599C99.6372 22.3158 102.265 22.3081 104.496 22.3613C104.62 21.6 104.787 20.8055 104.936 20.0457C102.829 19.9936 100.54 20.0389 98.4199 20.0394L98.7625 18.3504Z" fill="#2F2828"/>
<path d="M133.256 13.9166C133.997 13.9023 134.788 13.9161 135.533 13.9168C135.386 14.8072 135.174 15.7896 135.001 16.6831C137.384 16.4394 139.769 16.2153 142.155 16.0109C141.98 16.6792 141.965 17.2281 141.716 17.9362C141.421 17.9321 141.131 17.9513 140.839 17.9823C138.756 18.2028 136.663 18.3336 134.585 18.5982C134.457 19.6086 134.11 21.1394 133.89 22.1662L135.596 22.1591H140.893C140.792 22.7924 140.684 23.4247 140.569 24.0557C137.586 23.9698 134.244 24.0318 131.246 24.0396C131.9 20.9278 132.738 17.0234 133.256 13.9166Z" fill="#2F2828"/>
<path d="M135.098 4.62931C135.853 4.62166 136.607 4.62419 137.363 4.6369C137.162 5.41299 137.017 6.28154 136.868 7.07464C139.102 6.84738 141.908 6.69114 144.053 6.37755C143.943 7.01342 143.835 7.73358 143.657 8.34683L136.465 8.98722C136.314 9.81163 136.157 10.6349 135.994 11.4569C136.445 11.4309 137.106 11.4499 137.573 11.4482C139.397 11.4591 141.222 11.4559 143.046 11.4386C142.935 12.1076 142.839 12.6758 142.674 13.3374C139.624 13.2772 136.387 13.3227 133.323 13.3214C133.552 12.5671 133.793 11.1594 133.955 10.3413C134.324 8.43507 134.706 6.53103 135.098 4.62931Z" fill="#2F2828"/>
<path d="M102.916 4.63357C103.716 4.61473 104.573 4.6323 105.378 4.63596L104.934 5.48128L121.832 5.48318C121.739 6.10161 121.672 6.52446 121.507 7.12456C119.818 7.05448 117.769 7.10604 116.053 7.10625L103.984 7.11772C103.38 7.9858 102.762 8.89904 102.054 9.68109C101.302 9.67604 100.124 9.63799 99.419 9.71446C99.8816 9.06719 100.543 8.33907 101.04 7.66743C101.837 6.59222 102.296 5.78928 102.916 4.63357Z" fill="#2F2828"/>
<path d="M126 4.67433C126.546 4.62029 128.007 4.65657 128.619 4.65502C127.72 6.37437 126.984 7.53498 125.875 9.09798C127.492 9.05503 129.258 9.08605 130.886 9.08474C130.822 8.136 130.773 7.13368 130.53 6.2156C131.306 6.15771 132.166 6.17288 132.949 6.17043C133.021 7.06163 133.221 8.13834 133.316 9.09988C133.309 9.63037 133.157 10.3841 133.057 10.9028C129.465 10.8026 125.423 10.8945 121.795 10.8945C123.602 8.78078 124.778 7.14823 126 4.67433Z" fill="#2F2828"/>
<path d="M37.8456 14.1928C39.809 14.1984 42.2951 14.127 44.2099 14.22L41.9937 18.0919C41.7788 18.4647 41.1512 19.6063 40.9155 19.9025L34.5791 19.8957C35.6336 17.9833 36.7911 16.1017 37.8456 14.1928Z" fill="#007143"/>
<path d="M40.9155 19.902C43.0195 19.8499 45.1502 19.9224 47.2506 19.8789C46.4721 21.4168 44.8928 24.105 43.9715 25.602L37.6138 25.6077C38.4831 23.9831 39.9311 21.447 40.9155 19.902Z" fill="#007143"/>
<path d="M104.415 7.76391L120.496 7.76172C120.35 8.3135 120.238 8.87355 120.16 9.43887C118.084 9.37345 115.82 9.41552 113.733 9.4153L104.085 9.42408C104.155 8.93055 104.312 8.26469 104.415 7.76391Z" fill="#2F2828"/>
<path d="M96.4304 26.879C99.2507 26.6667 98.9579 30.7714 95.9312 31.3474C92.5488 31.3098 93.6973 27.1881 96.4304 26.879ZM95.9883 30.5709C96.9304 30.2786 97.7955 29.3384 97.4662 28.3229C97.3944 28.096 97.2327 27.9087 97.0175 27.8056C96.7964 27.6975 96.4978 27.6448 96.2518 27.6614C95.3083 27.9393 94.49 28.8604 94.7842 29.8668C94.8501 30.099 95.0075 30.2944 95.2205 30.4079C95.4752 30.5427 95.7065 30.5599 95.9883 30.5709Z" fill="#2F2828"/>
<path d="M120.579 26.8756C123.424 26.7179 123.034 30.7691 120.086 31.3456C116.806 31.3978 117.733 27.1994 120.579 26.8756ZM120.22 30.5368C122.019 29.9774 122.223 27.4751 120.314 27.6674C119.642 27.9156 119.439 28.0285 119.093 28.7081C118.616 29.6484 119.009 30.7721 120.22 30.5368Z" fill="#2F2828"/>
<path d="M116.66 26.9238L117.992 26.9219C117.747 28.2834 117.347 29.9417 117.049 31.3153L116.157 31.3083C116.347 30.6023 116.495 29.7902 116.644 29.0672C116.688 28.9313 116.767 28.502 116.801 28.3408C116.395 29.0335 115.459 30.9511 114.675 31.3444C114.566 31.399 114.465 31.3038 114.371 31.2359C114.165 30.71 113.925 28.8772 113.836 28.242L113.198 31.3117L112.31 31.3165L113.23 26.9279L114.491 26.9225C114.605 27.7811 114.833 28.7942 114.973 29.6896C115.472 28.9993 116.215 27.6823 116.66 26.9238Z" fill="#2F2828"/>
<path d="M86.5921 26.9211C86.9917 26.9147 87.4287 26.9264 87.8225 26.9095C90.4759 26.7956 90.1867 29.6754 88.5223 30.9111C87.5305 31.4374 86.7699 31.3173 85.6661 31.3024C85.7957 30.2878 86.3578 28.007 86.5921 26.9211ZM86.7231 30.4522C87.1359 30.4503 87.7347 30.4871 88.1007 30.316C88.6401 29.8127 89.3033 28.7778 88.6848 28.0956C88.3158 27.6887 87.7976 27.7551 87.2911 27.7533C87.1257 28.5612 86.9295 29.669 86.7231 30.4522Z" fill="#2F2828"/>
<path d="M123.457 26.9226C124.213 26.9143 125.634 26.8007 126.186 27.3128C126.71 27.7978 125.896 28.5423 125.68 28.9376C125.595 29.0947 126.087 29.5814 126.078 29.8632C126.065 30.2604 125.746 30.6635 125.492 30.9391C124.737 31.4476 123.501 31.3147 122.564 31.3117C122.81 29.8751 123.187 28.3774 123.457 26.9226ZM123.593 30.5161C124.141 30.5116 124.358 30.5255 124.891 30.3884C125.067 30.151 125.129 30.0825 125.184 29.7933C125.024 29.2759 124.325 29.3801 123.862 29.3789L123.593 30.5161ZM123.999 28.6156C124.282 28.6098 124.866 28.6145 125.123 28.5796C125.245 28.3715 125.336 28.252 125.328 28.0082C125.082 27.6121 124.658 27.6901 124.214 27.6711C124.144 27.9762 124.058 28.3111 123.999 28.6156Z" fill="#2F2828"/>
<path d="M90.735 26.9215C92.024 26.9243 94.8238 26.5442 93.2493 29.0441C93.0876 29.3009 92.7831 29.446 92.5137 29.583C92.7384 30.1413 92.9478 30.7419 93.1556 31.3095C92.8314 31.3198 92.3036 31.3998 92.0979 31.1551C91.9179 30.6395 91.7971 30.0956 91.3931 29.741C90.8792 29.6812 90.7592 30.8999 90.6765 31.3136L89.8201 31.3149C90.1114 29.8477 90.4166 28.3831 90.735 26.9215ZM91.2167 28.8258C91.4736 28.8227 92.2707 28.8268 92.4815 28.7911C92.6447 28.5511 92.7304 28.4484 92.7926 28.1652C92.6755 27.6152 91.9171 27.7496 91.4458 27.7535C91.366 28.1101 91.2891 28.4675 91.2167 28.8258Z" fill="#2F2828"/>
<path d="M69.3081 26.9222L70.1771 26.9141C69.9698 28.186 69.5337 30.0383 69.2388 31.3177L68.1065 31.3098C67.8398 30.3494 67.4692 29.2815 67.1696 28.3128C67.0189 29.2546 66.7757 30.3753 66.5616 31.3076L65.6787 31.3079L66.6075 26.9221L67.7409 26.9223C68.0639 27.8733 68.37 28.898 68.6717 29.8604C68.873 28.8787 69.0851 27.8992 69.3081 26.9222Z" fill="#2F2828"/>
<path d="M108.881 29.8868C109.027 29.2478 109.289 27.5001 109.54 27.0002C109.723 26.889 110.12 26.9136 110.352 26.9105C110.263 27.8288 109.679 30.3103 109.457 31.3037L108.341 31.3085C108.011 30.3531 107.719 29.367 107.384 28.4004L106.772 31.3134L105.9 31.3077C106.155 29.911 106.523 28.3126 106.832 26.9175L107.966 26.9206C108.2 27.8185 108.6 28.9795 108.881 29.8868Z" fill="#2F2828"/>
<path d="M61.0197 26.9225L61.8629 26.9102C61.7079 28.0991 61.2332 30.0627 60.9722 31.3099L59.8503 31.3086C59.519 30.3592 59.2069 29.3429 58.8931 28.3815L58.2722 31.3125L57.3802 31.3049C57.7123 30.0129 58.0673 28.24 58.3208 26.9184L59.4601 26.922C59.6904 27.8033 60.1149 29.0009 60.4008 29.8927C60.5467 28.9635 60.7999 27.8383 61.0197 26.9225Z" fill="#2F2828"/>
<path d="M78.6518 26.9215L79.5294 26.9146C79.4255 27.4595 79.2959 28.0172 79.1766 28.5603C79.689 28.553 80.216 28.5592 80.7298 28.5592L81.0951 26.923L81.9493 26.9141C81.8519 27.9269 81.2576 30.0868 81.0526 31.1643C81.0343 31.2615 80.9867 31.26 80.8865 31.3094C80.6281 31.3129 80.44 31.3262 80.1889 31.262C80.2658 30.6683 80.4253 30.0042 80.5534 29.4127L78.998 29.4118C78.867 30.0925 78.7586 30.6456 78.5669 31.3166L77.7339 31.3159C77.9893 29.9039 78.3363 28.3198 78.6518 26.9215Z" fill="#2F2828"/>
<path d="M103.391 26.918L106.201 26.9222L106.032 27.7871C105.48 27.7389 104.623 27.7531 104.059 27.7493C104.008 28.0771 103.939 28.4261 103.88 28.7546L105.576 28.7521L105.445 29.4786C105.093 29.4693 104.014 29.408 103.727 29.5427L103.677 29.7236L103.488 30.4471L105.46 30.4415C105.391 30.7111 105.332 31.0336 105.273 31.3098C104.347 31.2889 103.359 31.3061 102.428 31.3067C102.714 29.9523 103.028 28.2233 103.391 26.918Z" fill="#2F2828"/>
<path d="M101.23 26.8806C101.741 26.8186 102.269 26.978 102.715 27.2175C102.67 27.4649 102.637 27.8569 102.466 28.0164C102.325 28.0441 101.618 27.7141 101.272 27.6162C100.683 27.7595 100.181 27.9029 99.8684 28.4645C99.2265 29.6163 99.5405 30.8072 101.032 30.4857C101.475 30.0635 101.026 29.4324 101.687 29.1473C101.888 29.1663 102.02 29.1878 102.216 29.2257C102.312 29.5647 101.987 30.9407 101.742 31.0531C97.9931 32.7738 97.5708 27.2933 101.23 26.8806Z" fill="#2F2828"/>
<path d="M74.9385 26.9214C75.1712 26.9118 75.4808 26.8744 75.6821 26.9707C75.7905 27.2226 75.7246 27.2838 75.6726 27.6035C75.3345 28.829 75.382 30.2578 74.2211 31.0737C73.6941 31.4969 72.454 31.4555 72.0957 30.8256C71.57 29.9017 72.2194 27.9607 72.4365 26.9236L73.3245 26.923C73.2315 27.4568 72.5294 29.9806 72.8643 30.2786C74.3522 31.6025 74.7181 27.8186 74.9385 26.9214Z" fill="#2F2828"/>
<path d="M64.4934 26.8798C65.0658 26.8499 65.4038 26.9622 65.9183 27.1614C65.9004 27.4821 65.8231 27.8782 65.7694 28.201C65.3237 27.8267 65.0369 27.7669 64.4898 27.6111C64.0346 27.7299 63.4555 27.898 63.1815 28.3015C62.8659 28.7125 62.5666 30.01 63.1207 30.2988C64.5127 31.0242 64.4083 30.3038 64.6865 29.2341C64.6957 29.1988 65.0789 29.1547 65.1737 29.1403L65.443 29.2169C65.5798 29.5824 65.2474 30.6016 65.1325 31.0264C64.7903 31.1835 64.4256 31.2859 64.0516 31.3298C60.9761 31.6856 61.3322 27.1955 64.4934 26.8798Z" fill="#2F2828"/>
<path d="M85.1552 26.9215L86.2297 26.918C85.7693 27.4771 85.3097 28.0605 84.8551 28.6258C84.028 29.5226 84.0163 30.1406 83.7762 31.3103L82.8773 31.3089C83.4446 29.0983 83.1291 29.0171 82.3298 26.9199L83.3107 26.9197C83.512 27.4507 83.7059 27.9844 83.8926 28.5204L85.1552 26.9215Z" fill="#2F2828"/>
<path d="M139.373 26.9247L140.422 26.918C140.11 27.4009 139.441 28.178 139.059 28.6479C138.207 29.6578 138.305 30.0537 137.985 31.3112L137.113 31.3094C137.262 30.7606 137.396 30.0375 137.516 29.4702C137.197 28.6188 136.873 27.7696 136.543 26.9227L137.516 26.921C137.728 27.4476 137.932 27.9767 138.13 28.5083C138.556 27.9893 138.97 27.4613 139.373 26.9247Z" fill="#2F2828"/>
<path d="M57.3993 4.67527C58.1061 4.65383 58.8637 4.66377 59.5748 4.66016C59.8347 5.63537 59.8946 6.41442 59.9652 7.40979C59.3798 7.36896 58.4268 7.39837 57.8139 7.39757C57.7331 6.3945 57.6463 5.64817 57.3993 4.67527Z" fill="#2F2828"/>
<path d="M133.406 26.9219L136.447 26.9253L136.333 27.7751C135.986 27.7619 135.585 27.7671 135.234 27.7638C135.054 28.5778 134.891 29.4212 134.722 30.2397L134.468 31.3148L133.589 31.3145C133.858 30.1379 134.11 28.9573 134.343 27.7731C134.007 27.7561 133.613 27.7725 133.273 27.7783L133.406 26.9219Z" fill="#2F2828"/>
<path d="M53.4287 26.918L54.3232 26.9254L53.5875 30.453L55.5179 30.4498L55.3763 31.3123C54.4204 31.2926 53.4227 31.3031 52.4631 31.2988C52.7142 30.6151 53.2746 27.7241 53.4287 26.918Z" fill="#2F2828"/>
<path d="M128.859 26.924L129.753 26.9219C129.475 28.0093 129.226 29.3489 128.993 30.4563L130.915 30.4496L130.763 31.3133C129.848 31.2883 128.851 31.3078 127.93 31.3087L128.859 26.924Z" fill="#2F2828"/>
<path d="M127.308 26.9204L128.164 26.918L127.239 31.3152L126.351 31.3151C126.65 29.8952 126.949 28.3149 127.308 26.9204Z" fill="#2F2828"/>
<path d="M56.7724 26.918L57.6656 26.9141C57.3133 28.3287 57.0132 29.8795 56.7123 31.3129L55.8318 31.3086C56.1293 29.8417 56.4429 28.3781 56.7724 26.918Z" fill="#2F2828"/>
<path d="M70.8595 26.9214L71.7381 26.918C71.567 28.0722 71.0941 30.1443 70.8119 31.3022L69.9707 31.3105C70.1852 29.9437 70.5812 28.3064 70.8595 26.9214Z" fill="#2F2828"/>
<path d="M132.146 26.9187L133.056 26.918C132.809 28.082 132.418 30.2402 132.087 31.3152L131.25 31.3052C131.533 29.8444 131.902 28.3846 132.146 26.9187Z" fill="#2F2828"/>
</svg>

Before

Width:  |  Height:  |  Size: 18 KiB

+178
View File
@@ -0,0 +1,178 @@
沪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
+60
View File
@@ -0,0 +1,60 @@
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); });
+1 -19
View File
@@ -3,7 +3,6 @@ import { Shell } from "./components/Shell";
import AuthProvider from "./auth/AuthProvider"; import AuthProvider from "./auth/AuthProvider";
import { useAuth } from "./auth/useAuth"; import { useAuth } from "./auth/useAuth";
import UnauthorizedPage from "./auth/UnauthorizedPage"; import UnauthorizedPage from "./auth/UnauthorizedPage";
import PasswordLogin from "./auth/PasswordLogin";
import { canAccessEnergy } from "./shared/auth/roles"; import { canAccessEnergy } from "./shared/auth/roles";
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface"; import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
import { buildModules } from "./app/modules"; import { buildModules } from "./app/modules";
@@ -14,16 +13,10 @@ import {
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage")); const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage")); const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
const HydrogenPrototypeBoard = lazy(
() =>
import("./modules/energy/hydrogen/board/EnergyBiBoardApp").then(
({ EnergyBiBoardApp }) => ({ default: EnergyBiBoardApp }),
),
);
normalizeBrowserPath(); normalizeBrowserPath();
function AuthGate() { function AuthGate() {
const { isLoading, isAuthenticated, error, user, mode } = useAuth(); const { isLoading, isAuthenticated, error, user } = useAuth();
const [route, setRoute] = useState(readBrowserRoute); const [route, setRoute] = useState(readBrowserRoute);
const { routeKey, pathSet } = route; const { routeKey, pathSet } = route;
@@ -72,7 +65,6 @@ function AuthGate() {
} }
if (!isAuthenticated) { if (!isAuthenticated) {
if (mode === 'password') return <PasswordLogin />;
return <UnauthorizedPage message={error || undefined} />; return <UnauthorizedPage message={error || undefined} />;
} }
@@ -91,16 +83,6 @@ function AuthGate() {
</Suspense> </Suspense>
); );
} }
if (routeKey === "energy/hydrogen-board") {
if (!canAccessEnergy(user?.roles)) {
return <UnauthorizedPage message="无能源管理模块访问权限" />;
}
return (
<Suspense fallback={<LoadingState label="正在加载氢能经营看板" />}>
<HydrogenPrototypeBoard />
</Suspense>
);
}
// /energy 整组按能源权限控制 // /energy 整组按能源权限控制
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) { if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
-1
View File
@@ -35,5 +35,4 @@ test('keeps energy heatmap visibility and the independent energy navigation', ()
buildModules('energy', []).map(module => module.id), buildModules('energy', []).map(module => module.id),
['hydrogen', 'electric', 'etc'], ['hydrogen', 'electric', 'etc'],
); );
assert.equal(buildModules('energy', [])[0]?.label, '氢费BI');
}); });
+6 -6
View File
@@ -1,12 +1,12 @@
import { lazy } from 'react'; import { lazy } from 'react';
import { import {
Activity, Activity,
BatteryCharging,
Fuel, Fuel,
MapPinned, MapPinned,
Receipt,
Route, Route,
Truck, Truck,
Wallet,
Zap,
} from 'lucide-react'; } from 'lucide-react';
import type { ModuleConfig } from '../components/Shell'; import type { ModuleConfig } from '../components/Shell';
import { canAccessEnergy, canAccessScheduling } from '../shared/auth/roles'; import { canAccessEnergy, canAccessScheduling } from '../shared/auth/roles';
@@ -15,7 +15,7 @@ import type { PathSet } from './routing';
const AssetsModule = lazy(() => import('../modules/assets/AssetsModule')); const AssetsModule = lazy(() => import('../modules/assets/AssetsModule'));
const MileageModule = lazy(() => import('../modules/mileage/MileageModule')); const MileageModule = lazy(() => import('../modules/mileage/MileageModule'));
const SchedulingModule = lazy(() => import('../modules/scheduling/SchedulingModule')); const SchedulingModule = lazy(() => import('../modules/scheduling/SchedulingModule'));
const HydrogenModule = lazy(() => import('../modules/energy/hydrogen/index')); const HydrogenModule = lazy(() => import('../modules/energy/HydrogenModule'));
const ElectricModule = lazy(() => import('../modules/energy/ElectricModule')); const ElectricModule = lazy(() => import('../modules/energy/ElectricModule'));
const EtcModule = lazy(() => import('../modules/energy/EtcModule')); const EtcModule = lazy(() => import('../modules/energy/EtcModule'));
const VehicleHeatmapModule = lazy(() => import('../modules/vehicle-heatmap/VehicleHeatmapModule')); const VehicleHeatmapModule = lazy(() => import('../modules/vehicle-heatmap/VehicleHeatmapModule'));
@@ -48,9 +48,9 @@ const SCHEDULING_MODULE: ModuleConfig = {
}; };
const ENERGY_MODULES: ModuleConfig[] = [ const ENERGY_MODULES: ModuleConfig[] = [
{ id: 'hydrogen', label: '氢费BI', icon: Fuel, component: HydrogenModule }, { id: 'hydrogen', label: '氢', icon: Fuel, component: HydrogenModule },
{ id: 'electric', label: '电能', icon: Zap, component: ElectricModule }, { id: 'electric', label: '电能', icon: BatteryCharging, component: ElectricModule },
{ id: 'etc', label: 'ETC', icon: Wallet, component: EtcModule }, { id: 'etc', label: 'ETC', icon: Receipt, component: EtcModule },
]; ];
/** 根据主路径和角色生成导航;返回新数组,避免调用方修改静态注册表。 */ /** 根据主路径和角色生成导航;返回新数组,避免调用方修改静态注册表。 */
-3
View File
@@ -9,14 +9,11 @@ test('normalizes legacy asset paths without losing search parameters', () => {
test('keeps canonical and hidden administration paths unchanged', () => { test('keeps canonical and hidden administration paths unchanged', () => {
assert.equal(resolveCanonicalUrl({ pathname: '/asset', search: '', hash: '#assets' }), null); assert.equal(resolveCanonicalUrl({ pathname: '/asset', search: '', hash: '#assets' }), null);
assert.equal(resolveCanonicalUrl({ pathname: '/admin/feedback', search: '', hash: '' }), 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', () => { test('derives path groups and hidden routes from path or hash', () => {
assert.equal(getPathSet('/energy'), 'energy'); assert.equal(getPathSet('/energy'), 'energy');
assert.equal(getPathSet('/energy/hydrogen-board'), 'energy');
assert.equal(getPathSet('/asset'), 'asset'); 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('/asset', '#/ele/import'), 'ele/import');
assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback'); assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback');
assert.equal(getRouteKey('/asset', '#mileage'), ''); assert.equal(getRouteKey('/asset', '#mileage'), '');
+2 -11
View File
@@ -5,13 +5,7 @@ interface BrowserLocation {
search: string; search: string;
hash: string; hash: string;
} }
const ROOT_PATHS = new Set([ const ROOT_PATHS = new Set(['/asset', '/energy', '/ele/import', '/admin/feedback']);
'/asset',
'/energy',
'/energy/hydrogen-board',
'/ele/import',
'/admin/feedback',
]);
const LEGACY_PATHS: Record<string, { path: string; hash?: string }> = { const LEGACY_PATHS: Record<string, { path: string; hash?: string }> = {
'/': { path: '/asset' }, '/': { path: '/asset' },
@@ -37,13 +31,10 @@ export function normalizeBrowserPath(): void {
} }
export function getPathSet(pathname: string): PathSet { export function getPathSet(pathname: string): PathSet {
return pathname === '/energy' || pathname === '/energy/hydrogen-board' ? 'energy' : 'asset'; return pathname === '/energy' ? 'energy' : 'asset';
} }
export function getRouteKey(pathname: string, hash: string): string { 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') { if (pathname === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') {
return 'ele/import'; return 'ele/import';
} }
-206
View File
@@ -1,206 +0,0 @@
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");
});
+8 -34
View File
@@ -5,7 +5,6 @@ import { setTokenGetter } from './api-client';
const AUTH_API = '/api/auth'; const AUTH_API = '/api/auth';
export default function AuthProvider({ children }: { children: ReactNode }) { export default function AuthProvider({ children }: { children: ReactNode }) {
const [mode, setMode] = useState<'sso' | 'password'>('sso');
const [state, setState] = useState<AuthState>({ const [state, setState] = useState<AuthState>({
isLoading: true, isLoading: true,
isAuthenticated: false, isAuthenticated: false,
@@ -20,6 +19,8 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
setTokenGetter(() => tokenRef.current); setTokenGetter(() => tokenRef.current);
// 防止 StrictMode 双重调用(jumpToken 一次性使用) // 防止 StrictMode 双重调用(jumpToken 一次性使用)
if (authStarted.current) return;
authStarted.current = true;
// 监听 401 事件 // 监听 401 事件
const onUnauthorized = () => { const onUnauthorized = () => {
@@ -29,25 +30,14 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
}; };
window.addEventListener('auth:unauthorized', onUnauthorized); window.addEventListener('auth:unauthorized', onUnauthorized);
if (!authStarted.current) { authStarted.current = true; authenticate(); } authenticate();
return () => window.removeEventListener('auth:unauthorized', onUnauthorized); return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
}, []); }, []);
async function authenticate() { async function authenticate() {
let currentMode: 'sso' | 'password';
try {
const response = await fetch(`${AUTH_API}/config`, { cache: 'no-store' });
const config = await response.json();
if (!response.ok || !['sso', 'password'].includes(config.mode)) throw new Error();
currentMode = config.mode;
setMode(currentMode);
} catch {
setState({ isLoading: false, isAuthenticated: false, user: null, error: '无法获取验证配置,请刷新重试' });
return;
}
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效 // 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
if (currentMode === 'sso' && import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') { if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
setState({ setState({
isLoading: false, isLoading: false,
isAuthenticated: true, isAuthenticated: true,
@@ -69,15 +59,15 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
tokenRef.current = savedToken; tokenRef.current = savedToken;
// 验证 token 是否仍然有效(尝试请求 health) // 验证 token 是否仍然有效(尝试请求 health)
try { try {
const res = await fetch(`${AUTH_API}/me`, { const res = await fetch('/api/health', {
headers: { Authorization: `Bearer ${savedToken}` }, headers: { Authorization: `Bearer ${savedToken}` },
}); });
if (res.ok) { if (res.ok) {
const savedUser = await res.json(); const savedUser = sessionStorage.getItem('bi_user');
setState({ setState({
isLoading: false, isLoading: false,
isAuthenticated: true, isAuthenticated: true,
user: savedUser, user: savedUser ? JSON.parse(savedUser) : null,
error: null, error: null,
}); });
return; return;
@@ -85,12 +75,6 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
} catch { /* token 无效,继续流程 */ } } catch { /* token 无效,继续流程 */ }
sessionStorage.removeItem('bi_jwt'); sessionStorage.removeItem('bi_jwt');
sessionStorage.removeItem('bi_user'); sessionStorage.removeItem('bi_user');
tokenRef.current = null;
}
if (currentMode === 'password') {
setState({ isLoading: false, isAuthenticated: false, user: null, error: null });
return;
} }
// 2. 从 URL 提取 jumpToken // 2. 从 URL 提取 jumpToken
@@ -135,18 +119,8 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
} }
} }
async function loginWithPassword(password: string) {
const response = await fetch(`${AUTH_API}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
const data = await response.json();
if (!response.ok || !data.token) throw new Error(data.message || '登录失败');
tokenRef.current = data.token;
sessionStorage.setItem('bi_jwt', data.token);
sessionStorage.setItem('bi_user', JSON.stringify(data.user));
setState({ isLoading: false, isAuthenticated: true, user: data.user, error: null });
}
return ( return (
<AuthContext.Provider value={{ ...state, mode, loginWithPassword }}> <AuthContext.Provider value={state}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
-25
View File
@@ -1,25 +0,0 @@
import { useState } from 'react';
import { useAuth } from './useAuth';
export default function PasswordLogin() {
const { loginWithPassword, error: sessionError } = useAuth();
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
return <main className="min-h-screen flex items-center justify-center bg-slate-950 p-6">
<form className="w-full max-w-sm rounded-2xl bg-white p-7 shadow-xl" onSubmit={async event => {
event.preventDefault(); if (busy || !loginWithPassword) return;
setBusy(true); setError('');
try { await loginWithPassword(password); } catch (e) { setError(e instanceof Error ? e.message : '登录失败,请重试'); }
finally { setBusy(false); setPassword(''); }
}}>
<p className="text-xs font-semibold text-blue-600"> · BI</p>
<h1 className="mt-2 text-2xl font-bold text-slate-900">访</h1>
<p className="mt-2 mb-6 text-sm text-slate-500">访访</p>
<label htmlFor="bi-password" className="text-sm font-medium text-slate-700">访</label>
<input id="bi-password" type="password" autoComplete="current-password" required maxLength={1024} value={password} onChange={e => setPassword(e.target.value)} className="mt-2 w-full rounded-lg border border-slate-300 px-3 py-3 text-base focus:outline-blue-600" />
{error || sessionError ? <p role="alert" className="mt-3 text-sm text-red-600">{error || sessionError}</p> : null}
<button disabled={busy || !password} className="mt-5 min-h-11 w-full rounded-lg bg-blue-600 px-4 py-3 font-semibold text-white disabled:opacity-50">{busy ? '正在验证…' : '进入看板'}</button>
</form>
</main>;
}
-2
View File
@@ -1,8 +1,6 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
export interface AuthState { export interface AuthState {
mode?: 'sso' | 'password';
loginWithPassword?: (password: string) => Promise<void>;
isLoading: boolean; isLoading: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
user: { user: {
+17
View File
@@ -0,0 +1,17 @@
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>;
}
+10 -17
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react'; import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Building2, ChevronRight } from 'lucide-react'; import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
import { useAuth } from '../auth/useAuth'; import { useAuth } from '../auth/useAuth';
import { DemoModeProvider } from './Blur';
import FeedbackFab from './FeedbackFab'; import FeedbackFab from './FeedbackFab';
import { cn } from '../lib/cn'; import { cn } from '../lib/cn';
import { LoadingState } from './ui/surface'; import { LoadingState } from './ui/surface';
@@ -82,9 +83,6 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
const { user } = useAuth(); const { user } = useAuth();
const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板'; const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
// 氢能经营看板已在模块内提供原型一致的标题与导航,避免桌面端重复显示全局面包屑。
const showDesktopBreadcrumb = activeModule !== 'hydrogen';
const isHydrogenPrototype = activeModule === 'hydrogen';
const watermarkText = useMemo(() => { const watermarkText = useMemo(() => {
const name = user?.userName || '未登录'; const name = user?.userName || '未登录';
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-'); const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
@@ -92,17 +90,16 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
}, [user]); }, [user]);
return ( return (
<DemoModeProvider enabled={false}>
<div className="enterprise-grid-bg flex min-h-screen"> <div className="enterprise-grid-bg flex min-h-screen">
{/* 氢费看板按原型交付;其他模块继续保留全局水印 */} {/* 全局水印 */}
{!isHydrogenPrototype ? (
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}> <div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
<div className="absolute inset-0" style={{ <div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' width='320' height='200'><text x='50%' y='50%' text-anchor='middle' dominant-baseline='middle' font-size='14' font-family='sans-serif' fill='%23000' transform='rotate(-25 160 100)'>${watermarkText}</text></svg>`)}")`, backgroundImage: `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' width='320' height='200'><text x='50%' y='50%' text-anchor='middle' dominant-baseline='middle' font-size='14' font-family='sans-serif' fill='%23000' transform='rotate(-25 160 100)'>${watermarkText}</text></svg>`)}")`,
backgroundRepeat: 'repeat', backgroundRepeat: 'repeat',
}} /> }} />
</div> </div>
) : null} {/* Web 侧边栏 (md 及以上) */}
{/* Web 侧边栏 (md 及以上):能源各模块使用同一套规格,以电能为基准。 */}
<nav className="fixed left-0 top-0 z-50 hidden h-full w-20 flex-col items-center border-r border-white/10 bg-slate-950 px-2 py-4 text-white shadow-[12px_0_40px_rgba(15,23,42,0.16)] md:flex"> <nav className="fixed left-0 top-0 z-50 hidden h-full w-20 flex-col items-center border-r border-white/10 bg-slate-950 px-2 py-4 text-white shadow-[12px_0_40px_rgba(15,23,42,0.16)] md:flex">
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-blue-600 shadow-lg shadow-blue-950/20"> <div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-blue-600 shadow-lg shadow-blue-950/20">
<Building2 size={22} /> <Building2 size={22} />
@@ -121,8 +118,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
aria-expanded={isGroup ? isOpen : undefined} aria-expanded={isGroup ? isOpen : undefined}
aria-haspopup={isGroup ? 'menu' : undefined} aria-haspopup={isGroup ? 'menu' : undefined}
className={cn( className={cn(
'group relative flex flex-col items-center justify-center transition-all', 'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
'h-16 w-16 rounded-2xl text-[10px] font-black',
isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white', isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white',
)} )}
title={m.label} title={m.label}
@@ -153,9 +149,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
</div> </div>
<div className="flex w-full flex-col items-center gap-2 border-t border-white/10 pt-3"> <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 || '当前用户'}> <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-400/10 text-emerald-300 ring-1 ring-emerald-300/20" title={user?.userName || '当前用户'}>
<svg width="28" height="22" viewBox="4 3 44 30" role="img" aria-label="羚牛 Logo"> <ShieldCheck size={18} />
<image href="/lingniu-logo-light.svg" width="150" height="36" style={{ filter: 'brightness(0) invert(1)' }} />
</svg>
</div> </div>
<div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div> <div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div>
</div> </div>
@@ -163,17 +157,15 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
{/* 内容区 */} {/* 内容区 */}
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}> <main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
{showDesktopBreadcrumb ? ( <div className="pointer-events-none fixed left-20 right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex">
<div className={cn('pointer-events-none fixed right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex', isHydrogenPrototype ? 'left-[72px]' : 'left-20')}>
<span className="text-slate-600"> BI</span> <span className="text-slate-600"> BI</span>
<span className="mx-2 text-slate-300">/</span> <span className="mx-2 text-slate-300">/</span>
<span>{activeLabel}</span> <span>{activeLabel}</span>
</div> </div>
) : null}
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}> <Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
{ActiveComponent && <ActiveComponent />} {ActiveComponent && <ActiveComponent />}
</Suspense> </Suspense>
{!isHydrogenPrototype ? <FeedbackFab module={activeModule} /> : null} <FeedbackFab module={activeModule} />
</main> </main>
{/* 移动端底部导航 (md 以下) */} {/* 移动端底部导航 (md 以下) */}
@@ -218,5 +210,6 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
</div> </div>
</nav> </nav>
</div> </div>
</DemoModeProvider>
); );
} }
+17 -14
View File
@@ -9,7 +9,7 @@ import {
MapPin, MapPin,
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { buildJsonSheet, writeWorkbook } from '../../shared/xlsx'; import * as XLSX from 'xlsx';
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -40,6 +40,7 @@ import {
} from './model'; } from './model';
import { SearchSelect } from '../../components/SearchSelect'; import { SearchSelect } from '../../components/SearchSelect';
import { MultiSearchSelect } from '../../components/MultiSearchSelect'; import { MultiSearchSelect } from '../../components/MultiSearchSelect';
import Blur from '../../components/Blur';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface'; import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
import { AssetsHeader } from './components/AssetsHeader'; import { AssetsHeader } from './components/AssetsHeader';
@@ -392,7 +393,7 @@ export default function AssetsModule() {
业务负责人: item.manager || '', 业务负责人: item.manager || '',
客户: item.customerName || '', 客户: item.customerName || '',
})); }));
const ws = buildJsonSheet(table); const ws = XLSX.utils.json_to_sheet(table);
ws['!cols'] = [ ws['!cols'] = [
{ wch: 14 }, { wch: 14 },
{ wch: 8 }, { wch: 8 },
@@ -403,7 +404,9 @@ export default function AssetsModule() {
{ wch: 14 }, { wch: 14 },
{ wch: 24 }, { wch: 24 },
]; ];
writeWorkbook([{ name: '明细', sheet: ws }], `${title}-${flowRange.start}-${flowRange.end}.xlsx`); const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '明细');
XLSX.writeFile(wb, `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
}, [flowRange.end, flowRange.start, flowStats]); }, [flowRange.end, flowRange.start, flowStats]);
const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]); const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]);
@@ -702,7 +705,7 @@ export default function AssetsModule() {
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />} {isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
<span className="font-bold text-gray-700 text-xs">{m.manager}</span> <span className="font-bold text-gray-700 text-xs"><Blur>{m.manager}</Blur></span>
</div> </div>
<button <button
onClick={(e) => { onClick={(e) => {
@@ -784,7 +787,7 @@ export default function AssetsModule() {
> >
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-1"> <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" />} {isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />}
{m.manager} <Blur>{m.manager}</Blur>
</td> </td>
<td className="p-2 border-r border-gray-100 text-gray-600">{m.department}</td> <td className="p-2 border-r border-gray-100 text-gray-600">{m.department}</td>
<td <td
@@ -904,7 +907,7 @@ export default function AssetsModule() {
> >
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />} {isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />}
<span className="text-[11px] font-bold text-gray-700">{m.manager}</span> <span className="text-[11px] font-bold text-gray-700"><Blur>{m.manager}</Blur></span>
</div> </div>
<button <button
onClick={(e) => { onClick={(e) => {
@@ -983,7 +986,7 @@ export default function AssetsModule() {
<div className="flex items-center gap-2 flex-1 min-w-0"> <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" />} {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"> <div className="flex items-center gap-3 min-w-0">
<h3 className="text-sm font-bold text-gray-800 shrink-0">{m.manager}</h3> <h3 className="text-sm font-bold text-gray-800 shrink-0"><Blur>{m.manager}</Blur></h3>
<span className="text-[11px] text-gray-500 shrink-0">{m.department}</span> <span className="text-[11px] text-gray-500 shrink-0">{m.department}</span>
<div <div
className="text-[11px] font-bold text-blue-600 whitespace-nowrap" className="text-[11px] font-bold text-blue-600 whitespace-nowrap"
@@ -1507,12 +1510,12 @@ export default function AssetsModule() {
> >
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-2"> <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" />} {isExpanded ? <ChevronDown size={14} className="text-emerald-600" /> : <ChevronRight size={14} className="text-gray-400" />}
{cust.customer} <Blur>{cust.customer}</Blur>
</td> </td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"> <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> <span className="bg-gray-100 px-2 py-0.5 rounded text-[10px] font-medium">{cust.region}</span>
</td> </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-gray-600 text-center"><Blur>{cust.manager}</Blur></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: 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: '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> <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>
@@ -1527,7 +1530,7 @@ export default function AssetsModule() {
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm"> <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-[10px] text-gray-400 uppercase mb-1"></div>
<div className="text-sm font-bold text-gray-700">{cust.customer}</div> <div className="text-sm font-bold text-gray-700"><Blur>{cust.customer}</Blur></div>
</div> </div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm"> <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-[10px] text-gray-400 uppercase mb-1"></div>
@@ -1537,7 +1540,7 @@ export default function AssetsModule() {
</div> </div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm"> <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-[10px] text-gray-400 uppercase mb-1"></div>
<div className="text-sm font-bold text-gray-700">{cust.manager}</div> <div className="text-sm font-bold text-gray-700"><Blur>{cust.manager}</Blur></div>
</div> </div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm"> <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-[10px] text-gray-400 uppercase mb-1"></div>
@@ -1571,7 +1574,7 @@ export default function AssetsModule() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isExpanded ? <ChevronDown size={16} className="text-emerald-600" /> : <ChevronRight size={16} className="text-gray-400" />} {isExpanded ? <ChevronDown size={16} className="text-emerald-600" /> : <ChevronRight size={16} className="text-gray-400" />}
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-bold text-gray-800 text-sm">{cust.customer}</span> <span className="font-bold text-gray-800 text-sm"><Blur>{cust.customer}</Blur></span>
<span className="text-[10px] text-emerald-600 font-medium">{cust.region}</span> <span className="text-[10px] text-emerald-600 font-medium">{cust.region}</span>
</div> </div>
</div> </div>
@@ -1589,7 +1592,7 @@ export default function AssetsModule() {
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="bg-gray-50 p-2 rounded border border-gray-100"> <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-[8px] text-gray-400 uppercase mb-1"></div>
<div className="text-[10px] font-bold text-gray-700">{cust.customer}</div> <div className="text-[10px] font-bold text-gray-700"><Blur>{cust.customer}</Blur></div>
</div> </div>
<div className="bg-gray-50 p-2 rounded border border-gray-100"> <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-[8px] text-gray-400 uppercase mb-1"></div>
@@ -1599,7 +1602,7 @@ export default function AssetsModule() {
</div> </div>
<div className="bg-gray-50 p-2 rounded border border-gray-100"> <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-[8px] text-gray-400 uppercase mb-1"></div>
<div className="text-xs font-bold text-gray-700">{cust.manager}</div> <div className="text-xs font-bold text-gray-700"><Blur>{cust.manager}</Blur></div>
</div> </div>
<div className="bg-gray-50 p-2 rounded border border-gray-100"> <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-[8px] text-gray-400 uppercase mb-1"></div>
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react'; import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import Blur from '../../../components/Blur';
import { SearchSelect } from '../../../components/SearchSelect'; import { SearchSelect } from '../../../components/SearchSelect';
import type { WeeklyDetailItem } from '../api'; import type { WeeklyDetailItem } from '../api';
import type { ModalVehicleFilters, VehicleModalSelection } from '../model'; import type { ModalVehicleFilters, VehicleModalSelection } from '../model';
@@ -166,8 +167,8 @@ export function VehicleDetailModal({
<tbody className="text-[11px]"> <tbody className="text-[11px]">
{filteredModalWeeklyDetail.map((v, i) => ( {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'}`}> <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">{v.plate_number}</td> <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">{v.customer_name || '—'}</td> <td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{v.customer_name || '—'}</Blur></td>
<td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td> <td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td>
</tr> </tr>
))} ))}
@@ -214,12 +215,12 @@ export function VehicleDetailModal({
{showPlateNumbers.source === 'customer' ? ( {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 text-gray-600">{v.departmentName || '—'}</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 font-medium text-gray-700"><Blur>{v.customerManager || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.brandLabel || '—'}</td> <td className="p-2 border-r border-gray-100 text-gray-600">{v.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-600">{v.type}</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 text-gray-500 text-[10px]"><Blur>{v.subjectOrg || '—'}</Blur></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-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'}`}>{v.plateNumber || v.vin || '—'}</td> <td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 text-center"> <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 ${ <span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${
v.status === 'Operating' ? 'bg-green-100 text-green-700' : v.status === 'Operating' ? 'bg-green-100 text-green-700' :
@@ -232,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-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-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 border-r border-gray-100 text-center font-bold text-orange-600">{'—'}</td>
<td className="p-2 text-gray-500 text-[10px]">{v.orgName || '—'}</td> <td className="p-2 text-gray-500 text-[10px]"><Blur>{v.orgName || '—'}</Blur></td>
</> </>
) : ( ) : (
<> <>
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}>{v.plateNumber || v.vin || '—'}</td> <td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && ( {showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
<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 font-bold text-gray-800 text-center"><Blur>{v.customerName || '—'}</Blur></td>
)} )}
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td> <td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td> <td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td>
+67 -40
View File
@@ -1,52 +1,79 @@
import { useEffect, useState } from 'react'; import { motion } from 'motion/react';
import { BadgeCheck, CarFront, FileText, ReceiptText, WalletCards } from 'lucide-react'; import { Construction, Hammer } from 'lucide-react';
import { fetchEtcOverview, type EtcOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
function formatYuan(value: number) { const ETC_HINTS = [
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`; 'ETC 通行费数据正在与发卡方系统打通…',
} '工人 GG 正在搭脚手架,敬请期待 ~',
'马上能看到每月通行费明细啦',
'想看哪个维度的 ETC?反馈一下嘛',
'上线时机:等数据接通的那一天',
];
export default function ETCView() { export default function ETCView() {
const [data, setData] = useState<EtcOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchEtcOverview()
.then(result => { if (!cancelled) setData(result); })
.catch(reason => { if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason)); });
return () => { cancelled = true; };
}, []);
if (error) return <ErrorState message="ETC 数据源暂时不可用,请稍后刷新。" />;
if (!data) return <LoadingState label="正在核对 ETC 台账" />;
if (!data.hasData) {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-4">
<EmptyState title="暂无 ETC 通行台账" description="当前账单和通行明细均未入库,接入后会自动展示统计数据。" /> <motion.div
<RotatingFooterHint hints={['ETC 台账状态已按数据库实时核对', '待通行明细入库后自动生成统计']} /> initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="relative w-20 h-20 mb-4">
<motion.div
animate={{ rotate: [0, -8, 8, -4, 4, 0] }}
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
className="absolute inset-0 rounded-3xl bg-gradient-to-br from-amber-50 to-orange-50 flex items-center justify-center"
>
<Construction size={36} className="text-amber-500" strokeWidth={2.2} />
</motion.div>
<motion.div
animate={{ rotate: [0, 18, -10, 0], y: [0, -2, 1, 0] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
className="absolute -top-1 -right-1 w-9 h-9 rounded-2xl bg-white border border-amber-100 shadow-sm flex items-center justify-center"
>
<Hammer size={16} className="text-amber-500" strokeWidth={2.2} />
</motion.div>
</div> </div>
);
}
return ( <div className="text-base font-black text-slate-800 mb-1.5">ETC </div>
<div className="flex flex-col gap-3"> <div className="text-[12px] text-slate-500 font-bold leading-relaxed max-w-[280px]">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={ReceiptText} label="通行明细" value={data.tollRecordCount.toLocaleString('zh-CN')} unit="笔" helper={data.latestTollTime ? `最新记录 ${data.latestTollTime}` : '暂无通行时间'} /> <br />
<MetricTile icon={CarFront} label="涉及车辆" value={data.vehicleCount.toLocaleString('zh-CN')} unit="台" helper="按通行明细车牌去重" tone="emerald" />
<MetricTile icon={WalletCards} label="通行费金额" value={formatYuan(data.totalAmount)} helper="通行费与服务费合计" tone="amber" />
<MetricTile icon={FileText} label="ETC 账单" value={data.billCount.toLocaleString('zh-CN')} unit="张" helper={`应收 ${formatYuan(data.receivableAmount)} · 已收 ${formatYuan(data.paidAmount)}`} tone="slate" />
</div> </div>
<SurfaceCard className="p-4">
<div className="flex items-start gap-2 text-xs font-bold text-slate-500"> {/* 简单的里程碑进度感 */}
<BadgeCheck size={17} className="mt-0.5 shrink-0 text-emerald-500" /> <div className="mt-6 w-full max-w-xs space-y-2">
<div> ETC </div> {[
{ label: '需求评审', done: true },
{ label: '数据对接', done: true },
{ label: '页面开发', done: false, current: true },
{ label: '正式上线', done: false },
].map((m, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + i * 0.08, duration: 0.3 }}
className="flex items-center gap-2.5 text-[11px]"
>
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${
m.done ? 'bg-emerald-400'
: m.current ? 'bg-amber-400 ring-4 ring-amber-100 animate-pulse'
: 'bg-slate-200'
}`} />
<span className={`font-bold ${m.done ? 'text-slate-500' : m.current ? 'text-amber-600' : 'text-slate-300'}`}>
{m.label}
</span>
{m.done && <span className="text-[10px] text-emerald-500 font-bold ml-auto"></span>}
{m.current && <span className="text-[10px] text-amber-500 font-bold ml-auto"></span>}
</motion.div>
))}
</div> </div>
</SurfaceCard> </motion.div>
<RotatingFooterHint />
<RotatingFooterHint hints={ETC_HINTS} />
</div> </div>
); );
} }
+29 -27
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { BatteryCharging, CalendarDays, ChevronRight, TrendingUp, Wallet } from 'lucide-react'; import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Wallet } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge'; import TrendBadge from './TrendBadge';
import { fetchElectricMonthly } from './api'; import { fetchElectricMonthly } from './api';
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types'; import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
@@ -28,7 +28,6 @@ export default function ElectricDaily() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setError(null); setError(null);
setMonths(null);
const query = pick === 'custom' const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end } ? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick }; : { range: pick };
@@ -39,11 +38,7 @@ export default function ElectricDaily() {
// 默认展开最新一个月 // 默认展开最新一个月
if (m.length > 0) setOpenMonths(new Set([m[0].month])); if (m.length > 0) setOpenMonths(new Set([m[0].month]));
}) })
.catch(e => { .catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
if (cancelled) return;
setMonths(null);
setError(e instanceof Error ? e.message : String(e));
});
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [customer, pick, effectiveRange.start, effectiveRange.end]); }, [customer, pick, effectiveRange.start, effectiveRange.end]);
@@ -64,6 +59,7 @@ export default function ElectricDaily() {
} = useMemo(() => summarizeElectricMonths(months), [months]); } = useMemo(() => summarizeElectricMonths(months), [months]);
const scopeLabel = getRangeModeLabel(pick); const scopeLabel = getRangeModeLabel(pick);
const rangeText = `${effectiveRange.start}${effectiveRange.end}`; const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
const applyQuickPick = (nextPick: DateQuickPick) => { const applyQuickPick = (nextPick: DateQuickPick) => {
setPick(nextPick); setPick(nextPick);
@@ -76,23 +72,6 @@ export default function ElectricDaily() {
setDateRange(prev => ({ ...prev, [field]: value })); setDateRange(prev => ({ ...prev, [field]: value }));
}; };
if (error) {
return (
<div className="flex flex-col gap-3">
<DailyRangeControls
pick={pick}
dateRange={dateRange}
customer={customer}
onQuickPick={applyQuickPick}
onCustomPick={() => setPick('custom')}
onDateRangeChange={updateDateRange}
onCustomerChange={setCustomer}
/>
<ErrorState message={error} />
</div>
);
}
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<DailyRangeControls <DailyRangeControls
@@ -110,15 +89,37 @@ export default function ElectricDaily() {
<MetricTile <MetricTile
icon={Wallet} icon={Wallet}
label="充电费用" label="充电费用"
value={`¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`} value={hasFeeDetail ? `¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '待同步'}
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前范围未记录费用'} helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前明细仅返回充电量'}
tone={hasFeeDetail ? 'emerald' : 'slate'} tone={hasFeeDetail ? 'emerald' : 'slate'}
/> />
<MetricTile icon={CalendarDays} label="有效天数" value={`${activeDays}`} unit="天" helper={`日均 ${avgKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} tone="amber" /> <MetricTile icon={CalendarDays} label="有效天数" value={`${activeDays}`} unit="天" helper={`日均 ${avgKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} tone="amber" />
<MetricTile icon={TrendingUp} label="波动提醒" value={abnormalDays} unit="天" helper="环比超过 30% 标记" tone={abnormalDays > 0 ? 'rose' : 'slate'} /> <MetricTile icon={TrendingUp} label="波动提醒" value={abnormalDays} unit="天" helper="环比超过 30% 标记" tone={abnormalDays > 0 ? 'rose' : 'slate'} />
</div> </div>
{/* 外部车辆 数据未就绪 */}
{showExternalEmpty && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
<Plug size={22} className="text-blue-500" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
</div>
<div className="text-sm font-bold text-slate-700 mb-1"> · </div>
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
<br />
线
</div>
</motion.div>
)}
{/* 月份分组表 */} {/* 月份分组表 */}
{!showExternalEmpty && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden"> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500"> <div className="grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
<span> / </span> <span> / </span>
@@ -184,6 +185,7 @@ export default function ElectricDaily() {
); );
})} })}
</div> </div>
)}
<RotatingFooterHint /> <RotatingFooterHint />
</div> </div>
); );
+4 -4
View File
@@ -6,7 +6,7 @@ import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface'; import { FadeIn, PageFrame } from '../../components/ui/surface';
const SUB_TABS = [ const SUB_TABS = [
{ id: 'daily', label: '日', icon: CalendarDays }, { id: 'daily', label: '日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard }, { id: 'overview', label: '总览', icon: LayoutDashboard },
] as const satisfies readonly { id: ElectricSubTab; label: string; icon: typeof CalendarDays }[]; ] as const satisfies readonly { id: ElectricSubTab; label: string; icon: typeof CalendarDays }[];
@@ -16,11 +16,11 @@ export default function ElectricModule() {
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS); const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
return ( return (
<PageFrame <PageFrame
title="电能经营看板" title="电能成本看板"
subtitle="充电量、费用、日趋势车辆归属" subtitle="围绕充电量、费用、日趋势车辆归属展示电能支出结构,辅助识别费用波动。"
icon={CalendarDays} icon={CalendarDays}
eyebrow="ELECTRIC BI" eyebrow="ELECTRIC BI"
meta="按日 · 总览" meta="时间单位清晰标注 · 支持日/总览切换"
> >
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} /> <SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
+12 -16
View File
@@ -34,32 +34,28 @@ export default function ElectricOverview() {
const trendData = data.trend; const trendData = data.trend;
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份 // 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
const trendMonthLabel = trendData[0]?.date.slice(0, 7); const trendMonthLabel = trendData[0]?.date.slice(0, 7);
const now = new Date(); const currentMonth = new Date().toISOString().slice(0, 7);
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
const dataIsCurrentMonth = trendMonthLabel === currentMonth; ? `${trendMonthLabel} 每日充电`
const chartTitle = trendMonthLabel ? `${trendMonthLabel} 每日充电` : '每日充电'; : '本月每日充电';
const activeDays = trendData.filter(item => item.kwh > 0).length; const activeDays = trendData.filter(item => item.kwh > 0).length;
const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0; const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0;
const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 0; const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 0;
const peakDay = trendData.reduce<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null); const peakDay = trendData.reduce<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null);
const latestDay = trendData.at(-1) ?? null;
const displayedMonthKwh = dataIsCurrentMonth ? k.monthKwh : trendData.reduce((sum, item) => sum + item.kwh, 0);
const displayedMonthFee = dataIsCurrentMonth ? k.monthFee : trendData.reduce((sum, item) => sum + item.fee, 0);
const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0; const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0;
const monthPrice = displayedMonthKwh > 0 ? displayedMonthFee / displayedMonthKwh : 0; const monthPrice = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 0;
const dataAsOf = data.latestChargeTime ?? '暂无充电记录';
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[11px] font-bold text-slate-400"> <div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
{dataAsOf} · {dataIsCurrentMonth ? '本月数据' : `${trendMonthLabel ?? '暂无'} 数据`} 2025-01-01
</div> </div>
{/* 横向 mini KPI 头 */} {/* 横向 mini KPI 头 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} /> <MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} />
<MetricTile icon={CalendarClock} label={dataIsCurrentMonth ? '本月充电费' : '数据最新月费用'} value={fmtYuan(displayedMonthFee)} helper={fmtKwh(displayedMonthKwh)} tone="emerald" /> <MetricTile icon={CalendarClock} label="本月充电费" value={fmtYuan(k.monthFee)} helper={fmtKwh(k.monthKwh)} tone="emerald" />
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`${dataIsCurrentMonth ? '本月' : '最新月'} ${monthPrice.toFixed(2)} 元/度`} tone="amber" /> <MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
<MetricTile icon={BatteryCharging} label={dataIsCurrentMonth ? '今日充电' : '最新日充电'} value={(dataIsCurrentMonth ? k.todayKwh : latestDay?.kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={dataIsCurrentMonth ? `${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%` : `${latestDay?.date ?? '暂无记录'} · ${fmtYuan(latestDay?.fee ?? 0)}`} tone={dataIsCurrentMonth && Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} /> <MetricTile icon={BatteryCharging} label="今日充电" value={k.todayKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={`${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
</div> </div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
@@ -75,8 +71,8 @@ export default function ElectricOverview() {
</SurfaceCard> </SurfaceCard>
<SurfaceCard className="p-3"> <SurfaceCard className="p-3">
<div className="text-[11px] font-black text-slate-400"></div> <div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (displayedMonthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div> <div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (k.monthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
<div className="mt-1 text-[11px] font-bold text-slate-500">{dataIsCurrentMonth ? '本月' : '最新月'} / </div> <div className="mt-1 text-[11px] font-bold text-slate-500"> / </div>
</SurfaceCard> </SurfaceCard>
</div> </div>
+2 -2
View File
@@ -6,10 +6,10 @@ export default function EtcModule() {
return ( return (
<PageFrame <PageFrame
title="ETC 通行费看板" title="ETC 通行费看板"
subtitle="通行明细、账单与回款状态" subtitle="规划按车、按月、按线路拆分通行费,让车辆运营成本口径逐步完整。"
icon={Receipt} icon={Receipt}
eyebrow="ETC BI" eyebrow="ETC BI"
meta="通行明细 · 账单" meta="数据对接中 · 页面能力预留"
> >
<ETCView /> <ETCView />
</PageFrame> </PageFrame>
+278
View File
@@ -0,0 +1,278 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
import TrendBadge from './TrendBadge';
import { fetchHydrogenDaily } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
import {
getQuickRange,
getRangeModeLabel,
normalizeRange,
summarizeHydrogenRows,
type RangeMode,
} from './hydrogen-daily/model';
import DailyRangeControls from './daily-range/DailyRangeControls';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
export default function HydrogenDaily() {
const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
useEffect(() => {
let cancelled = false;
setError(null);
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
fetchHydrogenDaily(query, customer)
.then(r => { if (!cancelled) setRows(r); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
const {
trendData,
totalKg,
activeDays,
stationCount,
avgKg,
peakDay,
lowDay,
zeroDays,
} = useMemo(() => summarizeHydrogenRows(rows), [rows]);
const scopeLabel = getRangeModeLabel(pick);
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const toggle = (date: string) => setExpanded(prev => {
const next = new Set(prev);
next.has(date) ? next.delete(date) : next.add(date);
return next;
});
const applyQuickPick = (nextPick: DateQuickPick) => {
setPick(nextPick);
setDateRange(getQuickRange(nextPick));
};
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
setPick('custom');
setDateRange(prev => ({ ...prev, [field]: value }));
};
return (
<div className="flex flex-col gap-3">
<DailyRangeControls
pick={pick}
dateRange={dateRange}
customer={customer}
onQuickPick={applyQuickPick}
onCustomPick={() => setPick('custom')}
onDateRangeChange={updateDateRange}
onCustomerChange={setCustomer}
/>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
</div>
{/* 外部车辆:新系统数据还没准备好 */}
{customer === 'external' && rows !== null && totalKg === 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
<Plug size={22} className="text-blue-500" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
</div>
<div className="text-sm font-bold text-slate-700 mb-1"> · </div>
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
<br />
线
</div>
</motion.div>
)}
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
<SurfaceCard>
<div className="flex items-center justify-between px-4 pt-4 mb-2">
<span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold"> · Kg</span>
</div>
<div className="mx-4 mb-2 grid grid-cols-3 gap-2 rounded-xl bg-slate-50 p-2">
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{peakDay ? `${peakDay.date.slice(5)} · ${peakDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{lowDay ? `${lowDay.date.slice(5)} · ${lowDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400"></div>
<div className={`mt-0.5 text-[11px] font-black ${zeroDays > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
{zeroDays}
</div>
</div>
</div>
<div className="h-[180px] min-w-0 px-2 pb-2">
<ResponsiveContainer width="100%" height={180} minWidth={0}>
<BarChart data={trendData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<XAxis
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={8}
/>
<YAxis
width={42}
axisLine={false}
tickLine={false}
tick={{ fontSize: 9, fill: '#94a3b8' }}
tickFormatter={(v: number) => v >= 1000 ? `${Math.round(v / 1000)}k` : `${Math.round(v)}`}
/>
<Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/>
{avgKg > 0 && (
<ReferenceLine
y={avgKg}
stroke="#f59e0b"
strokeDasharray="4 4"
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/>
)}
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => (
<Cell key={i} fill="url(#hydrogenBarGrad)" />
))}
</Bar>
<defs>
<linearGradient id="hydrogenBarGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#22d3ee" />
<stop offset="100%" stopColor="#3b82f6" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
</SurfaceCard>
)}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
{!(customer === 'external' && rows !== null && totalKg === 0) && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 表头 */}
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
<span> / </span>
<span className="hidden md:block text-right"> (/Kg)</span>
<span className="text-right"> (Kg)</span>
<span className="text-right"></span>
</div>
{/* 合计行 */}
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
<span></span>
<span className="hidden md:block" />
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
<span />
</div>
{/* 主行 + 子行 */}
{error ? (
<div className="p-3"><ErrorState message={error} /></div>
) : rows === null ? (
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
) : rows.length === 0 ? (
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
) : rows.map(r => {
const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
const abnormalBg = isAbnormal
? r.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
: '';
return (
<div key={r.date} className={`border-t border-slate-100 ${abnormalBg}`}>
<button
onClick={() => toggle(r.date)}
className="w-full grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2.5 text-left hover:bg-slate-50/60 transition-colors"
>
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
{r.date}
</span>
<span className="hidden md:block text-right text-[12px] text-slate-300"></span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={r.chainPct} /></span>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden bg-slate-50/50"
>
{r.stations.map(s => (
<div
key={s.name}
className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 pl-6 md:pl-9 border-t border-slate-100 first:border-t-0 items-start"
>
<div className="min-w-0">
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
{s.name}
</div>
{s.pricePerKg > 0 && (
<div className="md:hidden mt-1">
<span className="inline-flex items-center text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded font-bold whitespace-nowrap">
{s.pricePerKg} /Kg
</span>
</div>
)}
</div>
<span className="hidden md:block text-right text-[12px] text-slate-500 font-bold tabular-nums">{s.pricePerKg > 0 ? s.pricePerKg : '—'}</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{s.kg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={s.chainPct} /></span>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
)}
<RotatingFooterHint />
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface';
const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
] as const satisfies readonly { id: HydrogenSubTab; label: string; icon: typeof CalendarDays }[];
const SUB_IDS: readonly HydrogenSubTab[] = ['daily', 'overview'];
export default function HydrogenModule() {
const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS);
return (
<PageFrame
title="氢能经营看板"
subtitle="按时间、车辆归属、加氢站和区域统一展示加氢量、费用、收入与异常波动。"
icon={CalendarDays}
eyebrow="ENERGY BI"
meta="数据单位清晰标注 · 支持日/总览切换"
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<HydrogenView sub={sub} />
</FadeIn>
</AnimatePresence>
</PageFrame>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
import { DistributionCharts } from './hydrogen-overview/components/DistributionCharts';
import { HydrogenOverviewSkeleton } from './hydrogen-overview/components/HydrogenOverviewSkeleton';
import { InsightCards } from './hydrogen-overview/components/InsightCards';
import { KpiSection } from './hydrogen-overview/components/KpiSection';
import { MonthlyCharts } from './hydrogen-overview/components/MonthlyCharts';
import { OverviewHeader } from './hydrogen-overview/components/OverviewHeader';
import { RefreshOverlay } from './hydrogen-overview/components/RefreshOverlay';
import { CustomerSummaryTable, StationSummaryTable } from './hydrogen-overview/components/SummaryTables';
import { deriveOverviewMetrics, formatYuan as fmtYuan } from './hydrogen-overview/model';
export default function HydrogenOverview() {
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [year, setYear] = useState<number | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [lastRefreshAt, setLastRefreshAt] = useState<number>(0);
const refreshSeq = useRef(0);
const load = useCallback(async (selectedYear: number | null, force: boolean) => {
const seq = ++refreshSeq.current;
setRefreshing(true);
try {
const d = await fetchHydrogenOverview(selectedYear ?? undefined, force);
if (seq !== refreshSeq.current) return; // outdated
setData(d);
setError(null);
setLastRefreshAt(Date.now());
} catch (e) {
if (seq !== refreshSeq.current) return;
setError(e instanceof Error ? e.message : String(e));
} finally {
if (seq === refreshSeq.current) setRefreshing(false);
}
}, []);
// 初始加载 + 年份切换:用 force=false 命中热缓存
useEffect(() => { void load(year, false); }, [year, load]);
// 客户端兜底自动刷新:每 60s 静默拉一次(命中后端热缓存,几乎零成本)
useEffect(() => {
const t = setInterval(() => { void load(year, false); }, 60_000);
return () => clearInterval(t);
}, [year, load]);
if (error && !data) {
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">{error}</div>;
}
if (!data) {
return <HydrogenOverviewSkeleton />;
}
const { kpi, top5, regions, monthly, customers, stations, availableYears, year: activeYear } = data;
const {
monthAvgKg,
bestMonth,
latestMonth,
monthMomentum,
top5Share,
profitYield,
stationAvgKg,
monthlyDual,
} = deriveOverviewMetrics(data);
const yearProfitFmt = fmtYuan(kpi.yearProfit);
const yearRevenueFmt = fmtYuan(kpi.yearRevenue);
return (
<div className="flex flex-col gap-3 relative">
<OverviewHeader
activeYear={activeYear}
availableYears={availableYears}
lastRefreshAt={lastRefreshAt}
refreshing={refreshing}
onSelectYear={setYear}
onRefresh={() => void load(year, true)}
/>
<KpiSection kpi={kpi} />
<InsightCards
monthAvgKg={monthAvgKg}
bestMonth={bestMonth}
latestMonth={latestMonth}
monthMomentum={monthMomentum}
top5Share={top5Share}
profitYield={profitYield}
stationAvgKg={stationAvgKg}
stationCount={stations.length}
yearProfitValue={yearProfitFmt.value}
yearProfitUnit={yearProfitFmt.unit}
yearRevenueValue={yearRevenueFmt.value}
yearRevenueUnit={yearRevenueFmt.unit}
/>
<MonthlyCharts activeYear={activeYear} monthly={monthly} monthlyDual={monthlyDual} />
<DistributionCharts top5={top5} regions={regions} yearKg={kpi.yearKg} />
<StationSummaryTable stations={stations} />
<CustomerSummaryTable customers={customers} />
<RotatingFooterHint />
<RefreshOverlay refreshing={refreshing} hasData={Boolean(data)} />
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import HydrogenOverview from './HydrogenOverview';
import HydrogenDaily from './HydrogenDaily';
export type HydrogenSubTab = 'daily' | 'overview';
interface Props {
sub: HydrogenSubTab;
}
export default function HydrogenView({ sub }: Props) {
return sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily />;
}
+32 -43
View File
@@ -1,70 +1,59 @@
import { fetchJson } from '../../auth/api-client'; import { fetchJson } from '../../auth/api-client';
import type { import type {
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
HydrogenCustomerRow, HydrogenStationFull,
ElectricKpi, ElectricDailyRow, ElectricMonthGroup, ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
CustomerType, DateQuickPick, CustomerType, DateQuickPick,
HydrogenStationBoardResponse,
} from './types'; } from './types';
const BASE = '/api/energy'; const BASE = '/api/energy';
/** export interface HydrogenOverviewResponse {
* kpi: HydrogenKpi;
* `/hydrogen` top5: HydrogenStationTop[];
* 线 `/hydrogen/overview|daily|settlement` regions: HydrogenRegionShare[];
*/ monthly: HydrogenMonthlyPoint[];
export interface HydrogenStationBoardQuery { customers: HydrogenCustomerRow[];
startDate: string; stations: HydrogenStationFull[];
endDate: string; availableYears: number[];
stationId?: number | null; year: number;
force?: boolean;
} }
export function fetchHydrogenStationBoard(query: HydrogenStationBoardQuery): Promise<HydrogenStationBoardResponse> { export function fetchHydrogenOverview(year?: number, force = false): Promise<HydrogenOverviewResponse> {
const params = new URLSearchParams({ startDate: query.startDate, endDate: query.endDate }); const params = new URLSearchParams();
if (query.stationId) params.set('stationId', String(query.stationId)); if (year) params.set('year', String(year));
if (query.force) params.set('force', '1'); if (force) params.set('force', '1');
return fetchJson<HydrogenStationBoardResponse>(`${BASE}/hydrogen/station-board?${params.toString()}`); const q = params.toString();
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
} }
/** 电能与 ETC 共用的自然日区间查询参数。 */ export interface HydrogenDailyQuery {
export interface EnergyRangeQuery {
range?: DateQuickPick; range?: DateQuickPick;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
} }
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);
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
}
export interface ElectricOverviewResponse { export interface ElectricOverviewResponse {
kpi: ElectricKpi; kpi: ElectricKpi;
trend: ElectricDailyRow[]; trend: ElectricDailyRow[];
latestChargeTime: string | null;
} }
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> { export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`); return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
} }
export function fetchElectricMonthly( export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
customer: CustomerType, const q = new URLSearchParams({ customer });
query: EnergyRangeQuery = { range: 'last15' }, if (query.range) q.set('range', query.range);
): Promise<ElectricMonthGroup[]> { if (query.startDate) q.set('startDate', query.startDate);
const params = new URLSearchParams({ customer }); if (query.endDate) q.set('endDate', query.endDate);
if (query.range) params.set('range', query.range); return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
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 {
tollRecordCount: number;
vehicleCount: number;
totalAmount: number;
latestTollTime: string | null;
billCount: number;
receivableAmount: number;
paidAmount: number;
hasData: boolean;
}
export function fetchEtcOverview(force = false): Promise<EtcOverviewResponse> {
return fetchJson<EtcOverviewResponse>(`${BASE}/etc/overview${force ? '?force=1' : ''}`);
} }
@@ -1,4 +1,5 @@
import { Calendar, Fuel, RefreshCw, Truck } from 'lucide-react'; import { Truck } from 'lucide-react';
import { SurfaceCard } from '../../../components/ui/surface';
import type { CustomerType, DateQuickPick } from '../types'; import type { CustomerType, DateQuickPick } from '../types';
import { QUICK_PICK_OPTIONS, type RangeMode } from './model'; import { QUICK_PICK_OPTIONS, type RangeMode } from './model';
@@ -6,138 +7,86 @@ interface DailyRangeControlsProps {
pick: RangeMode; pick: RangeMode;
dateRange: { start: string; end: string }; dateRange: { start: string; end: string };
customer: CustomerType; customer: CustomerType;
stations?: { id: number; name: string }[];
selectedStationId?: number | null;
updatedAt?: string | null;
loading?: boolean;
onQuickPick: (pick: DateQuickPick) => void; onQuickPick: (pick: DateQuickPick) => void;
onCustomPick: () => void; onCustomPick: () => void;
onDateRangeChange: (field: 'start' | 'end', value: string) => void; onDateRangeChange: (field: 'start' | 'end', value: string) => void;
onCustomerChange: (customer: CustomerType) => void; onCustomerChange: (customer: CustomerType) => void;
onStationChange?: (stationId: number | null) => void;
onRefresh?: () => void;
} }
export default function DailyRangeControls({ export default function DailyRangeControls({
pick, pick,
dateRange, dateRange,
customer, customer,
stations = [],
selectedStationId = null,
updatedAt,
loading = false,
onQuickPick, onQuickPick,
onCustomPick, onCustomPick,
onDateRangeChange, onDateRangeChange,
onCustomerChange, onCustomerChange,
onStationChange,
onRefresh,
}: DailyRangeControlsProps) { }: DailyRangeControlsProps) {
return ( return (
<section className="rounded-[14px] border border-slate-200/70 bg-white px-3 py-3 shadow-sm md:px-4"> <SurfaceCard className="p-2 md:p-3">
<div className="flex flex-col gap-3"> <div className="flex items-center gap-2 overflow-x-auto pb-1">
<div className="flex min-w-0 flex-wrap items-center gap-2"> {QUICK_PICK_OPTIONS.map(opt => (
<div className="flex rounded-md bg-slate-100 p-0.5">
{QUICK_PICK_OPTIONS.map(option => (
<button <button
key={option.id} key={opt.id}
type="button" onClick={() => onQuickPick(opt.id)}
onClick={() => onQuickPick(option.id)} className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
className={`h-7 rounded px-3 text-[12px] font-medium transition-colors ${ pick === opt.id
pick === option.id ? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
? 'bg-white font-semibold text-blue-600 shadow-sm' : 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
: 'text-slate-500 hover:text-slate-800'
}`} }`}
> >
{option.label} {opt.label}
</button> </button>
))} ))}
<button <button
type="button"
onClick={onCustomPick} onClick={onCustomPick}
className={`h-7 rounded px-3 text-[12px] font-medium transition-colors ${ className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom' pick === 'custom'
? 'bg-white font-semibold text-blue-600 shadow-sm' ? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'text-slate-500 hover:text-slate-800' : 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`} }`}
> >
</button> </button>
</div> </div>
<DateField label="开始日期" value={dateRange.start} onChange={value => onDateRangeChange('start', value)} /> <div className="mt-2 grid grid-cols-2 gap-2">
<DateField label="结束日期" value={dateRange.end} onChange={value => onDateRangeChange('end', value)} /> <label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400"></span>
{onStationChange ? ( <input
<label className="inline-flex h-8 min-w-[190px] max-w-full items-center gap-2 rounded-md border border-slate-300 bg-white px-2.5 text-slate-600 transition-colors focus-within:border-sky-600 focus-within:ring-2 focus-within:ring-sky-100"> type="date"
<Fuel size={13} className="shrink-0 text-slate-400" /> value={dateRange.start}
<select onChange={event => onDateRangeChange('start', event.target.value)}
aria-label="选择加氢站" onInput={event => onDateRangeChange('start', event.currentTarget.value)}
value={selectedStationId ?? ''} className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
onChange={event => onStationChange(event.target.value === '' ? null : Number(event.target.value))} />
className="min-w-0 flex-1 bg-transparent text-[12px] font-semibold text-slate-700 outline-none" </label>
> <label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<option value=""></option> <span className="block text-[10px] font-black text-slate-400"></span>
{stations.map((station, index) => ( <input
<option key={`${station.id}-${station.name}-${index}`} value={station.id}>{station.name}</option> type="date"
))} value={dateRange.end}
</select> onChange={event => onDateRangeChange('end', event.target.value)}
onInput={event => onDateRangeChange('end', event.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label> </label>
) : null}
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
<div className="flex rounded-md bg-slate-100 p-0.5">
{(['lingniu', 'external'] as const).map(option => ( {(['lingniu', 'external'] as const).map(option => (
<button <button
key={option} key={option}
type="button"
onClick={() => onCustomerChange(option)} onClick={() => onCustomerChange(option)}
className={`inline-flex h-8 items-center gap-1.5 rounded px-3 text-[12px] font-medium transition-colors ${ className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
customer === option customer === option ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
? 'bg-white font-semibold text-blue-600 shadow-sm'
: 'text-slate-500 hover:text-slate-800'
}`} }`}
> >
<Truck size={13} /> <Truck size={14} />
{option === 'external' ? '外部车辆' : '羚牛车辆'} {option === 'external' ? '外部车辆' : '羚牛车辆'}
</button> </button>
))} ))}
</div> </div>
</SurfaceCard>
<div className="ml-auto flex items-center gap-3">
{updatedAt ? <span className="font-mono text-[11px] text-slate-400">{updatedAt}</span> : null}
{onRefresh ? (
<button
type="button"
onClick={onRefresh}
disabled={loading}
title="刷新数据"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-slate-200 bg-white px-3 text-[12px] font-semibold text-slate-600 transition-colors hover:border-blue-200 hover:text-blue-600 disabled:cursor-wait disabled:opacity-60"
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
</button>
) : null}
</div>
</div>
</div>
</section>
);
}
function DateField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return (
<label className="inline-flex h-8 items-center gap-2 rounded-md border border-slate-300 bg-white px-2.5 transition-colors focus-within:border-sky-600 focus-within:ring-2 focus-within:ring-sky-100">
<Calendar size={13} className="shrink-0 text-sky-600" />
<span className="hidden text-[11px] font-medium text-slate-400 sm:inline">{label}</span>
<input
type="date"
aria-label={label}
value={value}
onChange={event => onChange(event.target.value)}
className="h-7 min-w-[112px] bg-transparent font-mono text-[11px] font-semibold text-slate-700 outline-none"
/>
</label>
); );
} }
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { HydrogenDailyRow } from '../types.js';
import {
getQuickRange,
getRangeModeLabel,
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('自定义日期倒置时仅交换查询边界', () => {
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,
chainPct: -1,
customerType: 'lingniu',
stations: [{ name: 'A站', kg: 0, pricePerKg: 0, chainPct: -1 }],
},
{
date: '2026-08-10',
totalKg: 100,
chainPct: 0,
customerType: 'lingniu',
stations: [{ name: 'A站', kg: 100, pricePerKg: 20, chainPct: 0 }],
},
{
date: '2026-08-11',
totalKg: 300,
chainPct: 2,
customerType: 'lingniu',
stations: [{ name: 'B站', kg: 300, 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.activeDays, 2);
assert.equal(summary.avgKg, 200);
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,
activeDays: 0,
stationCount: 0,
avgKg: 0,
peakDay: null,
lowDay: null,
zeroDays: 0,
});
});
@@ -0,0 +1,41 @@
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 activeDays = source.filter(row => row.totalKg > 0).length;
const stationNames = new Set<string>();
source.forEach(row => row.stations.forEach(station => stationNames.add(station.name)));
const peakDay = trendData.reduce<HydrogenDailyRow | null>(
(best, item) => (!best || item.totalKg > best.totalKg ? item : best),
null,
);
const lowDay = trendData
.filter(item => item.totalKg > 0)
.reduce<HydrogenDailyRow | null>(
(low, item) => (!low || item.totalKg < low.totalKg ? item : low),
null,
);
return {
trendData,
totalKg,
activeDays,
stationCount: stationNames.size,
avgKg: activeDays > 0 ? totalKg / activeDays : 0,
peakDay,
lowDay,
zeroDays: source.filter(row => row.totalKg === 0).length,
};
}
@@ -0,0 +1,133 @@
import {
Bar,
BarChart,
Cell,
LabelList,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { HydrogenRegionShare, HydrogenStationTop } from '../../types';
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[];
yearKg: number;
}
export function DistributionCharts({ top5, regions, yearKg }: DistributionChartsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-bold text-slate-700"> Top5</span>
<span className="text-[11px] text-slate-400 font-bold"> Kg</span>
</div>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
width={188}
tick={<RankYAxisTick />}
tickLine={false}
axisLine={false}
/>
<Tooltip
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
/>
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
{top5.map((_, i) => (
<Cell key={i} fill="url(#topBarGrad)" />
))}
<LabelList
dataKey="kg"
position="right"
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
fill="#475569"
fontSize={11}
fontWeight={700}
/>
</Bar>
<defs>
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#22d3ee" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
<span className="text-sm font-bold text-slate-700"></span>
<div className="flex items-center gap-2">
<div className="relative w-1/2 h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={regions}
dataKey="kg"
nameKey="region"
innerRadius={48}
outerRadius={80}
paddingAngle={1}
>
{regions.map((_, i) => (
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<div className="text-[10px] text-slate-400 font-bold"></div>
<div className="text-base font-bold text-slate-700 leading-tight">{(yearKg / 1000).toFixed(2)}T</div>
</div>
</div>
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
{regions.map((r, i) => (
<div key={r.region} className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
<span className="text-slate-600 truncate">{r.region}</span>
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
export function HydrogenOverviewSkeleton() {
return (
<div className="flex flex-col gap-3 animate-pulse">
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
<div className="h-3 w-44 bg-slate-100 rounded" />
</div>
{/* 5 卡占位 */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-xl bg-slate-100" />
<div className="h-3 w-16 bg-slate-100 rounded" />
</div>
<div className="h-7 w-24 bg-slate-200 rounded" />
<div className="space-y-1.5 pt-1 border-t border-slate-50">
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
</div>
</div>
))}
</div>
{/* 月度柱图占位 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
<div className="flex items-end gap-2 h-[120px]">
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
<div className="space-y-3">
{[100, 78, 56, 40, 28].map((w, i) => (
<div key={i} className="flex items-center gap-3">
<div className="w-5 h-5 rounded-full bg-slate-200" />
<div className="h-3 w-32 bg-slate-100 rounded" />
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
))}
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
<div className="h-4 w-28 bg-slate-100 rounded" />
<div className="flex items-center gap-3">
<div className="w-1/2 h-[200px] flex items-center justify-center">
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
</div>
<div className="flex-1 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-slate-200" />
<div className="h-3 w-16 bg-slate-100 rounded" />
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
</div>
))}
</div>
</div>
</div>
</div>
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
</div>
</div>
);
}
@@ -0,0 +1,88 @@
import { AlertTriangle, Building2, Gauge } from 'lucide-react';
import type { HydrogenMonthlyPoint } from '../../types';
import { formatKg as fmtKg } from '../model';
interface InsightCardsProps {
monthAvgKg: number;
bestMonth: HydrogenMonthlyPoint | null;
latestMonth: HydrogenMonthlyPoint | undefined;
monthMomentum: number | null;
top5Share: number;
profitYield: number;
stationAvgKg: number;
stationCount: number;
yearProfitValue: string;
yearProfitUnit: string;
yearRevenueValue: string;
yearRevenueUnit: string;
}
export function InsightCards({
monthAvgKg,
bestMonth,
latestMonth,
monthMomentum,
top5Share,
profitYield,
stationAvgKg,
stationCount,
yearProfitValue,
yearProfitUnit,
yearRevenueValue,
yearRevenueUnit,
}: InsightCardsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 md:gap-3">
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
</div>
</div>
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<Gauge size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">Top5 {top5Share.toFixed(1)}%</div>
</div>
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-cyan-50 text-cyan-600 ring-1 ring-cyan-100">
<Building2 size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{stationCount} · {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit}
{top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'}
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{profitYield.toFixed(1)}%
</div>
</div>
<span className={`flex h-9 w-9 items-center justify-center rounded-xl ring-1 ${profitYield >= 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}>
<AlertTriangle size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{yearProfitValue}{yearProfitUnit} · {yearRevenueValue}{yearRevenueUnit}
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
</div>
</div>
</div>
);
}
@@ -0,0 +1,114 @@
import type { ReactNode } from 'react';
import { CalendarDays, Fuel, Sparkles, TrendingUp, Wallet } from 'lucide-react';
import type { HydrogenKpi } from '../../types';
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
interface KpiCardProps {
icon: ReactNode;
label: string;
hero: { value: string; unit: string };
rows: { label: string; value: string; valueClass?: string }[];
accentClass: string;
iconBg: string;
}
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
{icon}
</div>
<span className="text-[11px] font-bold text-slate-500">{label}</span>
</div>
<div className="flex items-baseline gap-1">
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
</div>
<div className="space-y-0.5 pt-1 border-t border-slate-50">
{rows.map((r, i) => (
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
<span className="text-slate-400">{r.label}</span>
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
</div>
))}
</div>
</div>
);
}
export function KpiSection({ kpi: k }: { kpi: HydrogenKpi }) {
const yearKgFmt = fmtKg(k.yearKg);
const yearFeeFmt = fmtYuan(k.yearFee);
const yearProfitFmt = fmtYuan(k.yearProfit);
const ourYearKgFmt = fmtKg(k.ourYearKg);
const customerYearKgFmt = fmtKg(k.customerYearKg);
const monthKgFmt = fmtKg(k.monthKg);
const monthFeeFmt = fmtYuan(k.monthFee);
const todayKgFmt = fmtKg(k.todayKg);
const todayFeeFmt = fmtYuan(k.todayFee);
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
const customerYearFeeFmt = fmtYuan(customerYearFee);
const yearRevenueFmt = fmtYuan(k.yearRevenue);
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
<KpiCard
icon={<Fuel size={14} className="text-cyan-600" strokeWidth={2.4} />}
iconBg="bg-cyan-50"
accentClass="text-slate-800"
label="累计加氢量"
hero={yearKgFmt}
rows={[
{ label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` },
{ label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` },
]}
/>
<KpiCard
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
iconBg="bg-blue-50"
accentClass="text-slate-800"
label="累计加氢费"
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
rows={[
{ label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
{ label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
]}
/>
<KpiCard
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
iconBg="bg-emerald-50"
accentClass={profitColor}
label="时享加氢获利"
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
rows={[
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
]}
/>
<KpiCard
icon={<CalendarDays size={14} className="text-amber-600" strokeWidth={2.4} />}
iconBg="bg-amber-50"
accentClass="text-amber-600"
label="本月加氢"
hero={monthKgFmt}
rows={[
{ label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` },
{ label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` },
]}
/>
<KpiCard
icon={<Sparkles size={14} className="text-violet-600" strokeWidth={2.4} />}
iconBg="bg-violet-50"
accentClass="text-violet-600"
label="本日加氢"
hero={todayKgFmt}
rows={[
{ label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` },
{ label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` },
]}
/>
</div>
);
}
@@ -0,0 +1,101 @@
import {
Bar,
BarChart,
Cell,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { HydrogenMonthlyPoint } from '../../types';
import { formatYuan as fmtYuan } from '../model';
type MonthlyChartPoint = HydrogenMonthlyPoint & { monthLabel: string };
interface MonthlyChartsProps {
activeYear: number;
monthly: HydrogenMonthlyPoint[];
monthlyDual: MonthlyChartPoint[];
}
export function MonthlyCharts({ activeYear, monthly, monthlyDual }: MonthlyChartsProps) {
return (
<>
{monthly.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{activeYear} </span>
<span className="text-[11px] text-slate-400 font-bold"> Kg</span>
</div>
<ResponsiveContainer width="100%" height={140}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval={0}
/>
<YAxis hide />
<Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']}
labelFormatter={(d) => `${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/>
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
{monthlyDual.map((_, i) => (
<Cell key={i} fill="url(#monthlyBarGrad)" />
))}
</Bar>
<defs>
<linearGradient id="monthlyBarGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#22d3ee" />
<stop offset="100%" stopColor="#3b82f6" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
)}
{monthly.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{activeYear} </span>
<span className="text-[11px] text-slate-400 font-bold"> </span>
</div>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval={0}
/>
<YAxis hide />
<Legend
verticalAlign="top"
height={20}
iconSize={8}
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
/>
<Tooltip
formatter={(v, name) => {
const f = fmtYuan(Number(v ?? 0));
return [`¥${f.value} ${f.unit}`, name];
}}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
/>
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</>
);
}
@@ -0,0 +1,53 @@
import { RefreshCw } from 'lucide-react';
import { formatRefreshTime } from '../model';
interface OverviewHeaderProps {
activeYear: number;
availableYears: number[];
lastRefreshAt: number;
refreshing: boolean;
onSelectYear: (year: number) => void;
onRefresh: () => void;
}
export function OverviewHeader({
activeYear,
availableYears,
lastRefreshAt,
refreshing,
onSelectYear,
onRefresh,
}: OverviewHeaderProps) {
return (
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRefreshTime(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
{availableYears.map(y => {
const active = y === activeYear;
return (
<button
key={y}
onClick={() => onSelectYear(y)}
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
}`}
>
{y}
</button>
);
})}
</div>
<button
onClick={onRefresh}
disabled={refreshing}
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
title="手动刷新(绕过缓存)"
>
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
<span className="text-[11px] font-bold"></span>
</button>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
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>
);
}
@@ -0,0 +1,123 @@
import type { HydrogenCustomerRow, HydrogenStationFull } from '../../types';
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
export function StationSummaryTable({ stations }: { stations: HydrogenStationFull[] }) {
return (
<>
{stations.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold"> {stations.length} </span>
</div>
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full text-[11px]">
<thead>
<tr className="text-slate-400 font-bold border-b border-slate-100">
<th className="text-left py-1.5 pl-1 w-8">#</th>
<th className="text-left py-1.5"></th>
<th className="text-right py-1.5 w-20"></th>
<th className="text-right py-1.5 pl-2 hidden sm:table-cell"></th>
<th className="text-right py-1.5 pl-2 w-24"></th>
<th className="text-right py-1.5 pr-1 hidden md:table-cell"></th>
</tr>
</thead>
<tbody>
{stations.map((s, i) => {
const kgFmt = fmtKg(s.kg);
const revFmt = fmtYuan(s.revenue);
return (
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
</td>
<td className="py-1.5 pl-2 text-right hidden sm:table-cell">
<div className="inline-flex items-center gap-1.5">
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-cyan-400 to-blue-500" style={{ width: `${Math.min(100, s.share * 100)}%` }} />
</div>
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
</div>
</td>
<td className="py-1.5 pl-2 text-right tabular-nums font-bold text-emerald-600">
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
</td>
<td className="py-1.5 pr-1 text-right hidden md:table-cell">
<div className="inline-flex items-center gap-1.5">
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: `${Math.min(100, s.revenueShare * 100)}%` }} />
</div>
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</>
);
}
export function CustomerSummaryTable({ customers }: { customers: HydrogenCustomerRow[] }) {
return (
<>
{customers.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold">Top {customers.length}</span>
</div>
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full text-[11px]">
<thead>
<tr className="text-slate-400 font-bold border-b border-slate-100">
<th className="text-left py-1.5 pl-1 w-8">#</th>
<th className="text-left py-1.5"></th>
<th className="text-center py-1.5 w-14 hidden sm:table-cell"></th>
<th className="text-right py-1.5 w-20"></th>
<th className="text-right py-1.5 pl-2 w-24"></th>
<th className="text-right py-1.5 pr-1 w-24 hidden md:table-cell"></th>
</tr>
</thead>
<tbody>
{customers.map((c2, i) => {
const kgFmt = fmtKg(c2.kg);
const costFmt = fmtYuan(c2.cost);
const revFmt = fmtYuan(c2.revenue);
return (
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
<td className="py-1.5 text-center hidden sm:table-cell">
{c2.payer === 'lingniu' ? (
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold"></span>
) : (
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold"></span>
)}
</td>
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
</td>
<td className="py-1.5 pl-2 text-right tabular-nums text-amber-600 font-bold">
¥{costFmt.value}<span className="text-slate-400 font-normal ml-0.5">{costFmt.unit}</span>
</td>
<td className="py-1.5 pr-1 text-right tabular-nums text-emerald-600 font-bold hidden md:table-cell">
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { HydrogenOverviewResponse } from '../api.js';
import {
deriveOverviewMetrics,
formatKg,
formatRelative,
formatYuan,
} from './model.js';
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: [{ 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: [
{ name: 'A', kg: 600, share: 0.6, revenue: 300, revenueShare: 0.6 },
{ name: 'B', kg: 400, share: 0.4, revenue: 200, revenueShare: 0.4 },
],
availableYears: [2026],
year: 2026,
} 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.profitYield, -10);
assert.equal(metrics.stationAvgKg, 500);
assert.deepEqual(metrics.monthlyDual.map(item => item.monthLabel), ['1月', '2月']);
});
@@ -0,0 +1,75 @@
import type { HydrogenOverviewResponse } from '../api';
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: '元',
};
}
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,
profitYield: 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/, '')}`,
})),
};
}
-182
View File
@@ -1,182 +0,0 @@
import { fetchJson } from '../../../auth/api-client';
import type {
H2BiDailyResponse,
H2BiDailyTreeQuery,
H2BiDailyTreeResponse,
H2BiDrillReadOptions,
H2BiFullDrillOptions,
H2BiFullDrillResponse,
H2BiDrillQuery,
H2BiDrillResponse,
H2BiMetaResponse,
H2BiOverviewResponse,
H2BiQuery,
} from './types';
const BASE = '/api/energy/h2/v2';
function queryString(query: Record<string, unknown>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== '') params.set(key, String(value));
}
return params.toString();
}
function request<T>(path: string, query: object = {}, options?: RequestInit) {
const qs = queryString(query as Record<string, unknown>);
return fetchJson<T>(`${BASE}/${path}${qs ? `?${qs}` : ''}`, options);
}
export function fetchH2BiMeta() {
return request<H2BiMetaResponse>('meta');
}
export function fetchH2BiOverview(query: H2BiQuery) {
return request<H2BiOverviewResponse>('overview', query);
}
export function fetchH2BiDaily(query: H2BiQuery) {
return request<H2BiDailyResponse>('daily', query).catch(async () => {
// The date-group drill is backed by the same read-only ledger and has a
// simpler query plan. Keep the date view usable when the aggregate daily
// endpoint times out/fails, without substituting mock data.
const drill = await request<H2BiDrillResponse>('drill', {
...query,
groupBy: 'date',
pageSize: 400,
});
const startDate = query.startDate ?? `${query.year}-01-01`;
const endDate = query.endDate ?? new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
const byDate = new Map(drill.groups.map((row) => [row.name, row]));
const days = [] as H2BiDailyResponse['trend'];
for (let cursor = new Date(`${startDate}T00:00:00Z`); cursor <= new Date(`${endDate}T00:00:00Z`); cursor.setUTCDate(cursor.getUTCDate() + 1)) {
const date = cursor.toISOString().slice(0, 10);
const row = byDate.get(date);
days.push({
date,
kg: Number(row?.kg ?? 0),
lingniuKg: Number(row?.lingniuKg ?? 0),
externalKg: Number(row?.externalKg ?? 0),
cost: Number(row?.cost ?? 0),
recordCount: Number(row?.recordCount ?? 0),
});
}
const totalKg = days.reduce((sum, row) => sum + row.kg, 0);
const totalCost = days.reduce((sum, row) => sum + row.cost, 0);
return {
range: { startDate, endDate },
watermark: { ledgerAt: null, paymentAt: null },
filters: query,
kpis: {
totalKg,
totalCost,
averageDailyKg: totalKg / Math.max(1, days.length),
stationCount: Number(drill.summary.stationCount ?? 0),
activeDays: days.filter((row) => row.kg > 0).length,
},
trend: days,
days: [...days].reverse(),
};
});
}
export function fetchH2BiDailyTree(date: string, query: H2BiDailyTreeQuery) {
return request<H2BiDailyTreeResponse>('daily-tree', { date, ...query });
}
export async function fetchH2BiDrill(
query: H2BiDrillQuery,
options?: H2BiDrillReadOptions,
) {
const page = query.page ?? 1;
const pageSize = Math.min(Math.max(1, query.pageSize ?? 100), 200);
const response = await request<H2BiDrillResponse>(
'drill',
{ ...query, page, pageSize },
options,
);
const itemCount = query.groupBy === 'record'
? response.records.length
: response.groups.length;
// Older servers only set hasMore for record pages. A full page is therefore
// also treated as potentially incomplete; the final empty/short page proves
// completion without silently dropping grouped rows.
return {
...response,
page: {
...response.page,
page,
pageSize,
itemCount,
hasMore: Boolean(response.page?.hasMore) || itemCount === pageSize,
},
} satisfies H2BiDrillResponse;
}
function abortError() {
const error = new Error('已取消全量读取');
error.name = 'AbortError';
return error;
}
/**
* Reads every page only after a caller explicitly asks for a complete result.
* It never returns a partial collection: server errors, cancellation, and the
* page guard all reject before an export can be created.
*/
export async function fetchAllH2BiDrill(
query: Omit<H2BiDrillQuery, 'page' | 'pageSize'>,
options: H2BiFullDrillOptions = {},
): Promise<H2BiFullDrillResponse> {
const pageSize = Math.min(Math.max(1, options.pageSize ?? 200), 200);
const maxPages = Math.max(1, options.maxPages ?? 250);
const maxRows = Math.max(1, options.maxRows ?? 50_000);
const groups: H2BiDrillResponse['groups'] = [];
const records: H2BiDrillResponse['records'] = [];
let first: H2BiDrillResponse | null = null;
for (let page = 1; page <= maxPages; page += 1) {
if (options.signal?.aborted) throw abortError();
const result = await fetchH2BiDrill(
{ ...query, page, pageSize },
{ signal: options.signal },
);
if (!first) first = result;
groups.push(...result.groups);
records.push(...result.records);
const itemCount = query.groupBy === 'record' ? records.length : groups.length;
if (itemCount > maxRows)
throw new Error(`全量读取超过 ${maxRows} 条保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
if (!result.page.hasMore) {
return {
...first,
groups,
records,
page: {
...result.page,
page: 1,
pageSize,
itemCount,
hasMore: false,
pagesRead: page,
complete: true,
},
};
}
}
throw new Error(`全量读取超过 ${maxPages} 页保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
}
/** Reusable contract for station-detail consumers that need every raw record. */
export function fetchAllH2BiDrillRecords(
query: Omit<H2BiDrillQuery, 'groupBy' | 'page' | 'pageSize'>,
options?: H2BiFullDrillOptions,
) {
return fetchAllH2BiDrill({ ...query, groupBy: 'record' }, options);
}
@@ -1,41 +0,0 @@
# 移动端经营总览布局决策
## 问题
- 顶部筛选区域占用首屏过多。
- 累计加氢量与累计加氢费使用两个大卡片并排,数字和承担结构拥挤。
- 利润卡片与本月、本日卡片高度不一致,形成大面积无效留白。
- 移动端需要先回答经营结果,再提供费用结构与近期指标。
## 用户选择
- 仅优化移动端布局,保留现有数据、筛选和下钻交互。
- 使用克制、专业、低饱和的视觉方向。
- 以管理层快速查看经营结果、费用结构和近期表现为核心任务。
## 最终设计决策
1. 新增单个移动端「经营总览」容器,集中展示累计加氢量、累计加氢费及我司承担、客户承担、待核准三行对照数据。
2. 桌面端继续使用原有 KPI 栅格,移动端隐藏原累计量费双卡,避免重复信息。
3. 加氢利润改为全宽紧凑卡,本月加氢量与今日加氢量并排呈现。
4. 压缩移动端页头、筛选容器和范围提示的垂直空间,不改变筛选状态与即时生效逻辑。
5. 保持 44px 最小触控热区、等宽数字及 375px/390px 视口无横向溢出。
## 参考稿细化确认
用户追加确认以参考截图优化移动端首屏:
1. 顶部只保留年份、视图、车辆范围和筛选四项快捷入口,订单范围收进展开筛选。
2. 累计经营概览增加我司、客户、待核准三段加氢量构成条,并展示吨数与占比。
3. 增加「查看构成」入口,继续复用累计加氢量明细。
4. 利润卡改为左侧利润、右侧收入与成本的横向结构。
5. 本月与今日指标使用等宽双卡,经营诊断延后至趋势内容之后。
## 单站页与累计明细补充确认
1. 单站页把站点数、统计加氢总量、车次、统计金额和现结金额收进一张经营概览,不再把桌面端四卡压成手机两列。
2. 日期范围与更新时间保留在同一块紧凑查询区;各站概况改为纵向卡片,竖屏不展示无必要的横屏入口。
3. 累计明细顶部改为双主指标:数据归集总量、数据总金额;覆盖站点数和来源完整度降为一行辅助信息。
4. 累计明细筛选默认收起为范围摘要,点击后展开完整筛选;竖屏只保留左上返回,不再重复提供关闭和横屏入口。
5. 层级数据优先保证站点、客户、车辆与订单摘要在首列可读;详细字段继续在表格内部横向查看,不允许撑宽整页。
6. 移动端竖屏明细页头保留左侧返回;站点与客户宽表明细同时保留右侧横屏图标,但不显示重复关闭按钮。业务标题按内容增高并换行,任何入口不得覆盖站点名、客户名或统计时间。
@@ -1,151 +0,0 @@
# 能源氢费经营看板 · 产品需求说明(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 预充值账户
- 现结在本页办理
- 云效建单(默认不上)
@@ -1,30 +0,0 @@
# 氢能经营看板开发交付说明
## 页面入口
- 本地地址:`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
@@ -1,287 +0,0 @@
import type {
CostDim,
FleetScope,
H2OrderRow,
LeaseKind,
OpsKind,
} 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[],
year: number,
verifyScope: 'all' | 'verified',
fleetScope: FleetScope,
): H2OrderRow[] {
return rows.filter((r) => {
if (!r.occurredAt.startsWith(String(year))) return false;
if (verifyScope === 'verified' && r.verifyStatus !== 'verified') return false;
if (fleetScope === 'own' && r.fleet !== 'own') return false;
if (fleetScope === 'external' && r.fleet !== 'external') return false;
return true;
});
}
export function sumAmount(rows: H2OrderRow[]): number {
return rows.reduce((s, r) => s + r.amount, 0);
}
export function sumKg(rows: H2OrderRow[]): number {
return rows.reduce((s, r) => s + r.quantityKg, 0);
}
export function companyCostRows(rows: H2OrderRow[]): H2OrderRow[] {
return rows.filter((r) => r.borneBy === 'company');
}
export function dimAmount(rows: H2OrderRow[], dim: CostDim): number {
return sumAmount(companyCostRows(rows).filter((r) => r.costDim === dim));
}
export function leaseSubAmount(rows: H2OrderRow[], kind: LeaseKind): number {
return sumAmount(
companyCostRows(rows).filter((r) => r.costDim === 'lease' && r.leaseKind === kind),
);
}
export function opsSubAmount(rows: H2OrderRow[], kind: OpsKind): number {
return sumAmount(
companyCostRows(rows).filter((r) => r.costDim === 'ops' && r.opsKind === kind),
);
}
export interface DimCard {
key: CostDim;
label: string;
amount: number;
subs: { key: string; label: string; amount: number }[];
}
export function costDimCards(rows: H2OrderRow[]): DimCard[] {
return [
{
key: 'lease',
label: COST_DIM_LABEL.lease,
amount: dimAmount(rows, 'lease'),
subs: [
{ key: 'company_borne', label: LEASE_KIND_LABEL.company_borne, amount: leaseSubAmount(rows, 'company_borne') },
{ key: 'package_h2', label: LEASE_KIND_LABEL.package_h2, amount: leaseSubAmount(rows, 'package_h2') },
],
},
{
key: 'logistics',
label: COST_DIM_LABEL.logistics,
amount: dimAmount(rows, 'logistics'),
subs: [],
},
{
key: 'ops',
label: COST_DIM_LABEL.ops,
amount: dimAmount(rows, 'ops'),
subs: [
{ key: 'abnormal', label: OPS_KIND_LABEL.abnormal, amount: opsSubAmount(rows, 'abnormal') },
{ key: 'transfer', label: OPS_KIND_LABEL.transfer, amount: opsSubAmount(rows, 'transfer') },
],
},
];
}
export function pendingAmount(rows: H2OrderRow[]): number {
return dimAmount(rows, 'pending');
}
export function unverified(rows: H2OrderRow[]): { amount: number; count: number } {
const list = rows.filter((r) => r.verifyStatus === 'unverified');
return { amount: sumAmount(list), count: list.length };
}
export function formatYuan(n: number): string {
return `¥${n.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`;
}
export function formatKg(n: number): string {
return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} kg`;
}
export function costDimLabel(row: H2OrderRow): string {
if (row.costDim === 'lease' && row.leaseKind) {
return `${COST_DIM_LABEL.lease} · ${LEASE_KIND_LABEL[row.leaseKind]}`;
}
if (row.costDim === 'ops' && row.opsKind) {
return `${COST_DIM_LABEL.ops} · ${OPS_KIND_LABEL[row.opsKind]}`;
}
return COST_DIM_LABEL[row.costDim];
}
export type DimFilter =
| { dim: CostDim; sub?: string }
| null;
export function applyDimFilter(rows: H2OrderRow[], filter: DimFilter): H2OrderRow[] {
if (!filter) return rows;
return rows.filter((r) => {
if (r.borneBy !== 'company') return false;
if (r.costDim !== filter.dim) return false;
if (!filter.sub) return true;
if (filter.dim === 'lease') return r.leaseKind === filter.sub;
if (filter.dim === 'ops') return r.opsKind === filter.sub;
return true;
});
}
/** 统计/明细共用:维度筛后的我司成本行;无维度筛则全部我司行 */
export function companyRowsForStats(rows: H2OrderRow[], filter: DimFilter): H2OrderRow[] {
if (!filter) return companyCostRows(rows);
return applyDimFilter(rows, filter);
}
export interface StationMonthRow {
stationId: string;
stationName: string;
month: string;
amount: number;
quantityKg: number;
unverifiedAmount: number;
}
export function stationMonthAgg(rows: H2OrderRow[]): StationMonthRow[] {
const map = new Map<string, H2OrderRow[]>();
rows.forEach((r) => {
const month = r.occurredAt.slice(0, 7);
const key = `${r.stationId}|${month}`;
const list = map.get(key) ?? [];
list.push(r);
map.set(key, list);
});
return Array.from(map.entries())
.map(([key, list]) => {
const [stationId, month] = key.split('|');
return {
stationId,
stationName: list[0].stationName,
month,
amount: sumAmount(list),
quantityKg: sumKg(list),
unverifiedAmount: sumAmount(list.filter((x) => x.verifyStatus === 'unverified')),
};
})
.sort((a, b) => b.amount - a.amount);
}
export interface CustomerAttrRow {
customerId: string;
customerName: string;
borneLabel: string;
quantityKg: number;
companyCost: number;
unverifiedAmount: number;
}
export function customerAttrAgg(rows: H2OrderRow[]): CustomerAttrRow[] {
const map = new Map<string, H2OrderRow[]>();
rows.forEach((r) => {
const list = map.get(r.customerId) ?? [];
list.push(r);
map.set(r.customerId, list);
});
return Array.from(map.entries())
.map(([customerId, list]) => {
const company = list.filter((x) => x.borneBy === 'company');
const 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,
borneLabel,
quantityKg: sumKg(list),
companyCost: sumAmount(company),
unverifiedAmount: sumAmount(list.filter((x) => x.verifyStatus === 'unverified')),
};
})
.sort((a, b) => b.companyCost - a.companyCost || b.quantityKg - a.quantityKg);
}
export const SOURCE_LABEL: Record<H2OrderRow['source'], string> = {
api: 'API',
manual: '补录',
fence: '围栏',
};
/** 总览 KPI:在宿主示意量级上按当前筛选(年/车辆/核对)等比缩放,保证卡片跟随顶栏筛选 */
export function computeHostKpi(
filtered: H2OrderRow[],
year: number,
allOrders: H2OrderRow[],
base: {
totalKgT: number;
companyKgT: number;
customerKgT: number;
pendingKgT: number;
totalFeeWan: number;
companyFeeWan: number;
customerFeeWan: number;
pendingFeeWan: number;
profitWan: number;
incomeWan: number;
costWan: number;
monthKgT: number;
monthFeeWan: number;
monthYearPct: number;
dayKg: number;
dayFee: number;
dayMonthPct: number;
},
) {
const round2 = (n: number) => Math.round(n * 100) / 100;
const baseline = filterOrders(allOrders, year, 'all', 'all');
const baseKg = sumKg(baseline) || 1;
const fKg = sumKg(filtered);
const ratio = fKg / baseKg;
const companyKg = sumKg(filtered.filter((r) => r.borneBy === 'company'));
const customerKg = sumKg(filtered.filter((r) => r.borneBy === 'customer'));
const 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`));
const monthKg = sumKg(monthRows);
const dayKgVal = sumKg(dayRows);
const monthAmt = sumAmount(monthRows);
const dayAmt = sumAmount(dayRows);
const yearKg = fKg || 1;
const monthKgShare = monthKg / yearKg;
const dayMonthShare = monthKg > 0 ? dayKgVal / monthKg : 0;
const totalKgT = round2(base.totalKgT * ratio);
const totalFeeWan = round2(base.totalFeeWan * ratio);
const incomeWan = round2(base.incomeWan * ratio);
const costWan = round2(base.costWan * ratio);
const profitWan = round2(base.profitWan * ratio);
const monthKgT = round2(totalKgT * monthKgShare);
const monthFeeWan = round2(totalFeeWan * monthKgShare);
return {
totalKgT,
companyKgT: round2(totalKgT * companyShare),
customerKgT: round2(totalKgT * customerShare),
pendingKgT: round2(totalKgT * pendingShare),
totalFeeWan,
companyFeeWan: round2(totalFeeWan * companyShare),
customerFeeWan: round2(totalFeeWan * customerShare),
pendingFeeWan: round2(totalFeeWan * pendingShare),
profitWan,
incomeWan,
costWan,
monthKgT,
monthFeeWan,
monthYearPct: round2(monthKgShare * 100),
dayKg: round2(dayKgVal > 0 ? dayKgVal : base.dayKg * ratio * Math.max(dayMonthShare, 0.01)),
dayFee: Math.round(dayAmt > 0 ? dayAmt : base.dayFee * ratio * Math.max(dayMonthShare, 0.01)),
dayMonthPct: round2((dayMonthShare || base.dayMonthPct / 100) * 100),
profitRatePct: incomeWan > 0 ? round2((profitWan / incomeWan) * 100) : 0,
};
}
@@ -1,191 +0,0 @@
import type { H2OrderRow, StationPrepaid } from '../types';
/** 辅助生成 200 条逼真高质量订单明细假数据 */
function generate200MockOrders(): H2OrderRow[] {
const stations = [
{ id: 'st-ln', name: '佛山南海羚牛加氢站' },
{ id: 'st-dp', name: '东鹏大道甲醇制氢一体站' },
{ id: 'st-jx', name: '嘉兴中石化滨海加氢站' },
{ id: 'st-jj', name: '嘉兴嘉锦加氢站' },
{ id: 'st-cd', name: '成都中石化天府机场高速北站加氢站' },
{ id: 'st-gz', name: '广州黄埔高新区氢能示范加氢站' },
{ id: 'st-sh', name: '上海安亭加氢站' },
];
const ownPlates = [
'粤A99887', '粤B12001', '浙A52088', '浙F77881', '浙A88888F',
'浙A66666', '浙F11223', '浙F33445', '川A77889', '川B99001',
'沪A33219', '粤B88102', '浙F99812', '浙F66521', '粤A11029',
];
const extPlates = [
'粤A77661', '浙F33211', '川A55432', '沪B98765', '粤B66554',
'浙A22334', '粤A99102', '川B88761', '无车牌(散车)',
];
const customers = [
{ id: 'c-ln', name: '羚牛自营', type: 'internal', deptId: 'd-ops', deptName: '运维中心' },
{ id: 'c-bao', name: '包氢专线项目', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' },
{ id: 'c-lease-a', name: '嘉兴智奇供应链', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' },
{ id: 'c-log', name: '嘉兴益顺冷链', type: 'internal', deptId: 'd-log', deptName: '物流中心' },
{ id: 'c-log2', name: '四川群彬物流', type: 'internal', deptId: 'd-log', deptName: '物流中心' },
{ id: 'c-lease-b', name: '无锡铭康物流', type: 'internal', deptId: 'd-lease', deptName: '租赁业务二部' },
{ id: 'c-ops', name: '运维调拨车辆', type: 'internal', deptId: 'd-ops', deptName: '运维中心' },
{ id: 'c-pend', name: '待归属样本', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' },
{ id: 'c-ext-a', name: '广东氢动力', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
{ id: 'c-ext-b', name: '广东清运专线', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
{ id: 'c-ext-c', name: '东展供应链', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
{ id: 'c-ext-d', name: '顺丰冷运专线', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
{ id: 'c-ext-e', name: '极兔速递冷链', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
];
const list: H2OrderRow[] = [];
// 生成 200 条记录
for (let i = 1; i <= 200; i++) {
const padId = String(i).padStart(3, '0');
// 年份分配: 1~145 (2026年), 146~185 (2025年), 186~200 (2024年)
let year = 2026;
let month = Math.floor((i % 8)) + 1; // 1~8月
if (i > 145 && i <= 185) {
year = 2025;
month = Math.floor((i % 12)) + 1;
} else if (i > 185) {
year = 2024;
month = Math.floor((i % 12)) + 1;
}
const day = (i * 7 % 28) + 1;
const hour = (i * 3 % 14) + 7;
const minute = (i * 11 % 50) + 5;
const mm = month < 10 ? `0${month}` : `${month}`;
const dd = day < 10 ? `0${day}` : `${day}`;
const hh = hour < 10 ? `0${hour}` : `${hour}`;
const min = minute < 10 ? `0${minute}` : `${minute}`;
const occurredAt = `${year}-${mm}-${dd} ${hh}:${min}`;
const station = stations[i % stations.length];
const customer = customers[i % customers.length];
const isOwn = customer.type === 'internal';
const fleet = isOwn ? 'own' : 'external';
const plateNo = isOwn
? ownPlates[i % ownPlates.length]
: extPlates[i % extPlates.length];
// 单价 & 加氢量
const unitPrice = [28, 30, 32, 35][i % 4];
const quantityKg = Math.round((18 + (i * 3.7 % 85)) * 100) / 100;
const amount = Math.round(quantityKg * unitPrice);
// 成本维度与费用承担(三类:自行结算并入客户承担)
let borneBy: 'company' | 'customer' | 'pending' = 'company';
let costDim: 'lease' | 'logistics' | 'ops' | 'pending' = 'lease';
let leaseKind: 'company_borne' | 'package_h2' | undefined = undefined;
let opsKind: 'abnormal' | 'transfer' | undefined = undefined;
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 (borneBy !== 'company') {
costDim = 'pending';
leaseKind = undefined;
opsKind = undefined;
} else if (dimType === 0) {
costDim = 'lease';
leaseKind = 'company_borne';
} else if (dimType === 1) {
costDim = 'lease';
leaseKind = 'package_h2';
} else if (dimType === 2) {
costDim = 'logistics';
} else if (dimType === 3) {
costDim = 'ops';
opsKind = i % 2 === 0 ? 'abnormal' : 'transfer';
} else {
costDim = 'pending';
}
// 核对状态与数据来源
const verifyStatus = isOwn ? (i % 4 === 0 ? 'unverified' : 'verified') : 'unverified';
const source = isOwn
? (i % 3 === 0 ? 'manual' : i % 3 === 1 ? 'fence' : 'api')
: (i % 2 === 0 ? 'api' : 'manual');
list.push({
id: `HO-${String(year).slice(2)}${mm}-${padId}`,
occurredAt,
stationId: station.id,
stationName: station.name,
plateNo,
customerId: customer.id,
customerName: customer.name,
deptId: customer.deptId,
deptName: customer.deptName,
amount,
quantityKg,
unitPrice,
borneBy,
costDim,
leaseKind,
opsKind,
verifyStatus,
source,
fleet,
});
}
return list;
}
/** 假数:200 条订单明细(三维度成本拆分) */
export const MOCK_ORDERS: H2OrderRow[] = generate200MockOrders();
export const MOCK_PREPAID: StationPrepaid[] = [
{
stationId: 'st-jx',
stationName: '嘉兴中石化滨海加氢站',
openingBalance: 120000,
openingAnchorLabel: '2025 年末财务期末',
recharge: 80000,
consume: 95000,
},
{
stationId: 'st-jj',
stationName: '嘉兴嘉锦加氢站',
openingBalance: null,
openingAnchorLabel: null,
recharge: 40000,
consume: 28000,
},
];
/** 宿主总览 KPI(三类承担口径,自行结算已并入客户承担) */
export const HOST_KPI = {
totalKgT: 697.16,
companyKgT: 598.01,
customerKgT: 80,
pendingKgT: 19.15,
totalFeeWan: 2093.71,
companyFeeWan: 1795.95,
customerFeeWan: 240,
pendingFeeWan: 57.76,
profitWan: 13.01,
incomeWan: 2106.72,
costWan: 2093.71,
monthKgT: 16.67,
monthFeeWan: 50.36,
monthYearPct: 2.4,
dayKg: 181.78,
dayFee: 6533,
dayMonthPct: 1.1,
};
export const DEFAULT_YEAR = 2026;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,73 +0,0 @@
/** 能源 BI · 氢能总览嵌入功能 · 类型(宿主 bi-next #hydrogen/overview,非 OneOS V2 */
/** 我司成本三维度(本尊 2026-08-07 */
export type CostDim = 'lease' | 'logistics' | 'ops' | 'pending';
/** 租赁成本二级 */
export type LeaseKind = 'company_borne' | 'package_h2';
/** 运维成本二级 */
export type OpsKind = 'abnormal' | 'transfer';
export type VerifyStatus = 'verified' | 'unverified';
/** 氢费承担口径:自行结算并入客户承担,未明确归属的订单进入待核准。 */
export type BorneBy = 'company' | 'customer' | 'pending';
export type FleetScope = 'own' | 'external' | 'all';
/** 按日 | 总览(站日报 / 现结登记已拆独立模块) */
export type HostView = 'daily' | 'overview';
export interface H2OrderRow {
id: string;
occurredAt: string;
stationId: string;
stationName: string;
plateNo: string;
customerId: string;
customerName: string;
deptId: string;
deptName: string;
amount: number;
quantityKg: number;
unitPrice: number;
borneBy: BorneBy;
costDim: CostDim;
leaseKind?: LeaseKind;
opsKind?: OpsKind;
verifyStatus: VerifyStatus;
source: 'api' | 'manual' | 'fence';
fleet: 'own' | 'external';
}
export interface StationPrepaid {
stationId: string;
stationName: string;
openingBalance: number | null;
openingAnchorLabel: string | null;
recharge: number;
consume: number;
}
export const COST_DIM_LABEL: Record<CostDim, string> = {
lease: '租赁成本',
logistics: '物流成本',
ops: '运维成本',
pending: '待归属',
};
export const LEASE_KIND_LABEL: Record<LeaseKind, string> = {
company_borne: '我司承担',
package_h2: '包氢项目',
};
export const OPS_KIND_LABEL: Record<OpsKind, string> = {
abnormal: '异动',
transfer: '调拨',
};
export const BORNE_BY_LABEL: Record<BorneBy, string> = {
company: '我司承担',
customer: '客户承担',
pending: '待核准',
};
export const BORNE_BY_ORDER: BorneBy[] = ['company', 'customer', 'pending'];
@@ -1,109 +0,0 @@
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>
);
}
@@ -1,2 +0,0 @@
export * from './types';
export * from './store';
@@ -1,569 +0,0 @@
/**
* 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,
};
}
@@ -1,65 +0,0 @@
/**
*
* 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';
@@ -1,385 +0,0 @@
.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;
}
@@ -1,13 +0,0 @@
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);
}
});
@@ -1,8 +0,0 @@
/** UA-based phone detection intentionally excludes iPad and Android tablets. */
export function isPhoneUserAgent(ua: string): boolean {
return /iPhone|iPod|Windows Phone/i.test(ua) || (/Android/i.test(ua) && /Mobile/i.test(ua));
}
export function phoneRotation(ua: string, width: number, height: number): boolean {
return isPhoneUserAgent(ua) && height > width;
}
@@ -1,16 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { devMockResponse } from "./dev-mock-api";
test("开发 mock 覆盖健康检查与氢能 v2 只读接口", () => {
for (const path of [
"/api/health",
"/api/energy/h2/v2/meta",
"/api/energy/h2/v2/overview",
"/api/energy/h2/v2/daily",
"/api/energy/h2/v2/daily-tree",
"/api/energy/h2/v2/drill",
]) assert.ok(devMockResponse(path), path);
assert.equal(devMockResponse("/api/unknown"), undefined);
});
-175
View File
@@ -1,175 +0,0 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Plugin } from "vite";
const station = {
id: "101",
name: "本地验收加氢站",
province: "浙江省",
city: "嘉兴市",
kg: 12345.67,
lingniuKg: 10000,
externalKg: 2345.67,
cost: 23456.78,
revenue: 34567.89,
customerRevenue: 34567.89,
customerCost: 18000,
companyCost: 4000,
otherCost: 1456.78,
recordCount: 42,
customerCount: 1,
share: 100,
};
const customer = {
id: 1,
name: "本地验收客户",
kg: 12345.67,
customerBearingKg: 10000,
companyBearingKg: 2000,
otherBearingKg: 345.67,
bearer: "both",
cost: 23456.78,
revenue: 34567.89,
customerRevenue: 34567.89,
customerCost: 18000,
companyCost: 4000,
otherCost: 1456.78,
recordCount: 42,
};
const range = { startDate: "2026-01-01", endDate: "2026-08-31" };
const watermark = { ledgerAt: "2026-08-31 16:00:00", paymentAt: null };
const kpis = {
totalKg: 12345.67,
totalCost: 23456.78,
customerBearingKg: 10000,
companyBearingKg: 2000,
otherBearingKg: 345.67,
customerRevenue: 34567.89,
customerCost: 18000,
companyCost: 4000,
otherCost: 1456.78,
totalRevenue: 34567.89,
customerGrossProfit: 16567.89,
monthKg: 3456.78,
monthCost: 6789.01,
todayKg: 123.45,
todayCost: 234.56,
monthShareOfRange: 28,
todayShareOfMonth: 3.57,
recordCount: 42,
stationCount: 1,
};
const overview = {
range,
watermark,
filters: {},
kpis,
monthly: [{
month: "2026-08",
totalKg: 12345.67,
lingniuKg: 10000,
externalKg: 2345.67,
cost: 23456.78,
customerCost: 18000,
companyCost: 4000,
otherCost: 1456.78,
revenue: 34567.89,
customerRevenue: 34567.89,
customerGrossProfit: 16567.89,
}],
topStations: [station],
regions: [{ region: "嘉兴市", kg: 12345.67, share: 100 }],
stations: [station],
customers: [customer],
};
const dailyPoint = {
date: "2026-08-31",
kg: 123.45,
lingniuKg: 100,
externalKg: 23.45,
cost: 234.56,
recordCount: 2,
stationCount: 1,
};
export function devMockResponse(pathname: string) {
if (pathname === "/api/health") return { status: "ok", source: "dev-mock" };
if (pathname.endsWith("/meta")) return {
years: [{ value: 2026, startDate: range.startDate, endDate: range.endDate }],
stations: [{ id: station.id, name: station.name }],
watermark,
};
if (pathname.endsWith("/overview")) return overview;
if (pathname.endsWith("/daily-tree")) return {
date: dailyPoint.date,
stations: [{
id: station.id,
name: station.name,
kg: dailyPoint.kg,
cost: dailyPoint.cost,
recordCount: 2,
customers: [{ id: customer.id, name: customer.name, kg: dailyPoint.kg, cost: dailyPoint.cost, recordCount: 2 }],
}],
};
if (pathname.endsWith("/daily")) return {
range,
watermark,
filters: {},
kpis: { totalKg: 12345.67, totalCost: 23456.78, averageDailyKg: 823.04, stationCount: 1, activeDays: 15 },
trend: [dailyPoint],
days: [dailyPoint],
};
if (pathname.endsWith("/drill")) return {
groupBy: "record",
amountScope: "all",
filters: {},
summary: { recordCount: 1, kg: 123.45, cost: 4000, revenue: 4320 },
groups: [{ ...station, stationCount: 1, customerCount: 1 }],
records: [{
id: 1,
time: "2026-08-31 12:00:00",
orderNo: "DEV-001",
stationId: station.id,
stationName: station.name,
customerId: customer.id,
customerName: customer.name,
plateNo: "浙FDEV01",
source: "dev-mock",
verifyStatus: "verified",
vehicleId: 1,
kg: 123.45,
unitPrice: 35,
cost: 4000,
revenue: 4320,
}],
page: { page: 1, pageSize: 100, hasMore: false },
};
return undefined;
}
function sendJson(response: ServerResponse, body: unknown) {
response.statusCode = 200;
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.end(JSON.stringify(body));
}
export function energyDevMockApi(): Plugin {
return {
name: "energy-dev-mock-api",
configureServer(server) {
server.middlewares.use((request: IncomingMessage, response: ServerResponse, next) => {
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
if (pathname === "/favicon.ico") {
response.statusCode = 204;
return response.end();
}
const body = devMockResponse(pathname);
if (body === undefined) return next();
sendJson(response, body);
});
},
};
}
@@ -1,143 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fetchAllH2BiDrill, fetchAllH2BiDrillRecords } from "./api";
import type { H2BiDrillResponse } from "./types";
const query = {
year: 2026,
vehicleScope: "all" as const,
verifyScope: "all" as const,
};
function response(overrides: Partial<H2BiDrillResponse> = {}): H2BiDrillResponse {
return {
groupBy: "station",
amountScope: "all",
filters: {},
summary: {},
groups: [],
records: [],
page: { page: 1, pageSize: 2, hasMore: false },
...overrides,
};
}
async function withFetch(
handler: (url: URL) => H2BiDrillResponse | Promise<H2BiDrillResponse>,
run: () => Promise<void>,
) {
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
const url = new URL(String(input), "http://ln-bi.local");
return new Response(JSON.stringify(await handler(url)), { status: 200 });
}) as typeof fetch;
try {
await run();
} finally {
globalThis.fetch = original;
}
}
test("完整分组读取跨页,并以短页而非旧服务 hasMore 字段确认结束", async () => {
const pages: number[] = [];
await withFetch((url) => {
const page = Number(url.searchParams.get("page"));
pages.push(page);
return response({
groups: page === 1
? ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 }))
: [{ id: "3", name: "丙", province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 }],
});
}, async () => {
const result = await fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2 });
assert.deepEqual(result.groups.map((row) => row.name), ["甲", "乙", "丙"]);
assert.equal(result.page.complete, true);
assert.equal(result.page.pagesRead, 2);
});
assert.deepEqual(pages, [1, 2]);
});
test("完整记录读取跨页后保留全部订单", async () => {
await withFetch((url) => {
const page = Number(url.searchParams.get("page"));
return response({
groupBy: "record",
records: page === 1
? [{ id: "a" }, { id: "b" }]
: [{ id: "c" }],
});
}, async () => {
const result = await fetchAllH2BiDrillRecords(query, { pageSize: 2 });
assert.deepEqual(result.records.map((row) => row.id), ["a", "b", "c"]);
assert.equal(result.page.hasMore, false);
});
});
test("全量读取在中途请求失败时拒绝,不返回部分结果", async () => {
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
const page = new URL(String(input), "http://ln-bi.local").searchParams.get("page");
if (page === "2") return new Response("failed", { status: 502, statusText: "Bad Gateway" });
return new Response(JSON.stringify(response({
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
})), { status: 200 });
}) as typeof fetch;
try {
await assert.rejects(
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2 }),
/API error: 502/,
);
} finally {
globalThis.fetch = original;
}
});
test("空结果是完整结果而不是加载失败", async () => {
await withFetch(() => response(), async () => {
const result = await fetchAllH2BiDrill({ ...query, groupBy: "station" });
assert.deepEqual(result.groups, []);
assert.equal(result.page.pagesRead, 1);
});
});
test("取消的全量读取不会发起请求或生成部分数据", async () => {
const controller = new AbortController();
controller.abort();
await assert.rejects(
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { signal: controller.signal }),
(error: Error) => error.name === "AbortError",
);
});
test("全量读取在第一页完成后也会响应取消,不会请求下一页", async () => {
const controller = new AbortController();
let calls = 0;
await withFetch(() => {
calls += 1;
controller.abort();
return response({
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
});
}, async () => {
await assert.rejects(
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { signal: controller.signal, pageSize: 2 }),
(error: Error) => error.name === "AbortError",
);
});
assert.equal(calls, 1);
});
test("maxRows 和 maxPages 均拒绝不完整的全量读取", async () => {
await withFetch(() => response({
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
}), async () => {
await assert.rejects(
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2, maxRows: 1 }),
/超过 1 条保护上限/,
);
await assert.rejects(
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2, maxPages: 1 }),
/超过 1 页保护上限/,
);
});
});
@@ -1,23 +0,0 @@
import type { ReactNode } from "react";
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
export function DailyTreeButton({ open, label, children, onClick }: {
open: boolean; label: string; children: ReactNode; onClick: () => void;
}) {
const Icon = open ? ChevronDown : ChevronRight;
return <button type="button" className="ehb-daily-disclosure" aria-expanded={open}
aria-label={`${open ? "收起" : "展开"}${label}`} onClick={onClick}>
<Icon size={15} aria-hidden="true" /><span>{children}</span>
</button>;
}
export function DailyBranchState({ columns, error, empty, onRetry }: {
columns: number; error?: string; empty?: boolean; onRetry: () => void;
}) {
return <tr className="ehb-daily-branch-state"><td colSpan={columns}>
<div role={error ? "alert" : "status"}>
{error || (empty ? "当前范围暂无明细" : "正在加载明细…")}
{error ? <button type="button" onClick={onRetry}><RefreshCw size={14} aria-hidden="true" /></button> : null}
</div>
</td></tr>;
}
@@ -1,52 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { DailyBranchState, DailyTreeButton } from "./daily-detail-controls";
import { dailySummaryRows, formatDailyChange } from "../model/daily-detail-format";
import type { H2BiDailyResponse } from "../types";
test("每日环比区分真实零值、缺失值和非数值", () => {
for (const value of [null, undefined, NaN, Infinity, "0"]) assert.equal(formatDailyChange(value), "环比 —");
assert.equal(formatDailyChange(0), "环比 0.00%");
assert.equal(formatDailyChange(-12.5), "环比 -12.50%");
assert.equal(formatDailyChange(12.5), "环比 +12.50%");
});
test("日期汇总导出包含全部日期,不依赖已展开明细并保留真零", () => {
const daily = {
kpis: { stationCount: 2, totalKg: 15.25, totalCost: 450 },
days: [
{ date: "2026-09-03", stationCount: 2, kg: 15.25, cost: 450 },
{ date: "2026-09-02", stationCount: 0, kg: 0, cost: 0 },
],
} as H2BiDailyResponse;
const rows = dailySummaryRows(daily);
assert.equal(rows.length, 4);
assert.deepEqual(rows[1], ["区间合计", 2, 15.25, 450]);
assert.deepEqual(rows[3], ["2026-09-02", 0, 0, 0]);
});
test("层级按钮包含键盘原生语义与明确展开状态", () => {
for (const open of [false, true]) {
const html = renderToStaticMarkup(createElement(DailyTreeButton, {
open, label: "测试站客户明细", children: "测试站", onClick() {},
}));
assert.match(html, /<button type="button"/);
assert.ok(html.includes(`aria-expanded="${open}"`));
assert.ok(html.includes(`${open ? "收起" : "展开"}测试站客户明细`));
}
});
test("展开状态区分加载、空数据、失败及重试入口", () => {
const render = (props: Partial<Parameters<typeof DailyBranchState>[0]>) => renderToStaticMarkup(createElement(DailyBranchState, {
columns: 3, onRetry() {}, ...props,
}));
assert.match(render({}), /正在加载明细/);
assert.match(render({ empty: true }), /当前范围暂无明细/);
const error = render({ error: "站点明细加载失败,请重试" });
assert.match(error, /role="alert"/);
assert.match(error, /<button type="button"/);
assert.match(error, /colSpan="3"/i);
assert.doesNotMatch(error, /正在加载明细/);
});
@@ -1,688 +0,0 @@
@import './drill-workspace.css';
.ehb-drill-modal--unified .ehb-modal-body { padding: 18px; background: #f6f8fb; }
.ehb-drill-modal--unified .ehb-drill-root-tabs { display: flex; justify-content: flex-end; margin: 0 0 12px; }
.ehb-drill-modal--unified .ehb-modal-meta-bar {
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0;
padding: 0; overflow: hidden; border: 1px solid #dfe7f0; border-radius: 8px; background: #fff;
}
.ehb-drill-modal--unified .ehb-modal-meta-item { min-width: 0; padding: 12px 16px; border-right: 1px solid #dfe7f0; }
.ehb-drill-modal--unified .ehb-modal-meta-item:last-child { border-right: 0; }
.ehb-drill-modal--unified .ehb-modal-meta-label { color: #64748b; }
.ehb-drill-modal--unified .ehb-modal-meta-val { color: #172238; font-family: var(--bi-font-mono); font-weight: 750; }
.ehb-drill-modal--unified .ehb-modal-filter-row {
gap: 8px; padding: 10px 12px; margin: 12px 0; border: 1px solid #dfe7f0;
border-radius: 8px; background: #fff;
}
.ehb-drill-modal--unified .ehb-modal-filter-group { width: 100%; gap: 8px; }
.ehb-drill-modal--unified .ehb-modal-hint-text { flex: 1 1 280px; min-width: 220px; color: #7b8aa1; }
.ehb-drill-modal--unified .ehb-modal-table-wrap { border-color: #d7e1ed; border-radius: 8px; background: #fff; }
/* Reserve space for pagination instead of clipping it below a fixed-height table. */
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) {
display: flex;
flex-direction: column;
min-height: 0;
}
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) > * {
flex-shrink: 0;
}
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) > .ehb-modal-table-wrap {
flex: 1 1 auto;
min-height: 100px;
}
.ehb-drill-page-controls {
display: grid !important;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.ehb-drill-page-controls .ehb-btn {
flex: 0 0 auto;
min-width: 72px;
width: 72px !important;
min-height: 40px;
white-space: nowrap;
}
.ehb-drill-modal--unified .ehb-modal-table th {
height: 44px; padding: 10px 12px; border-bottom-color: #d7e1ed;
background: #f0f3f8; color: #52627a;
}
.ehb-drill-modal--unified .ehb-modal-table > tbody > tr > td {
height: 48px; padding-block: 10px; border-bottom-color: #e5ebf2; color: #334155;
}
.ehb-drill-modal--unified .ehb-drill-group-row--station > td { background: #edf3fa; }
.ehb-drill-modal--unified .ehb-drill-group-row--customer > td { background: #f5f7fa; }
.ehb-drill-modal--unified .ehb-drill-group-row--vehicle > td { background: #fafbfd; }
.ehb-drill-modal--unified .ehb-drill-group-row:hover > td { background: #e8f0fb; }
.ehb-drill-modal--unified .ehb-tree-toggle {
display: inline-grid; width: 24px; min-width: 24px; height: 24px; place-items: center;
padding: 0; border: 0; border-radius: 5px; background: transparent; color: #2f6bff;
font-weight: 800; cursor: pointer;
}
.ehb-drill-modal--unified .ehb-tree-toggle:hover,
.ehb-drill-modal--unified .ehb-tree-toggle:focus-visible { background: #eaf1ff; outline: none; }
.ehb-drill-modal--unified .ehb-tree-drill-link {
margin-left: auto; padding: 0; border: 0; background: transparent; color: #2f6bff;
font: inherit; font-size: 12px; font-weight: 700; white-space: nowrap; cursor: pointer;
}
.ehb-drill-modal--unified .ehb-tree-drill-link:hover,
.ehb-drill-modal--unified .ehb-tree-drill-link:focus-visible { color: #174ebc; text-decoration: underline; outline: none; }
.ehb-drill-modal--unified .ehb-modal-table td[style*="text-align: right"] {
color: #334155; font-family: var(--bi-font-mono); font-variant-numeric: tabular-nums;
}
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-volume { color: #2f6bff !important; font-weight: 750; }
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-income { color: #2c8a78 !important; font-weight: 750; }
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-cost { color: #e28a24 !important; font-weight: 750; }
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-profit { color: #2f6bff !important; font-weight: 750; }
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-external { color: #7ea5ec !important; font-weight: 750; }
.ehb-drill-modal--unified .ehb-summary-volume,
.ehb-drill-modal--unified .ehb-summary-profit { color: #2f6bff; }
.ehb-drill-modal--unified .ehb-summary-income { color: #2c8a78; }
.ehb-drill-modal--unified .ehb-summary-cost { color: #e28a24; }
.ehb-drill-modal--unified .ehb-flat-drill-table tbody tr:nth-child(even) td { background: #fafbfd; }
.ehb-drill-modal--unified .ehb-drill-group-row--date td { background: #fff; }
.ehb-drill-modal--unified .ehb-day-change.is-up { color: #18a67a; font-weight: 700; }
.ehb-drill-modal--unified .ehb-day-change.is-down { color: #e26464; font-weight: 700; }
.ehb-drill-modal--unified .ehb-bearer-tag.is-cust { color: #b86606; border: 1px solid #f2d26c; background: #fffbea; }
.ehb-drill-modal--unified .ehb-bearer-tag.is-lingniu { color: #2f6bff; border: 1px solid #bdd0fb; background: #eef4ff; }
.ehb-drill-modal--unified .ehb-bearer-tag.is-other { color: #64748b; border: 1px solid #cbd5e1; background: #f8fafc; }
.ehb-drill-modal--unified .ehb-tag--source-api,
.ehb-drill-modal--unified .ehb-tag--source-station,
.ehb-drill-modal--unified .ehb-tag--source-lingniu {
border: 1px solid #cbd9e8; background: #eef4f9; color: #526b88;
}
.ehb-drill-local-error {
display: flex;
min-height: 180px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px;
color: #475569;
text-align: center;
}
.ehb-drill-local-error strong { color: #b42318; font-size: 16px; }
.ehb-drill-local-error span { max-width: 520px; line-height: 1.6; }
.ehb-drill-loading {
position: relative;
min-height: 300px;
overflow: hidden;
background: #fff;
animation: ehb-drill-fade-in .18s ease-out both;
}
.ehb-drill-loading__progress {
position: absolute;
inset: 0 auto auto 0;
width: 38%;
height: 3px;
border-radius: 999px;
background: linear-gradient(90deg, transparent, #2f6bff 45%, #67b5ff, transparent);
animation: ehb-drill-progress 1.15s ease-in-out infinite;
}
.ehb-drill-loading__label {
display: flex;
align-items: center;
gap: 12px;
padding: 22px 24px 18px;
border-bottom: 1px solid #e7edf5;
color: #1e293b;
}
.ehb-drill-loading__label > span:last-child {
display: flex;
flex-direction: column;
gap: 3px;
}
.ehb-drill-loading__label strong { font-size: 14px; }
.ehb-drill-loading__label small { color: #7b8aa0; font-size: 12px; }
.ehb-drill-loading__spinner {
width: 20px;
height: 20px;
flex: 0 0 20px;
border: 2px solid #dbe7fb;
border-top-color: #2f6bff;
border-radius: 50%;
animation: ehb-drill-spin .72s linear infinite;
}
.ehb-drill-loading__rows { padding: 4px 18px 18px; }
.ehb-drill-loading__row {
display: grid;
grid-template-columns: minmax(180px, 2.2fr) repeat(3, minmax(90px, 1fr));
gap: 28px;
align-items: center;
min-width: 680px;
height: 48px;
border-bottom: 1px solid #eef2f7;
}
.ehb-drill-loading__row i {
display: block;
height: 11px;
border-radius: 999px;
background: linear-gradient(100deg, #edf2f8 20%, #f8fafc 42%, #e7eef8 64%);
background-size: 220% 100%;
animation: ehb-drill-shimmer 1.25s ease-in-out infinite;
}
.ehb-drill-loading__row i:nth-child(2) { width: 72%; }
.ehb-drill-loading__row i:nth-child(3) { width: 58%; }
.ehb-drill-loading__row i:nth-child(4) { width: 82%; }
.ehb-modal-table-wrap.is-ready > .ehb-modal-table {
animation: ehb-drill-content-in .24s ease-out both;
}
@keyframes ehb-drill-spin { to { transform: rotate(360deg); } }
@keyframes ehb-drill-progress {
0% { transform: translateX(-110%); opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
100% { transform: translateX(370%); opacity: 0; }
}
@keyframes ehb-drill-shimmer { to { background-position: -220% 0; } }
@keyframes ehb-drill-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes ehb-drill-content-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.ehb-drill-loading,
.ehb-drill-loading__progress,
.ehb-drill-loading__spinner,
.ehb-drill-loading__row i,
.ehb-modal-table-wrap.is-ready > .ehb-modal-table { animation: none; }
}
@media (max-width: 760px) {
.ehb-drill-modal--unified .ehb-modal-meta-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ehb-drill-modal--unified .ehb-modal-meta-item:nth-child(2) { border-right: 0; }
.ehb-drill-modal--unified .ehb-modal-meta-item:nth-child(-n + 2) { border-bottom: 1px solid #dfe7f0; }
}
.ehb-real-drill-filter-summary {
display: none;
}
@media (max-width: 767px) {
.ehb-drill-modal--unified .ehb-modal-head {
gap: 8px;
padding: 10px 12px;
}
.ehb-drill-modal--unified .ehb-modal-head__title-group {
min-width: 0;
}
.ehb-drill-modal--unified .ehb-modal-head__title {
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ehb-drill-modal--unified .ehb-modal-head__sub {
font-size: 11px;
}
.ehb-drill-modal--unified .ehb-modal-head__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
}
.ehb-drill-modal--unified .ehb-modal-head__actions .mobile-list-fullscreen-trigger {
position: static;
min-width: 76px;
height: 32px;
padding-inline: 8px;
}
.ehb-drill-modal--unified .ehb-modal-body {
padding: 10px;
}
.ehb-drill-modal--unified .ehb-modal-meta-item {
padding: 9px 10px;
}
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
.ehb-drill-modal--unified .ehb-modal-table td:first-child {
width: 235px;
min-width: 235px;
}
/* 宽表左右移动时首列保留完整的站点/客户名称、层级三角和同省标识。 */
.ehb-drill-modal--unified .ehb-modal-table-wrap { overflow: auto !important; }
.ehb-drill-modal--unified .ehb-modal-table { min-width: max-content; }
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
.ehb-drill-modal--unified .ehb-modal-table td:first-child {
position: sticky;
left: 0;
z-index: 3;
background: #fff;
box-shadow: 6px 0 10px -10px #0f172a;
}
.ehb-drill-modal--unified .ehb-modal-table thead th:first-child {
z-index: 5;
background: #f0f3f8;
}
.ehb-drill-modal--unified .ehb-drill-group-row--station > td:first-child { background: #edf3fa; }
.ehb-drill-modal--unified .ehb-drill-group-row--customer > td:first-child { background: #f5f7fa; }
.ehb-drill-modal--unified .ehb-drill-group-row--vehicle > td:first-child { background: #fafbfd; }
.ehb-real-drill-filter-summary {
display: grid;
width: 100%;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
margin: 0 0 8px;
padding: 10px 12px;
border: 1px solid #dbe4f1;
border-radius: 10px;
background: #f8fafc;
color: #64748b;
text-align: left;
}
.ehb-real-drill-filter-summary > span {
display: inline-flex;
align-items: center;
gap: 5px;
color: #2f6bff;
font-weight: 700;
}
.ehb-real-drill-filter-summary strong {
overflow: hidden;
color: #1e293b;
text-overflow: ellipsis;
white-space: nowrap;
}
.ehb-real-drill-filter-summary > svg {
transition: transform .18s ease;
}
.ehb-real-drill-filter-summary > svg.is-open {
transform: rotate(180deg);
}
.ehb-real-drill-filter-panel:not(.is-open) {
display: none !important;
}
.ehb-real-drill-filter-panel.is-open {
display: flex !important;
}
.ehb-drill-modal--unified .ehb-modal-hint-text {
width: 100%;
overflow: visible !important;
white-space: normal !important;
text-overflow: clip !important;
line-height: 1.45;
}
.ehb-drill-modal--unified .ehb-modal-search-input,
.ehb-drill-modal--unified .ehb-modal-search-input input {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
}
/* 小屏横向空间充足时,把摘要和筛选压成一行,优先留高度给下钻表格。 */
@media (max-height: 500px) and (orientation: landscape) {
.ehb-drill-modal--unified .ehb-modal-head { padding-block: 6px; }
.ehb-drill-modal--unified .ehb-modal-head__sub { display: inline; margin-left: 8px; }
.ehb-drill-modal--unified .ehb-modal-body { padding: 6px 8px 8px; }
.ehb-drill-modal--unified .ehb-drill-root-tabs { margin-bottom: 6px; }
.ehb-drill-modal--unified .ehb-modal-meta-bar {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 6px;
}
.ehb-drill-modal--unified .ehb-modal-meta-item {
padding: 6px 10px;
border-right: 1px solid #dfe7f0;
border-bottom: 0 !important;
}
.ehb-drill-modal--unified .ehb-modal-meta-item:last-child { border-right: 0; }
.ehb-drill-modal--unified .ehb-modal-filter-row { margin-bottom: 6px; padding: 6px; }
.ehb-drill-modal--unified .ehb-modal-hint-text { display: none; }
.ehb-drill-modal--unified .ehb-drill-loading { min-height: 150px; }
}
/* “横屏查看”是页面布局模式:不依赖手机方向锁定。 */
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] {
display: flex;
flex-direction: column;
overflow: hidden !important;
padding: 0 !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
flex: 0 0 auto;
padding: 7px 10px;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-body {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
padding: 7px 8px 8px;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-bar {
grid-template-columns: repeat(4, minmax(150px, 1fr));
margin-bottom: 6px;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-item {
padding: 6px 9px;
border-right: 1px solid #dfe7f0;
border-bottom: 0;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-filter-row {
flex: 0 0 auto;
margin-bottom: 6px;
padding: 6px;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-table-wrap {
min-height: 0;
flex: 1 1 auto;
overflow: auto !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-hint-text,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-filter-summary {
display: none;
}
/* 下钻路径是导航,不是装饰:允许直接回到任一上级。 */
.ehb-drill-breadcrumbs {
display: flex;
flex: 0 0 auto;
min-width: 0;
align-items: center;
gap: 5px;
padding: 9px 18px;
overflow-x: auto;
border-bottom: 1px solid #dfe7f0;
background: #fff;
scrollbar-width: none;
}
.ehb-drill-breadcrumbs::-webkit-scrollbar { display: none; }
.ehb-drill-breadcrumb { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 5px; }
.ehb-drill-breadcrumb i { color: #9aabc0; font-style: normal; }
.ehb-drill-breadcrumb button {
max-width: 210px;
padding: 4px 7px;
overflow: hidden;
border: 0;
border-radius: 7px;
background: transparent;
color: #3564a5;
font: 650 12px/1.3 var(--bi-font);
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.ehb-drill-breadcrumb button:not(:disabled):hover { background: #edf4ff; color: #1f5fe0; }
.ehb-drill-breadcrumb button[aria-current="page"] {
background: #edf3fc;
color: #24344e;
cursor: default;
}
@media (max-width: 767px) {
.ehb-drill-modal--unified {
display: flex !important;
flex-direction: column !important;
}
.ehb-drill-modal--unified .ehb-modal-head { flex: 0 0 auto !important; }
.ehb-drill-modal--unified .ehb-modal-close-btn { display: none !important; }
.ehb-drill-modal--unified .ehb-modal-back-btn {
min-width: 108px !important;
min-height: 42px !important;
justify-content: center;
border-color: rgb(255 255 255 / 28%) !important;
background: rgb(255 255 255 / 10%) !important;
color: #fff !important;
font-weight: 700 !important;
}
.ehb-drill-breadcrumbs {
padding: 8px 10px;
box-shadow: 0 3px 10px rgb(32 55 89 / 6%);
}
.ehb-drill-breadcrumb button { max-width: 150px; min-height: 30px; font-size: 11px; }
.ehb-drill-modal--unified .ehb-modal-body {
height: auto !important;
min-height: 0 !important;
flex: 1 1 auto !important;
}
.ehb-drill-modal--unified .ehb-tree-drill-link { font-size: 10px !important; }
.ehb-drill-modal--unified .ehb-drill-group-row { min-height: 58px; }
.ehb-drill-modal--unified .ehb-drill-group-row > td:first-child { min-width: 220px !important; }
.ehb-drill-modal--unified .ehb-tree-node-title { display: flex; align-items: center; gap: 4px; }
.ehb-drill-modal--unified .ehb-tree-node-name {
display: inline-flex;
min-width: 0;
flex: 1 1 240px;
flex-wrap: wrap;
align-items: baseline;
gap: 4px;
}
.ehb-drill-modal--unified .ehb-tree-node-name strong { min-width: 0; overflow-wrap: anywhere; }
.ehb-drill-modal--unified .ehb-tree-expanded-row > td { padding: 0 !important; background: #f8fbff; }
.ehb-drill-modal--unified .ehb-tree-expanded-content { padding: 8px 14px 10px 38px; border-bottom: 1px solid #dbe7f5; }
.ehb-drill-modal--unified .ehb-tree-expanded-content ul { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 5px 10px; margin: 0; padding: 0; list-style: none; }
.ehb-drill-modal--unified .ehb-tree-expanded-content li { display: flex; justify-content: space-between; gap: 8px; padding: 5px 7px; color: #31578c; }
.ehb-drill-modal--unified .ehb-tree-expanded-all { margin: 8px 7px 0; padding: 3px 0; border: 0; background: transparent; color: #2563eb; font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; }
.ehb-drill-modal--unified .ehb-tree-expanded-all:hover { text-decoration: underline; }
.ehb-drill-modal--unified .ehb-tree-expanded-content small { color: #64748b; white-space: nowrap; }
}
@media (max-width: 767px) and (orientation: landscape) {
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
.ehb-drill-modal--unified .ehb-modal-table td:first-child { width: 300px; min-width: 300px; }
}
/* 竖屏宽表保持原方向;表格容器负责横向滚动,绝不旋转整个页面。 */
@media (max-width: 767px) and (orientation: portrait) {
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback {
display: flex !important;
flex-direction: column !important;
padding: 0 !important;
overflow: auto !important;
background: #f4f7fb !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table-wrap {
width: 100% !important;
min-width: 0 !important;
min-height: 240px !important;
border-radius: 8px !important;
overflow: auto !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table {
min-width: 960px !important;
font-size: 10px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table th,
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table td {
min-width: 112px !important;
height: 38px !important;
min-height: 38px !important;
padding: 6px 8px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table th:first-child,
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table td:first-child {
width: 190px !important;
min-width: 190px !important;
max-width: 190px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback::after {
right: 8px !important;
bottom: 8px !important;
padding: 4px 8px !important;
font-size: 10px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback,
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback * {
transform: none !important;
}
}
/* 原生横屏与旋转兼容统一进入专注阅读状态。 */
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-breadcrumbs,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-root-tabs,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-bar,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-filter-summary,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-filter-row,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-usage-guide {
display: none !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
min-height: 42px !important;
height: 42px !important;
padding: 4px 142px 4px 8px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__sub {
display: none !important;
}
/* 专注阅读仍是可导航的下钻页面;必须能逐层返回,不能要求先退出宽表。 */
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn {
display: inline-flex !important;
width: 44px !important;
min-width: 44px !important;
min-height: 44px !important;
padding: 0 !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
padding: 2px 88px 2px 8px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn > span {
display: none;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title-group {
display: grid !important;
grid-template-columns: 44px minmax(0, 1fr);
width: 100%;
min-width: 0;
align-items: center;
gap: 7px;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title-group > div {
min-width: 0;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title {
max-width: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-primary-actions,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger {
width: 80px !important;
min-width: 80px !important;
}
@media (max-width: 767px) {
/* 让三角、名称和轻量下钻提示在同一行网格内分配空间;长名称仅在名称格内换行。 */
.ehb-drill-modal--unified .ehb-tree-node-title {
display: grid !important;
grid-template-columns: 28px minmax(0, 1fr) 24px;
align-items: start;
gap: 3px;
width: 100%;
}
.ehb-drill-modal--unified .ehb-tree-toggle { width: 28px; min-width: 28px; height: 28px; }
.ehb-drill-modal--unified .ehb-tree-node-name {
display: block;
min-width: 0;
flex: none;
overflow-wrap: anywhere;
}
.ehb-drill-modal--unified .ehb-tree-node-name .ehb-tree-node-sub { margin-left: 4px; }
.ehb-drill-modal--unified .ehb-tree-drill-link {
display: inline-grid !important;
width: 24px !important;
min-width: 24px !important;
height: 28px !important;
place-items: center;
margin: 0 !important;
overflow: hidden;
color: transparent !important;
font-size: 0 !important;
text-decoration: none !important;
}
.ehb-drill-modal--unified .ehb-tree-drill-link::after { content: "" !important; color: #2563eb; font-size: 20px; line-height: 1; }
/* 普通弹层的筛选和宽表入口各占一个网格列;退出宽表后不保留绝对定位层。 */
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .ehb-real-drill-primary-actions {
position: static !important;
display: grid !important;
grid-template-columns: minmax(0, 1fr) 112px;
align-items: stretch;
gap: 8px;
width: 100%;
}
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .mobile-list-fullscreen-trigger.is-inline {
position: static !important;
inset: auto !important;
width: 112px !important;
min-width: 112px !important;
min-height: 48px !important;
}
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .mobile-list-fullscreen-trigger.is-inline::before {
display: none;
}
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-primary-actions {
position: absolute !important;
z-index: 280 !important;
top: 5px !important;
right: 8px !important;
display: block !important;
width: 126px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger {
position: static !important;
display: inline-flex !important;
width: 126px !important;
min-width: 126px !important;
height: 32px !important;
min-height: 32px !important;
border-radius: 8px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-body {
display: flex !important;
min-height: 0 !important;
flex: 1 1 auto !important;
padding: 5px 6px 6px !important;
gap: 0 !important;
overflow: hidden !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-table-wrap {
width: 100% !important;
min-width: 0 !important;
min-height: 0 !important;
max-height: none !important;
flex: 1 1 auto !important;
margin: 0 !important;
overflow: auto !important;
}
@@ -1,179 +0,0 @@
/* Shared drill workspace: navigation stays visible, data owns the remaining height. */
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] {
display: flex !important;
flex-direction: column !important;
}
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] > .ehb-modal-body {
flex: 1 1 0 !important;
min-height: 0 !important;
padding: 4px 8px !important;
gap: 3px !important;
}
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] .ehb-modal-table {
width: 100% !important;
min-width: 1050px !important;
}
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] .ehb-modal-table th,
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] .ehb-modal-table td {
padding: 7px 9px !important;
font-size: 11px !important;
}
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] .ehb-drill-page-controls .ehb-btn {
min-height: 32px !important;
padding: 4px 10px !important;
}
.ehb-drill-modal--unified .ehb-inline-child-row > td { background: #f8fbff; border-bottom: 1px solid #e2eaf4; }
.ehb-drill-modal--unified .ehb-inline-child-row > td:first-child { background: #f8fbff !important; }
.ehb-inline-child-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
.ehb-inline-child-name > span { overflow-wrap: anywhere; font-weight: 600; }
.ehb-inline-child-name small { display: block; color: #64748b; }
.ehb-inline-child-toggle { flex: 0 0 32px; width: 32px; height: 36px; border: 0; border-radius: 6px; color: #2563eb; background: #eaf2ff; cursor: pointer; }
.ehb-drill-modal--unified .ehb-inline-child-status > td,
.ehb-drill-modal--unified .ehb-tree-expanded-row > td { padding: 8px 16px !important; background: #f8fbff; }
.ehb-drill-modal--unified .ehb-tree-expanded-all { border: 0; background: transparent; color: #2563eb; font-size: 12px; font-weight: 600; cursor: pointer; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title-group {
min-width: 0;
gap: 14px;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title-group > div { min-width: 0; }
@media (max-width: 767px) {
.ehb-modal-card.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) > .ehb-modal-head {
min-height: 68px;
padding: 10px 12px !important;
box-sizing: border-box;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title-group {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-back-btn {
width: 44px !important;
min-width: 44px !important;
height: 44px !important;
min-height: 44px !important;
flex: 0 0 44px;
padding: 0 !important;
border: 0 !important;
border-radius: 10px;
background: rgb(255 255 255 / 6%) !important;
box-shadow: none;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-back-btn > span { display: none; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title {
font-size: 16px !important;
line-height: 1.4 !important;
white-space: normal;
overflow-wrap: anywhere;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__sub {
margin-top: 3px;
font-size: 11px !important;
line-height: 1.5;
color: #a7b6cc;
letter-spacing: 0;
}
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-table-wrap > table > thead > tr > th {
position: sticky;
top: 0;
z-index: 10;
background: #f0f3f8;
box-shadow: inset 0 -1px 0 #d7e1ed;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-table-wrap > table > thead > tr > th:first-child {
z-index: 11;
}
.ehb-modal-overlay .ehb-modal-card.ehb-drill-modal--unified {
width: 96vw !important;
max-width: 1800px !important;
height: 94dvh !important;
max-height: 94dvh !important;
display: flex !important;
flex-direction: column !important;
overflow: hidden !important;
}
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-head {
padding: 10px 16px !important;
flex: 0 0 auto;
}
.ehb-modal-card.ehb-drill-modal--unified > .ehb-drill-breadcrumbs { padding: 5px 16px; }
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-body {
flex: 1 1 auto !important;
min-height: 0 !important;
display: flex !important;
flex-direction: column !important;
gap: 6px !important;
padding: 8px 16px !important;
overflow: hidden !important;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-body > * { flex: 0 0 auto; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-body > .ehb-modal-hint-text {
flex: 0 0 auto !important;
min-width: 0;
margin: 0 !important;
line-height: 1.5;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-summary > span {
display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-drill-root-tabs { margin: 0 !important; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-bar { margin: 0 !important; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-item { padding: 6px 12px !important; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-val { font-size: 18px !important; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-primary-actions {
display: flex !important;
align-items: center;
gap: 8px;
margin: 0 !important;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-summary {
display: flex !important;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 40px;
padding: 6px 10px;
border: 1px solid #d7e1ed;
border-radius: 8px;
background: white;
color: #334155;
flex: 1;
min-width: 0;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-summary strong {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-panel:not(.is-open) { display: none !important; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-panel.is-open {
display: flex !important;
max-height: 30dvh;
overflow: auto;
margin: 0 !important;
padding: 8px !important;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-drill-usage-guide { font-size: 12px; margin: 0; display: flex; gap: 12px; flex-wrap: wrap; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-body > .ehb-modal-table-wrap {
flex: 1 1 0 !important;
min-height: 0 !important;
max-height: none !important;
margin: 0 !important;
overflow: auto !important;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-drill-page-controls { margin-top: 0 !important; padding: 0; }
@media (max-width: 767px), (max-height: 500px) {
.ehb-modal-overlay .ehb-modal-card.ehb-drill-modal--unified {
width: 100vw !important; height: 100dvh !important; max-height: 100dvh !important;
border-radius: 0 !important; margin: 0 !important;
}
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-head { padding: 6px 10px !important; }
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-body {
padding: 6px 8px max(6px, env(safe-area-inset-bottom)) !important; gap: 4px !important;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-bar {
display: flex !important; flex-wrap: nowrap !important; overflow-x: auto !important;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-item { flex: 0 0 auto; width: auto !important; min-width: 120px; padding: 5px 9px !important; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-val { font-size: 16px !important; }
}
@@ -1,762 +0,0 @@
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { Download, RefreshCw, Truck } from "lucide-react";
import { DailyTreeButton, DailyBranchState } from "./daily-detail-controls";
import { dailySummaryRows, formatDailyChange } from "../model/daily-detail-format";
import { formatFixed } from "../model/display-format";
import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiDrill } from "../api";
import { exportAoaSheet } from "../../../../shared/xlsx";
import "./real-daily-mobile.css";
import type {
H2BiDailyResponse,
H2BiDailyTreeResponse,
H2BiDrillResponse,
H2BiQuery,
H2BiVehicleScope,
} from "../types";
const toScope = (scope: "all" | "own" | "external"): H2BiVehicleScope =>
scope === "own" ? "lingniu" : scope;
const sourceLabel = (source: unknown) => {
const value = String(source || "").toLowerCase();
if (value === "lingniu") return "羚牛上报";
if (value === "api") return "API接入";
if (value === "station") return "站点上报";
return String(source || "未知来源");
};
/** Keep raw API statuses out of the UI. Missing/unknown statuses are unverified. */
const verifyLabel = (status: unknown) => {
const value = String(status ?? "").trim().toLowerCase();
return value === "verified" || value === "pass" || value === "已验证"
? "已验证"
: "未验证";
};
export function PrototypeRealDailyView({
startDate,
endDate,
onStartDateChange,
onEndDateChange,
fleetScope,
onFleetScopeChange,
verifyScope,
stationId = null,
onRefresh,
refreshToken = 0,
onLoadingChange,
}: {
startDate: string;
endDate: string;
onStartDateChange: (value: string) => void;
onEndDateChange: (value: string) => void;
fleetScope: "all" | "own" | "external";
onFleetScopeChange: (value: "all" | "own" | "external") => void;
verifyScope: "all" | "verified";
stationId?: string | number | null;
onRefresh: () => void;
refreshToken?: number;
onLoadingChange?: (loading: boolean) => void;
}) {
const query = useMemo<H2BiQuery>(
() => ({
year: Number(startDate.slice(0, 4)),
startDate,
endDate,
vehicleScope: toScope(fleetScope),
verifyScope,
stationId,
}),
[endDate, fleetScope, startDate, stationId, verifyScope],
);
const [daily, setDaily] = useState<H2BiDailyResponse | null>(null);
const [reloadToken, setReloadToken] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [trees, setTrees] = useState<Record<string, H2BiDailyTreeResponse>>({});
const [expandedDate, setExpandedDate] = useState<string | null>(null);
const [expandedStation, setExpandedStation] = useState<
Record<string, boolean>
>({});
const [expandedCustomer, setExpandedCustomer] = useState<
Record<string, boolean>
>({});
const [expandedStationLists, setExpandedStationLists] = useState<
Record<string, boolean>
>({});
const [expandedCustomerLists, setExpandedCustomerLists] = useState<
Record<string, boolean>
>({});
const [expandedRecordLists, setExpandedRecordLists] = useState<
Record<string, boolean>
>({});
const [customerRecords, setCustomerRecords] = useState<
Record<string, H2BiDrillResponse>
>({});
const [error, setError] = useState<string | null>(null);
const [highlightedDate, setHighlightedDate] = useState<string | null>(null);
const [detailMode, setDetailMode] = useState<"key" | "full">("key");
const [branchErrors, setBranchErrors] = useState<Record<string, string>>({});
const requestGeneration = useRef(0);
const pendingBranches = useRef(new Set<string>());
const tableWrapRef = useRef<HTMLDivElement>(null);
const dateRowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
useEffect(() => {
let alive = true;
requestGeneration.current += 1;
pendingBranches.current.clear();
setBranchErrors({});
let finishTimer: number | undefined;
const loadingStartedAt = Date.now();
setIsLoading(true);
onLoadingChange?.(true);
// The selected range is a new data contract. Clear the former response so
// an API error can never be mistaken for fresh data or business zeroes.
setDaily(null);
setError(null);
setTrees({});
setExpandedDate(null);
setExpandedStation({});
setExpandedCustomer({});
setExpandedStationLists({});
setExpandedCustomerLists({});
setExpandedRecordLists({});
setCustomerRecords({});
void fetchH2BiDaily(query)
.then((result) => alive && setDaily(result))
.catch(
(reason: unknown) => {
if (!alive) return;
setDaily(null);
setError(
reason instanceof Error ? reason.message : "按日数据加载失败",
);
},
)
.finally(() => {
if (!alive) return;
const remaining = Math.max(0, 500 - (Date.now() - loadingStartedAt));
finishTimer = window.setTimeout(() => {
if (!alive) return;
setIsLoading(false);
onLoadingChange?.(false);
}, remaining);
});
return () => {
alive = false;
requestGeneration.current += 1;
if (finishTimer !== undefined) window.clearTimeout(finishTimer);
};
}, [query, reloadToken, refreshToken, onLoadingChange]);
const handleRefresh = () => {
setReloadToken((value) => value + 1);
onRefresh();
};
const ensureDateTree = (date: string) => {
if (trees[date] || pendingBranches.current.has(date)) return;
const generation = requestGeneration.current;
pendingBranches.current.add(date);
setBranchErrors((items) => ({ ...items, [date]: "" }));
void fetchH2BiDailyTree(date, {
vehicleScope: query.vehicleScope,
verifyScope,
stationId,
}).then((tree) => {
if (generation === requestGeneration.current) setTrees((items) => ({ ...items, [date]: tree }));
}).catch(() => {
if (generation === requestGeneration.current) setBranchErrors((items) => ({ ...items, [date]: "站点明细加载失败,请重试" }));
}).finally(() => {
if (generation === requestGeneration.current) pendingBranches.current.delete(date);
});
};
const openDate = (date: string, scrollIntoDate = false) => {
setExpandedDate(date);
ensureDateTree(date);
if (!scrollIntoDate) return;
setHighlightedDate(date);
window.setTimeout(() => {
dateRowRefs.current[date]?.scrollIntoView({
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
block: "center",
});
}, 40);
window.setTimeout(() => {
setHighlightedDate((current) => (current === date ? null : current));
}, 2200);
};
const toggleDate = (date: string) => {
if (expandedDate === date) {
setExpandedDate(null);
return;
}
openDate(date);
};
const loadCustomer = (
date: string,
stationId: string | number,
customerId: number,
) => {
const key = `${date}:${stationId}:${customerId}`;
if (customerRecords[key] || pendingBranches.current.has(key)) return;
const generation = requestGeneration.current;
pendingBranches.current.add(key);
setBranchErrors((items) => ({ ...items, [key]: "" }));
void fetchH2BiDrill({
...query,
date,
stationId,
customerId,
groupBy: "record",
page: 1,
pageSize: 200,
}).then((result) => {
if (generation === requestGeneration.current) setCustomerRecords((items) => ({ ...items, [key]: result }));
}).catch(() => {
if (generation === requestGeneration.current) setBranchErrors((items) => ({ ...items, [key]: "车辆明细加载失败,请重试" }));
}).finally(() => {
if (generation === requestGeneration.current) pendingBranches.current.delete(key);
});
};
const toggleCustomer = (date: string, station: string | number, customer: number) => {
const key = `${date}:${station}:${customer}`;
setExpandedCustomer((items) => ({ ...items, [key]: !items[key] }));
loadCustomer(date, station, customer);
};
const exportRows = () => {
if (!daily) return;
exportAoaSheet(
dailySummaryRows(daily),
`每日加氢日期汇总_${startDate}_${endDate}.xlsx`,
"日期汇总",
);
};
const trend = daily?.trend ?? [];
const maxKg = Math.max(...trend.map((row) => row.kg), 1);
const averageKg = daily?.kpis.averageDailyKg ?? 0;
const peak = trend.reduce(
(best, row) => (row.kg > best.kg ? row : best),
trend[0],
);
const trough = trend
.filter((row) => row.kg > 0)
.reduce(
(best, row) => (!best || row.kg < best.kg ? row : best),
undefined as (typeof trend)[number] | undefined,
);
return (
<div className="ehb-daily-container">
{isLoading && !daily ? (
<div className="ehb-live-data-state is-loading" role="status" aria-live="polite">
<span className="ehb-live-data-spinner" aria-hidden />
<strong></strong>
<span></span>
</div>
) : null}
<section className="ehb-daily-filter-card">
<div className="ehb-daily-filter-row">
<div className="ehb-daily-filter-group">
<div className="ehb-pill-tabs">
<button
type="button"
className="ehb-pill-btn"
onClick={() => {
const today = endDate;
onStartDateChange(today.slice(0, 8) + "01");
}}
>
</button>
<button
type="button"
className="ehb-pill-btn"
onClick={() => {
const end = new Date(`${endDate}T00:00:00`);
const begin = new Date(end);
begin.setDate(end.getDate() - 14);
onStartDateChange(begin.toISOString().slice(0, 10));
}}
>
15
</button>
<button type="button" className="ehb-pill-btn is-active">
</button>
</div>
<input
className="ehb-modal-select"
type="date"
value={startDate}
onChange={(event) => onStartDateChange(event.target.value)}
/>
<input
className="ehb-modal-select"
type="date"
value={endDate}
onChange={(event) => onEndDateChange(event.target.value)}
/>
</div>
<div className="ehb-daily-filter-group">
<div className="ehb-fleet-segmented">
<button
type="button"
className={`ehb-fleet-btn ${fleetScope === "all" ? "is-active" : ""}`}
onClick={() => onFleetScopeChange("all")}
>
</button>
<button
type="button"
className={`ehb-fleet-btn ${fleetScope === "own" ? "is-active" : ""}`}
onClick={() => onFleetScopeChange("own")}
>
<Truck size={14} />
</button>
<button
type="button"
className={`ehb-fleet-btn ${fleetScope === "external" ? "is-active" : ""}`}
onClick={() => onFleetScopeChange("external")}
>
<Truck size={14} />
</button>
</div>
<button
type="button"
className="ehb-btn ehb-btn--ghost"
onClick={handleRefresh}
disabled={isLoading}
aria-busy={isLoading}
>
<RefreshCw size={14} className={isLoading ? "is-spinning" : ""} />
{isLoading ? "加载中…" : "刷新"}
</button>
</div>
</div>
</section>
{error ? <div className="ehb-empty">{error}</div> : null}
{daily ? <>
<section className="ehb-daily-kpi-grid">
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title"></div>
<div className="ehb-daily-kpi-val">
{formatFixed(daily?.kpis.totalKg ?? 0)} <small>Kg</small>
</div>
<div className="ehb-daily-kpi-sub">
{startDate} {endDate}
</div>
</div>
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title"></div>
<div className="ehb-daily-kpi-val">
¥{formatFixed(daily?.kpis.totalCost ?? 0)}
</div>
<div className="ehb-daily-kpi-sub"></div>
</div>
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title"></div>
<div className="ehb-daily-kpi-val">{daily?.kpis.activeDays ?? 0}</div>
<div className="ehb-daily-kpi-sub">
{formatFixed(daily?.kpis.averageDailyKg ?? 0)} Kg
</div>
</div>
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title"></div>
<div className="ehb-daily-kpi-val">
{daily?.kpis.stationCount ?? 0} <small></small>
</div>
<div className="ehb-daily-kpi-sub"></div>
</div>
</section>
<section className="ehb-daily-chart-section">
<div className="ehb-daily-chart-head">
<div className="ehb-daily-chart-title">
{" "}
<span className="ehb-title-sub">
</span>
</div>
<div className="ehb-daily-chart-meta-group">
<div className="ehb-daily-chart-legend">
<span className="ehb-legend-item">
<span className="ehb-legend-dot is-own" />
</span>
<span className="ehb-legend-item">
<span className="ehb-legend-dot is-ext" />
</span>
</div>
<span className="ehb-daily-chart-meta"> · Kg</span>
</div>
</div>
<div className="ehb-daily-summary-pills">
<div className="ehb-daily-pill-item">
<span></span>
<strong>{peak ? `${peak.date} ${formatFixed(peak.kg)} Kg` : "—"}</strong>
</div>
<div className="ehb-daily-pill-item">
<span></span>
<strong>
{trough ? `${trough.date} ${formatFixed(trough.kg)} Kg` : "—"}
</strong>
</div>
<div className="ehb-daily-pill-item">
<span></span>
<strong>{trend.filter((row) => row.kg === 0).length} </strong>
</div>
</div>
<div className="ehb-daily-bar-container">
<div
className="ehb-daily-avg-line"
style={{
bottom: `${Math.min(92, Math.round((averageKg / maxKg) * 100))}%`,
}}
>
<span className="ehb-daily-avg-label">
{formatFixed(averageKg)} Kg
</span>
</div>
{trend.map((row) => {
const total = row.kg || 1;
const ownRatio = (row.lingniuKg / total) * 100;
const extRatio = (row.externalKg / total) * 100;
const active = expandedDate === row.date;
return (
<button
type="button"
key={row.date}
className="ehb-daily-bar-col"
onClick={() => openDate(row.date, true)}
title={`${row.date} 加氢总量 ${formatFixed(row.kg)} Kg;点击展开并定位当日明细`}
aria-label={`展开并定位${row.date}当日加氢明细`}
>
<div
className="ehb-daily-bar-val"
style={{
color: active ? "#0284c7" : undefined,
fontWeight: active ? 700 : undefined,
}}
>
{Math.round(row.kg)}
</div>
<div
className={`ehb-daily-bar-fill is-stacked ${active ? "is-active" : ""}`}
style={{ height: `${Math.max(0, (row.kg / maxKg) * 100)}%` }}
>
{row.externalKg > 0 ? (
<div
className="ehb-bar-segment is-ext"
style={{ height: `${extRatio}%` }}
/>
) : null}
{row.lingniuKg > 0 ? (
<div
className="ehb-bar-segment is-own"
style={{ height: `${ownRatio}%` }}
/>
) : null}
</div>
<div
className="ehb-daily-bar-label"
style={{
color: active ? "#0284c7" : undefined,
fontWeight: active ? 700 : undefined,
}}
>
{row.date.slice(5)}
</div>
</button>
);
})}
</div>
</section>
</> : null}
<section className="ehb-daily-table-card ehb-real-daily-detail" data-detail-mode={detailMode}>
<div className="ehb-daily-table-head">
<div className="ehb-daily-table-title">
{" "}
<span className="ehb-title-sub">
</span>
</div>
<button
type="button"
className="ehb-btn ehb-btn--outline ehb-export-btn"
onClick={exportRows}
disabled={!daily || isLoading || !daily.days.length}
title="导出所选区间的全部日期汇总,不含客户和车辆流水"
>
<Download size={14} />
</button>
</div>
<div className="ehb-daily-detail-toolbar">
<div className="ehb-daily-view-switch" role="group" aria-label="明细显示方式">
{([['key', '重点指标'], ['full', '完整表格']] as const).map(([mode, label]) =>
<button type="button" key={mode} aria-pressed={detailMode === mode} onClick={() => {
setDetailMode(mode);
if (tableWrapRef.current) tableWrapRef.current.scrollLeft = 0;
}}>{label}</button>)}
</div>
<label className="ehb-daily-date-jump">
<span></span>
<select value={expandedDate ?? ""} disabled={!daily?.days.length} onChange={(event) => {
if (event.target.value) openDate(event.target.value, true);
}}>
<option value=""></option>
{(daily?.days ?? []).map(day => <option key={day.date} value={day.date}>{day.date}</option>)}
</select>
</label>
<button type="button" className="ehb-daily-collapse" disabled={!expandedDate} onClick={() => {
setExpandedDate(null); setExpandedStation({}); setExpandedCustomer({});
setExpandedStationLists({}); setExpandedCustomerLists({}); setExpandedRecordLists({});
}}></button>
</div>
<div
className="ehb-h5-scroll-hint ehb-daily-table-scroll-hint"
aria-hidden="true"
>
{detailMode === "key" ? "点击名称逐层查看:日期 → 站点 → 客户 → 车辆" : "首列已固定 · 左右滑动查看全部指标"}
</div>
{!daily ? <div className="ehb-daily-detail-empty" role={error ? "alert" : "status"}>
{error ? <><button type="button" onClick={handleRefresh}></button></> : "正在加载日期明细…"}
</div> : !daily.days.length ? <div className="ehb-daily-detail-empty" role="status"></div> :
<div ref={tableWrapRef} className="ehb-table-wrap" role="region" aria-label="每日加氢明细,可左右滚动" tabIndex={0}>
<table className="ehb-table">
<thead>
<tr>
<th> / </th>
<th>(/Kg)</th>
<th>(Kg)</th>
<th>() / </th>
<th> / </th>
</tr>
</thead>
<tbody>
<tr style={{ background: "#f8fafc", fontWeight: 700 }}>
<td></td>
<td />
<td>{formatFixed(daily?.kpis.totalKg ?? 0)}</td>
<td>¥{formatFixed(daily?.kpis.totalCost ?? 0)}</td>
<td></td>
</tr>
{(daily?.days ?? []).map((day) => {
const tree = trees[day.date];
const open = expandedDate === day.date;
return (
<Fragment key={day.date}>
<tr
ref={(node) => {
dateRowRefs.current[day.date] = node;
}}
id={`daily-row-${day.date}`}
className={`ehb-daily-date-row${highlightedDate === day.date ? " is-highlighted" : ""}`}
style={{
background: open ? "#f0f9ff" : undefined,
}}
>
<td>
<DailyTreeButton open={open} label={`${day.date}加氢站明细`} onClick={() => toggleDate(day.date)}>
{day.date}{" "}
<span className="ehb-title-sub">
({day.stationCount ?? 0} )
</span>
</DailyTreeButton>
</td>
<td></td>
<td>{formatFixed(day.kg)}</td>
<td>
<strong className="ehb-daily-cost">{formatFixed(day.cost)}</strong>
<small className="ehb-daily-change">{formatDailyChange((day as { chainPct?: number }).chainPct)}</small>
</td>
<td></td>
</tr>
{open && (!tree || tree.stations.length === 0) ? <DailyBranchState
columns={detailMode === "key" ? 3 : 5} error={branchErrors[day.date]}
empty={!!tree} onRetry={() => ensureDateTree(day.date)} /> : null}
{open &&
tree?.stations
.slice(0, expandedStationLists[day.date] ? undefined : 10)
.map((station) => {
const stationKey = `${day.date}:${station.id}`;
const stationOpen = !!expandedStation[stationKey];
return (
<Fragment key={stationKey}>
<tr
style={{
background: "#f8fafc",
}}
>
<td className="ehb-tree-cell-l1">
<DailyTreeButton open={stationOpen} label={`${station.name}客户明细`} onClick={() =>
setExpandedStation(items => ({ ...items, [stationKey]: !items[stationKey] }))}>
<small className="ehb-daily-level-label"></small>{station.name}
</DailyTreeButton>
</td>
<td></td>
<td>{formatFixed(station.kg)}</td>
<td>¥{formatFixed(station.cost)}</td>
<td></td>
</tr>
{stationOpen && station.customers.length === 0 ? <DailyBranchState
columns={detailMode === "key" ? 3 : 5} empty onRetry={() => {}} /> : null}
{stationOpen &&
station.customers
.slice(
0,
expandedCustomerLists[stationKey]
? undefined
: 10,
)
.map((customer) => {
const customerKey = `${stationKey}:${customer.id}`;
const customerOpen =
!!expandedCustomer[customerKey];
const allRecords =
customerRecords[customerKey]?.records ?? [];
const records = allRecords.slice(
0,
expandedRecordLists[customerKey]
? undefined
: 20,
);
return (
<Fragment key={customerKey}>
<tr>
<td className="ehb-tree-cell-l2">
<DailyTreeButton open={customerOpen} label={`${customer.name}车辆明细`}
onClick={() => toggleCustomer(day.date, station.id, customer.id)}>
<small className="ehb-daily-level-label"></small>{customer.name}{" "}
<span className="ehb-title-sub">
({customer.recordCount} )
</span>
</DailyTreeButton>
</td>
<td></td>
<td>{formatFixed(customer.kg)}</td>
<td>¥{formatFixed(customer.cost)}</td>
<td></td>
</tr>
{customerOpen && (!customerRecords[customerKey] || allRecords.length === 0) ? <DailyBranchState
columns={detailMode === "key" ? 3 : 5} error={branchErrors[customerKey]}
empty={!!customerRecords[customerKey]} onRetry={() => loadCustomer(day.date, station.id, customer.id)} /> : null}
{customerOpen &&
records.map((record) => (
<tr key={String(record.id)}>
<td className="ehb-tree-cell-l3">
<small className="ehb-daily-level-label"> · {String(record.time || "—").slice(11, 16)}</small>
<strong>
{String(record.plateNo || "无车牌")}
</strong>{" "}
<span
className={`ehb-daily-record-tag ${record.vehicleScope === "lingniu" ? "is-own" : "is-external"}`}
>
{record.vehicleScope === "lingniu"
? "羚牛车辆"
: "外部车辆"}
</span>
<span
className="ehb-daily-record-tag is-source"
title={String(record.source || "未知来源")}
>
{sourceLabel(record.source)}
</span>
<span
className={`ehb-daily-record-tag ${verifyLabel(record.verifyStatus) === "已验证" ? "is-verified" : "is-unverified"}`}
title={verifyLabel(record.verifyStatus)}
>
{verifyLabel(record.verifyStatus)}
</span>
</td>
<td>
{formatFixed(
Number(record.unitPrice ?? 0),
)}
</td>
<td>
{formatFixed(Number(record.kg ?? 0))}
</td>
<td>
¥{formatFixed(Number(record.cost ?? 0))}
</td>
<td></td>
</tr>
))}
{customerOpen && allRecords.length > 20 ? (
<tr className="ehb-daily-tree-more-row">
<td colSpan={detailMode === "key" ? 3 : 5}>
<button
type="button"
className="ehb-daily-tree-more-btn"
onClick={(event) => {
event.stopPropagation();
setExpandedRecordLists((items) => ({
...items,
[customerKey]: !items[customerKey],
}));
}}
>
{expandedRecordLists[customerKey]
? "收起车辆明细"
: `更多车辆明细(还有 ${allRecords.length - 20} 笔)`}
</button>
</td>
</tr>
) : null}
</Fragment>
);
})}
{stationOpen && station.customers.length > 10 ? (
<tr className="ehb-daily-tree-more-row">
<td colSpan={detailMode === "key" ? 3 : 5}>
<button
type="button"
className="ehb-daily-tree-more-btn"
onClick={(event) => {
event.stopPropagation();
setExpandedCustomerLists((items) => ({
...items,
[stationKey]: !items[stationKey],
}));
}}
>
{expandedCustomerLists[stationKey]
? "收起客户"
: `更多客户(还有 ${station.customers.length - 10} 个)`}
</button>
</td>
</tr>
) : null}
</Fragment>
);
})}
{open && tree && tree.stations.length > 10 ? (
<tr className="ehb-daily-tree-more-row">
<td colSpan={detailMode === "key" ? 3 : 5}>
<button
type="button"
className="ehb-daily-tree-more-btn"
onClick={(event) => {
event.stopPropagation();
setExpandedStationLists((items) => ({
...items,
[day.date]: !items[day.date],
}));
}}
>
{expandedStationLists[day.date]
? "收起加氢站"
: `更多加氢站(还有 ${tree.stations.length - 10} 个)`}
</button>
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</div>}
</section>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,189 +0,0 @@
/* Scoped to the live daily table; keep the other boards and drill dialogs intact. */
.ehb-real-daily-detail {
min-width: 0;
}
.ehb-daily-detail-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 16px;
margin: 12px 0;
}
.ehb-daily-view-switch {
display: inline-flex;
padding: 3px;
background: #f1f5f9;
border-radius: 9px;
}
.ehb-real-daily-detail button,
.ehb-real-daily-detail select { font: inherit; }
.ehb-daily-view-switch button,
.ehb-daily-collapse,
.ehb-daily-branch-state button,
.ehb-daily-detail-empty button {
min-height: 40px;
border: 0;
padding: 8px 12px;
border-radius: 7px;
background: transparent;
color: #475569;
cursor: pointer;
font-size: 13px;
}
.ehb-daily-view-switch button[aria-pressed="true"] {
background: #fff;
color: #1d4ed8;
box-shadow: 0 1px 4px #0f172a14;
font-weight: 600;
}
.ehb-daily-date-jump { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #64748b; }
.ehb-daily-date-jump select { min-height: 40px; border: 1px solid #e2e8f0; border-radius: 7px; padding: 6px 8px; background: #fff; color: #334155; }
.ehb-real-daily-detail button:disabled { opacity: .45; cursor: default; }
.ehb-real-daily-detail :is(button, select, [tabindex]):focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.ehb-real-daily-detail .ehb-daily-disclosure {
display: flex;
align-items: flex-start;
gap: 5px;
width: 100%;
min-height: 44px;
padding: 6px 0;
border: 0;
background: none;
color: inherit;
text-align: left;
line-height: 1.5;
cursor: pointer;
}
.ehb-daily-disclosure > svg { flex-shrink: 0; margin-top: 3px; color: #64748b; }
.ehb-daily-disclosure > span { min-width: 0; }
.ehb-daily-disclosure[aria-expanded="true"] { font-weight: 600; }
.ehb-daily-disclosure[aria-expanded="true"] > svg { color: #2563eb; }
.ehb-daily-level-label,
.ehb-daily-change { display: block; font-size: 11px; font-weight: 400; color: #64748b; line-height: 1.6; }
.ehb-daily-cost { font-weight: 500; }
.ehb-daily-branch-state > td > div { display: flex; align-items: center; gap: 8px; }
.ehb-daily-branch-state button { display: inline-flex; align-items: center; gap: 5px; color: #1d4ed8; background: #eff6ff; }
.ehb-daily-detail-empty { padding: 24px 16px; text-align: center; color: #64748b; font-size: 13px; }
.ehb-daily-detail-empty button { display: block; margin: 8px auto 0; color: #1d4ed8; }
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table { min-width: 0; }
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td):is(:nth-child(2), :nth-child(5)) { display: none; }
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td):first-child:not([colspan]) { width: 36%; }
.ehb-real-daily-detail .ehb-table td:not(:first-child) { text-align: right; }
.ehb-real-daily-detail .ehb-table th:not(:first-child) { text-align: right; }
.ehb-real-daily-detail .ehb-table-wrap {
isolation: isolate;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
.ehb-real-daily-detail .ehb-table {
table-layout: fixed;
min-width: 940px;
}
.ehb-real-daily-detail .ehb-table tr {
background-color: #fff;
}
.ehb-real-daily-detail .ehb-table th:first-child,
.ehb-real-daily-detail .ehb-table td:first-child:not([colspan]) {
position: sticky;
left: 0;
z-index: 2;
width: 260px;
background-color: inherit;
box-shadow: 1px 0 0 #e2e8f0, 5px 0 8px -6px #64748b;
overflow-wrap: anywhere;
white-space: normal;
}
/* Give nested day → station → customer → vehicle rows a viewport-sized workspace. */
.ehb-real-daily-detail.ehb-daily-table-card > .ehb-table-wrap {
height: 80vh;
height: 80dvh;
max-height: none;
min-height: 0;
overflow: auto;
}
.ehb-real-daily-detail .ehb-table th:first-child {
z-index: 4;
background-color: #f8fafc;
}
.ehb-real-daily-detail .ehb-table th {
z-index: 3;
}
.ehb-real-daily-detail .ehb-table td:not(:first-child) {
font-variant-numeric: tabular-nums;
}
@media (max-width: 767px), (max-width: 1024px) and (max-height: 500px) {
.ehb-real-daily-detail.ehb-daily-table-card {
padding: 12px 0 0;
overflow: hidden;
}
.ehb-real-daily-detail .ehb-daily-table-head {
padding: 0 12px;
gap: 8px;
align-items: flex-start;
}
.ehb-real-daily-detail .ehb-daily-table-title {
font-size: 15px;
line-height: 1.5;
}
.ehb-real-daily-detail .ehb-daily-table-title .ehb-title-sub {
display: none;
}
.ehb-real-daily-detail .ehb-export-btn {
display: inline-flex;
flex-shrink: 0;
min-height: 44px;
padding: 6px 8px;
font-size: 12px;
}
.ehb-real-daily-detail .ehb-daily-detail-toolbar { padding: 0 12px; gap: 8px; }
.ehb-real-daily-detail .ehb-daily-view-switch { width: 100%; }
.ehb-real-daily-detail .ehb-daily-view-switch button { flex: 1; min-height: 44px; }
.ehb-real-daily-detail .ehb-daily-date-jump { flex: 1; }
.ehb-real-daily-detail .ehb-daily-date-jump select { min-height: 44px; }
.ehb-real-daily-detail .ehb-daily-collapse { min-height: 44px; padding: 6px; font-size: 12px; }
.ehb-real-daily-detail .ehb-daily-table-scroll-hint {
display: block;
padding: 8px 12px;
font-size: 11px;
color: #64748b;
background: #f8fafc;
}
.ehb-real-daily-detail .ehb-table-wrap {
max-height: 65dvh;
}
.ehb-real-daily-detail .ehb-table {
min-width: 760px;
font-size: 13px;
}
.ehb-real-daily-detail .ehb-table th:first-child,
.ehb-real-daily-detail .ehb-table td:first-child:not([colspan]) {
width: 142px;
}
.ehb-real-daily-detail .ehb-table td {
height: 48px;
padding: 10px 12px;
line-height: 1.5;
box-sizing: border-box;
}
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table { min-width: 0; width: 100%; }
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td) { padding: 10px 8px; }
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table td:not(:first-child) { font-size: 12px; overflow-wrap: anywhere; }
.ehb-real-daily-detail .ehb-table td:first-child:has(.ehb-daily-disclosure) { padding-top: 4px; padding-bottom: 4px; }
.ehb-real-daily-detail .ehb-table th {
padding: 10px 12px;
white-space: normal;
line-height: 1.5;
}
.ehb-real-daily-detail .ehb-table td:first-child .ehb-title-sub {
display: block;
margin-left: 0;
}
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l1 { padding-left: 16px; }
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l2 { padding-left: 22px; }
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l3 { padding-left: 28px; }
.ehb-real-daily-detail .ehb-daily-record-tag {
display: inline-block;
margin: 2px 3px 0 0;
}
}
@@ -1,93 +0,0 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -1,10 +0,0 @@
# JetBrains MonoOneOS V2 自托管)
| 项 | 值 |
|---|---|
| 版本 | 2.304 |
| 来源 | https://github.com/JetBrains/JetBrainsMono |
| 许可 | SIL Open Font License 1.1`OFL.txt` |
| 用途 | 金额 / 日期 / 单号 / 车牌等数据等宽数字(DESIGN §2.2) |
引入:由 `oneos-ds-tokens.css` `@import` 本目录 `jetbrains-mono.css`**禁止**再绑 Google Fonts CDN。
@@ -1,45 +0,0 @@
/**
* OneOS V2 · JetBrains Mono自托管
* 版本2.304 · 许可SIL OFL 1.1见同目录 OFL.txt
* 禁止改回 Google Fonts CDN内网 / Windows 无外网时必须本地 woff2
*/
@font-face {
font-family: 'JetBrains Mono';
src: url('./JetBrainsMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('./JetBrainsMono-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('./JetBrainsMono-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('./JetBrainsMono-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('./JetBrainsMono-ExtraBold.woff2') format('woff2');
font-weight: 800;
font-style: normal;
font-display: swap;
}
@@ -1,37 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import test from "node:test";
const here = path.dirname(fileURLToPath(import.meta.url));
/** 抽出文件里指向某个模块的 import 说明符(支持静态 import 与动态 import())。 */
function importedSpecifier(source: string, moduleName: string): string | null {
const patterns = [
new RegExp(`from\\s*["']([^"']*${moduleName})["']`),
new RegExp(`import\\(\\s*["']([^"']*${moduleName})["']\\s*\\)`),
];
for (const re of patterns) {
const match = source.match(re);
if (match) return match[1];
}
return null;
}
test("能源氢费 BI 入口与独立验收地址复用同一看板", () => {
const entryPath = path.join(here, "index.tsx");
const appPath = path.join(here, "..", "..", "..", "App.tsx");
const entry = readFileSync(entryPath, "utf8");
const app = readFileSync(appPath, "utf8");
const entrySpec = importedSpecifier(entry, "EnergyBiBoardApp");
const appSpec = importedSpecifier(app, "EnergyBiBoardApp");
assert.ok(entrySpec, "氢费 BI 入口必须引用 EnergyBiBoardApp");
assert.ok(appSpec, "App.tsx 的独立验收路由必须引用 EnergyBiBoardApp");
// 不锁定具体路径,只锁定"两个入口解析到同一个文件",避免目录调整造成无意义漂移。
const resolve = (spec: string, from: string) => path.resolve(path.dirname(from), spec);
assert.equal(resolve(entrySpec!, entryPath), resolve(appSpec!, appPath));
assert.match(entry, /return <EnergyBiBoardApp \/>/);
});
-9
View File
@@ -1,9 +0,0 @@
import { EnergyBiBoardApp } from './board/EnergyBiBoardApp';
/**
* The accepted hydrogen fee BI surface. It is shared with the independent
* acceptance route so the menu entry and /energy/hydrogen-board cannot drift.
*/
export default function HydrogenModule() {
return <EnergyBiBoardApp />;
}
@@ -1,18 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { bearingLabels } from "./bearing-labels";
test("records display their ledger bearing type", () => {
assert.deepEqual(bearingLabels({ settlementType: 1 }).map(x => x.label), ["客户承担"]);
assert.deepEqual(bearingLabels({ settlementType: "2" }).map(x => x.label), ["我司承担"]);
assert.deepEqual(bearingLabels({ settlementType: 3 }).map(x => x.label), ["客户自行结算"]);
});
test("groups display every distinct bearing type, including unknown", () => {
assert.deepEqual(bearingLabels({ settlementTypes: "1,2,3,unknown,1" }).map(x => x.label),
["客户承担", "我司承担", "客户自行结算", "未明确"]);
});
test("missing and unrecognized values do not imply an actual payer", () => {
for (const settlementType of [null, undefined, "", 4, "all"]) {
assert.deepEqual(bearingLabels({ settlementType }).map(x => x.label), ["未明确"]);
}
});
@@ -1,13 +0,0 @@
const labels: Record<string, { label: string; className: string }> = {
"1": { label: "客户承担", className: "is-cust" },
"2": { label: "我司承担", className: "is-lingniu" },
"3": { label: "客户自行结算", className: "is-other" },
};
// Use ledger settlement types, never the selected filter or monetary amounts.
export function bearingLabels(row: { settlementTypes?: unknown; settlementType?: unknown }) {
const types = String(row.settlementTypes ?? row.settlementType ?? "")
.split(",").map((value) => value.trim());
const results = types.map((value) => labels[value] ?? { label: "未明确", className: "is-other" });
return [...new Map(results.map((item) => [item.label, item])).values()];
}
@@ -1,15 +0,0 @@
import type { H2BiDailyResponse } from "../types";
export function formatDailyChange(value: unknown): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "环比 —";
return `环比 ${value > 0 ? "+" : ""}${value.toFixed(2)}%`;
}
/** Export the entire selected range, independently of which branches were opened. */
export function dailySummaryRows(daily: H2BiDailyResponse): Array<Array<string | number>> {
return [
["日期", "加氢站数", "加氢量(Kg)", "成本(元)"],
["区间合计", daily.kpis.stationCount, daily.kpis.totalKg, daily.kpis.totalCost],
...daily.days.map(day => [day.date, day.stationCount ?? "—", day.kg, day.cost]),
];
}
@@ -1,19 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { formatFixed, isFiniteNumberValue } from "./display-format";
test("默认口径:缺失值按 0 展示,与氢能明细表既有行为一致", () => {
assert.equal(formatFixed(0), "0.00");
assert.equal(formatFixed(null), "0.00");
assert.equal(formatFixed(undefined), "0.00");
assert.equal(formatFixed(1234.5), "1,234.50");
assert.equal(formatFixed(1234.5, 0), "1,235");
});
test("显式开启 blankForMissing 时区分真实零值与不可用值", () => {
for (const value of [null, undefined, NaN, Infinity, -Infinity, "0"]) {
assert.equal(isFiniteNumberValue(value), false);
assert.equal(formatFixed(value, 2, { blankForMissing: true }), "—");
}
assert.equal(formatFixed(0, 2, { blankForMissing: true }), "0.00");
});
@@ -1,33 +0,0 @@
/**
*
*
* 42
* 使 "—"
* "缺失值是否显示为 0"
*/
export interface FormatOptions {
/**
* "—" 0
* false 0
* "真实零值""接口未返回"
*/
blankForMissing?: boolean;
}
/** 该值本身是否为可参与计算的有限数字(不把 null / undefined / "0" 视为数字)。 */
export function isFiniteNumberValue(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
/**
*
* `Number(value ?? 0)`
*/
export function formatFixed(value: unknown, digits = 2, options: FormatOptions = {}): string {
if (options.blankForMissing && !isFiniteNumberValue(value)) return '—';
return Number(value ?? 0).toLocaleString('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
@@ -1,63 +0,0 @@
# 加氢站日报 · 产品需求说明(PRD)
> 原型路径:`src/prototypes/energy-h2-station-daily`
> 口令:`lingniu`(轻门禁)
> 设计:能源 BI / 经营看板皮(非 OneOS V2 台账)
> 对外:只叙事、禁听众标签、禁说明书墙
> 作者:OneOS
> 日期:2026-08-12
> **2026-08-13**:去掉总览堆积「加氢量占比」条;占比改到各站概况行内「加氢量占比」(含进度条);各站按区间加氢量从高到低排序
---
## 0. 定位与边界
| 项 | 口径 |
|---|---|
| 名称 | 加氢站日报 |
| 职责 | 查询区间总览 → 钻取单站明细;现结区展示进账事实 |
| 不做 | 现结登记办理;经营看板三维度;台账列表总览;「单站驾驶舱」表述;总览页导出 |
| 与经营看板 | 可嵌入经营看板「单站」;也可独立打开。**嵌入时看板标题旁不展示查询区间**(维度不同) |
### 视图决策
| 决策 | 结论 | 理由 |
|---|---|---|
| 总览形态 | Hero KPI + 各站单卡片(行内加氢量占比) | 经营读数与下钻;占比不下单独堆积条 |
| 各站概况 | **单卡片**;默认「全部」,右上可切「单站」;**按区间加氢量降序** | 本尊 2026-08-12 / 2026-08-13 |
| 台账列表总览 | **不做** | 缺经营感 |
| 明细表集 | 日汇总 → 区间趋势 → 客户月量/费 →(收支∥现结明细) | 页长可控 |
| 车辆明细 | **不外挂**;仅导出取证派生 | 外层过长 |
| 时间维度 | **起止日期**(精确到日)+ 本日/本周/本月快捷;下方 KPI/站卡按区间重算 | 本尊 2026-08-12 |
| 看板嵌入 | 单站模式**不显示**经营看板顶栏时间芯片 | 查询维度不同 |
| 空数值 | 写 `0` | 本尊 2026-08-12 |
| 听众标签 | **禁止**进页面 | 只说事不对人 |
---
## 1. 用户故事
- **起点:** 打开日报 / 看板「单站」→ 选查询起止日期。
- **怎么运作:** KPI「统计加氢总量 / 统计加氢量 / 统计现结金额」可点开日明细;各站行内看「加氢量占比」与进度条;点站卡进站明细。
- **闭环:** 钻取页可导出 `.xlsx`;总览无导出。
---
## 2. 验收
1. 口令 `lingniu`
2. 看板单站模式:标题旁**无**查询区间芯片;页面无「单站驾驶舱」文案。
3. 查询日期为起止选择器,含本日/本周/本月;改区间后 KPI/站卡同步变化。
4. 加氢站副文案为「统计站点数」;三个 KPI 可钻取日表(条数=区间天数)。
5. **无**总览堆积占比条;各站行有「加氢量占比」数字 + 进度条。
6. 各站概况为单卡片,右上「全部 / 单站」切换;全部列表按区间加氢量从高到低。
---
## 3. 关联
- 现结登记:`energy-spot-cash-intake`
- 经营看板:`energy-h2-bi-board`
- 共享现结:`oneos-energy-spot-cash-intake-v3`
- 加氢客户(外部)/ 外部车辆:`energy-h2-external-customer` · `energy-h2-external-vehicle`(外部量仅统计,不进车辆氢费明细)
- 量侧实站(禁造站):南海 + 东鹏大道(未提供 Excel 不上 mock)
@@ -1,128 +0,0 @@
/**
* BI · V2
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, X } from 'lucide-react';
export const SdCustomerMultiSelect: React.FC<{
options: string[];
value: string[];
onChange: (next: string[]) => void;
label?: string;
}> = ({ options, value, onChange, label = '客户' }) => {
const [open, setOpen] = useState(false);
const [q, setQ] = useState('');
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
const filtered = useMemo(() => {
const key = q.trim();
if (!key) return options;
return options.filter((n) => n.includes(key));
}, [options, q]);
const allSelected = value.length === 0 || value.length === options.length;
const triggerText = allSelected
? '全部客户'
: value.length <= 2
? value.join('、')
: `已选 ${value.length}`;
const toggle = (name: string) => {
if (value.length === 0) {
// 从「全部」切入:只留当前点中
onChange([name]);
return;
}
if (value.includes(name)) {
const next = value.filter((n) => n !== name);
onChange(next.length === 0 ? [] : next);
return;
}
const next = [...value, name];
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((v) => !v)}
>
<span className="sd-msel__label">{label}</span>
<span className="sd-msel__value" title={triggerText}>
{triggerText}
</span>
<ChevronDown size={14} aria-hidden className="sd-msel__chev" />
</button>
{open ? (
<div className="sd-msel__panel" role="listbox" aria-multiselectable>
<div className="sd-msel__search">
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索客户"
aria-label="搜索客户"
/>
{q ? (
<button type="button" className="sd-msel__clear" onClick={() => setQ('')} aria-label="清空搜索">
<X size={12} />
</button>
) : null}
</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">
{filtered.length === 0 ? (
<li className="sd-msel__empty"></li>
) : (
filtered.map((name) => {
const checked = allSelected || value.includes(name);
return (
<li key={name}>
<button
type="button"
className={`sd-msel__opt ${checked ? 'is-on' : ''}`}
role="option"
aria-selected={checked}
onClick={() => toggle(name)}
>
<span className="sd-msel__check" aria-hidden>
{checked ? <Check size={12} /> : null}
</span>
<span className="sd-msel__name" title={name}>{name}</span>
</button>
</li>
);
})
)}
</ul>
</div>
) : null}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More