diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index eec8677..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..b34d807 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,18 @@ +# Copy to .env.local; never commit real credentials. Environment values win. +HYDROGEN_DB_HOST= +HYDROGEN_DB_PORT=3306 +HYDROGEN_DB_USER= +HYDROGEN_DB_PASSWORD= +HYDROGEN_DB_NAME= +# 单站与外部客户数据租户;仅由服务端设置,不接受网页传入。 +HYDROGEN_TENANT_ID=000000 +# Optional legacy/electric business database; use a database-level read-only user. +DB_HOST= +DB_PORT=3306 +DB_USER= +DB_PASSWORD= +DB_NAME= +# Local development uses the public OneOS endpoint; the ECS private IP is not reachable locally. +ONEOS_MILEAGE_API_BASE_URL=https://open.d.lnoneos.com +ONEOS_MILEAGE_API_KEY= +ONEOS_MILEAGE_API_TIMEOUT_MS=20000 diff --git a/.gitignore b/.gitignore index 7071147..fd06fec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ node_modules dist .env +.env.local .worktrees +.DS_Store +scripts-tmp diff --git a/Dockerfile b/Dockerfile index 80f0978..c59ab07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,9 @@ COPY src/shared ./src/shared COPY tsconfig.json ./ EXPOSE 3001 +ENV NODE_ENV=production ENV SERVER_PORT=3001 ENV EXTERNAL_API_BASE=https://lnh2e.com -ENV JWT_SECRET=ln-bi-jwt-prod-secret +# JWT_SECRET 不再写入镜像:镜像层里的默认密钥等同于公开密钥,任何人可离线伪造令牌。 +# 必须在运行环境注入(至少 32 字符),缺失时服务会拒绝启动(见 src/server/config.ts)。 CMD ["npm", "run", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..63f93a3 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# LN-BI + +羚牛业务数据的统一 BI Web 应用。React 19 + Vite 前端,Hono 后端,单进程同时提供 API 与静态页面。 + +## 入口 + +| 入口 | 模块 | 路由 | +| --- | --- | --- | +| 资产 BI | 资产管理、里程管理、车辆/加氢热力图、智能调度 | `/asset` | +| 能源 BI | 氢费 BI、电能、ETC | `/energy` | +| 隐藏页 | 充电记录导入(需能源角色) | `/ele/import` | +| 隐藏页 | 用户反馈管理 | `/admin/feedback` | +| 独立入口 | 氢能经营看板(与 `/energy` 内同一组件) | `/energy/hydrogen-board` | + +页面内部用 Hash 保存子页面状态,例如 `/energy#hydrogen/overview`。入口兼容逻辑见 `src/app/routing.ts`。 + +## 快速开始 + +需要 Node.js 22 与 npm。 + +```bash +npm ci +cp .env.local.example .env.local # 填入数据库等连接配置,切勿提交 +npm run dev:local # 只读本地预览:前端 127.0.0.1:8115,API 127.0.0.1:3001 +``` + +`dev:local` 会强制 `DB_READ_ONLY=1`、`HYDROGEN_DB_READ_ONLY=1`、`MILEAGE_REPORT_AUTO_ARCHIVE=0`、 +`DEV_BYPASS_AUTH=1`,并且**不启动建表与定时任务**,适合看真实数据而不写库。不要把它暴露到公网。 + +常规开发(前端 3000,后端 3001,`/api` 由 Vite 代理): + +```bash +npm run dev +``` + +## 命令 + +| 命令 | 说明 | +| --- | --- | +| `npm run dev` | 前后端开发模式 | +| `npm run dev:local` | 只读本地预览(免登录,禁写库) | +| `npm run lint` | 类型检查(`tsc --noEmit`) | +| `npm test` | Node 内置测试运行器 | +| `npm run build` | 生成 `dist` | +| `npm start` | 生产式启动(需先 build,且必须提供环境变量) | + +发布门禁是 `lint` + `test` + `build` 三者同时通过(CI 见 `woodpecker.yml`)。 + +## 目录 + +``` +src/ +├── app/ 应用外壳:路由解析、导航注册 +├── auth/ 前端认证状态与注入 +├── components/ 跨模块 UI(Shell、反馈、通用控件) +├── modules/ 前端业务域(每个域自带 api / model / components / css) +│ ├── assets/ 资产管理 +│ ├── mileage/ 里程(monitoring / statistics / daily-report) +│ ├── scheduling/智能调度 +│ ├── energy/ 能源(hydrogen / electric / etc) +│ ├── ele/ 电能数据导入 +│ ├── admin/ 反馈后台 +│ └── *-heatmap/ 两类热力图 +├── shared/ 前后端共享的叶子层(角色常量、跨端 DTO、日期区间、Excel、高德接入) +└── server/ + ├── config.ts 环境变量集中读取 + 启动期校验 + ├── app.ts 应用装配(无副作用,便于测试) + ├── index.ts 进程启动 + ├── local.ts 只读预览启动 + ├── db/ MySQL / PostgreSQL 连接 + ├── middleware/认证、只读 + ├── auth/ 登录(SSO 换票 / 固定密码)、权限 + └── routes/ 按业务域的 HTTP 路由 +``` + +## 架构约定 + +分层规则、依赖方向与「新增一个模块该放哪」见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。 +其中 6 条硬规则由 `src/architecture.test.ts` 断言,违反即测试失败。 + +## 部署注意事项 + +- `JWT_SECRET` **必须**在运行环境注入且不少于 32 字符;SSO 模式下缺失时服务会拒绝启动。 + 镜像内不再内置默认密钥(历史默认值等同于公开密钥,可被离线伪造令牌)。 +- 数据库口令、Harbor 凭据、OSS / OneOS / 高德密钥一律通过环境变量或 CI Secret 注入, + 仓库内不保留真实值。历史提交中出现过的凭据都需要轮换。 +- 生产环境请显式设置 `NODE_ENV=production` 与 `DEV_BYPASS_AUTH=0`;后者会绕过全部认证。 diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..dbbf2bb --- /dev/null +++ b/design-qa.md @@ -0,0 +1,47 @@ +# Energy Hydrogen Board Design QA + +- Source of truth: `/tmp/energy-h2-bi-board-prototype-0818/index.html` +- Prototype archive: `/Users/kkfluous/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/wxid_1704407055112_3af8/msg/file/2026-08/energy-h2-bi-board-html.zip` +- Implementation: `http://localhost:3000/energy#hydrogen/overview` +- Desktop viewport: 1280 x 720 CSS px +- Mobile viewport checked for the daily flow: 390 x 844 CSS px + +## Compared States + +| State | Prototype | Implementation | Combined comparison | +| --- | --- | --- | --- | +| Global overview | `/var/tmp/proto-global-overview.png` | `/var/tmp/impl-global-overview-final.png` | `/var/tmp/compare-global-overview-final.png` | +| Global daily | `/var/tmp/proto-global-daily.png` | `/var/tmp/impl-global-daily-final.png` | `/var/tmp/compare-global-daily-final.png` | +| All stations | `/var/tmp/proto-station.png` | `/var/tmp/impl-station-final.png` | `/var/tmp/compare-station-final.png` | +| Station detail | `/var/tmp/proto-station-detail.png` | `/var/tmp/impl-station-detail-final.png` | `/var/tmp/compare-station-detail-final.png` | + +All desktop comparisons use the same viewport, pixel dimensions, navigation state, and side-by-side source/implementation input. + +## Interaction Evidence + +- Global overview KPI, month, region, station, customer, and insight entries open real ledger drill-down data. +- Global daily supports date presets, custom dates, all/Lingniu/external vehicle scopes, chart-to-date focus, and date -> station -> customer -> vehicle/source drill-down. +- Daily and station evidence tables provide Excel export where the prototype exposes an export command. +- All-station mode supports all/single station display, station selection, clickable daily KPI dialogs, and zero-activity stations. +- Station detail includes a continuous 10-day series with zero-filled late days, recent 7-day table, range trend, and 12-month customer quantity/fee matrices. +- Entering and leaving a station detail resets the page scroll position so the header and notice remain visible. +- A clean browser tab loaded the final overview with no console warnings or errors. +- Daily mobile flow has no body-level horizontal overflow; wide analytical tables retain intentional internal horizontal scrolling. + +## Iteration History + +1. P1: Station detail inherited the long station-list scroll position. Added deterministic scroll reset on enter and return. +2. P1: Daily drill stopped at station level. Implemented the prototype's full four-level drill and real ledger rows. +3. P1: Station KPI cards were static. Added the three prototype daily detail dialogs using continuous server-side daily summaries. +4. P2: Prototype state matrix was incomplete. Added global overview, global daily, all-station, single-station selector, and station detail states. +5. P2: Icons differed from the prototype. Replaced approximate icons with matching Lucide Fuel, Zap, Wallet, Calendar, Truck, Refresh, Download, and navigation icons. +6. P2: Recharts could measure a transitioning container at a negative size. Added minimum and initial dimensions and verified a clean reload. +7. P2: Station detail omitted dates without records. Zero-filled the requested range so delayed or absent reporting is visible rather than silently skipped. + +## Residual P3 + +- Prototype screenshots contain fixed mock dates and values; implementation screenshots use the current read-only database values. +- The prototype warning says opening balances are still calibrating. The implementation uses a truthful traceability notice because the live ledger does not expose that calibration state. +- Native date-picker popovers vary by operating system; the closed controls match the prototype hierarchy and icon roles. + +final result: passed diff --git a/docker-compose.yml b/docker-compose.yml index 6f235dc..22e5fdb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,29 @@ version: '3.8' +# 凭据一律通过 Portainer / 环境变量注入,不再写入本文件。 +# 注意:此前提交到 Git 的数据库口令与 JWT 密钥已视为泄漏,必须轮换后再使用。 +# JWT_SECRET 至少要 32 个字符,缺失时服务会拒绝启动(src/server/config.ts)。 + services: ln-bi: - image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.0.0 + image: harbor.lnh2e.com/lingniu-v1/ln-bi:main-1.2.0 network_mode: host environment: - DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com" - DB_PORT: "3306" - DB_USER: "oneos_db_prod" - DB_PASSWORD: "adASHJcviqwjkbn23ngt1" - DB_NAME: "ln_asset_management" - HYDROGEN_DB_HOST: "47.99.185.173" - HYDROGEN_DB_PORT: "3306" - HYDROGEN_DB_USER: "root" - HYDROGEN_DB_PASSWORD: "lnMysql." - HYDROGEN_DB_NAME: "ln_asset_management" - MILEAGE_DB_HOST: "101.133.130.65" - MILEAGE_DB_PORT: "3306" - MILEAGE_DB_USER: "bi_reader_02" - MILEAGE_DB_PASSWORD: "bi_reader_02_Pass" - MILEAGE_DB_NAME: "hydrogen_energy" + NODE_ENV: "production" + # 生产必须保持为 0:该开关会绕过全部认证。 + DEV_BYPASS_AUTH: "0" + DB_HOST: "${DB_HOST:?请注入主业务库地址}" + DB_PORT: "${DB_PORT:-3306}" + DB_USER: "${DB_USER:?请注入主业务库只读账号}" + DB_PASSWORD: "${DB_PASSWORD:?请注入主业务库口令}" + DB_NAME: "${DB_NAME:-ln_asset_management}" + # 氢能库未配置时复用主库,因此默认不再单独注入;如需独立只读库再配置。 + HYDROGEN_DB_HOST: "${HYDROGEN_DB_HOST:-}" + HYDROGEN_DB_PORT: "${HYDROGEN_DB_PORT:-3306}" + HYDROGEN_DB_USER: "${HYDROGEN_DB_USER:-}" + HYDROGEN_DB_PASSWORD: "${HYDROGEN_DB_PASSWORD:-}" + HYDROGEN_DB_NAME: "${HYDROGEN_DB_NAME:-}" + HYDROGEN_TENANT_ID: "${HYDROGEN_TENANT_ID:-000000}" # ECS production accesses OneOS over the private network. Configure the key # as a Portainer stack environment variable; never commit it to this file. ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}" @@ -27,7 +31,9 @@ services: ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}" SERVER_PORT: "8111" EXTERNAL_API_BASE: "https://lnh2e.com" - JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7" + BI_AUTH_MODE: "${BI_AUTH_MODE:-sso}" + BI_AUTH_PASSWORD: "${BI_AUTH_PASSWORD:-}" + JWT_SECRET: "${JWT_SECRET:?请注入至少 32 字符的随机签名密钥}" AMAP_WEB_KEY: "${AMAP_WEB_KEY}" AMAP_SECURITY_JS_CODE: "${AMAP_SECURITY_JS_CODE}" HEATMAP_DB_HOST: "${HEATMAP_DB_HOST}" @@ -36,6 +42,13 @@ services: HEATMAP_DB_PASSWORD: "${HEATMAP_DB_PASSWORD}" HEATMAP_DB_NAME: "${HEATMAP_DB_NAME:-lingniu_vehicle_data}" HEATMAP_DB_SSL: "${HEATMAP_DB_SSL:-false}" + # 用户反馈截图上传所需;此前缺失会导致反馈上传必然失败。 + OSS_REGION: "${OSS_REGION}" + OSS_ENDPOINT: "${OSS_ENDPOINT}" + OSS_BUCKET: "${OSS_BUCKET}" + OSS_ACCESS_KEY_ID: "${OSS_ACCESS_KEY_ID}" + OSS_ACCESS_KEY_SECRET: "${OSS_ACCESS_KEY_SECRET}" + OSS_BASE_DIR: "${OSS_BASE_DIR}" deploy: replicas: 1 restart_policy: diff --git a/docs/.DS_Store b/docs/.DS_Store deleted file mode 100644 index 894578a..0000000 Binary files a/docs/.DS_Store and /dev/null differ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..22c2f88 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,165 @@ +# 架构与分层约定 + +本文是这个仓库的结构契约。**6 条硬规则由 `src/architecture.test.ts` 断言**,破坏即测试失败。 + +## 1. 分层与依赖方向 + +``` +app/ 应用外壳(路由、导航注册) + ↓ +modules/ 前端业务域 —— 只依赖 shared/ + ↓ +shared/ 叶子层:角色常量、跨端 DTO、纯工具(不得依赖 modules/ 或 server/) + +server/ 后端 —— 只依赖 shared/,不得依赖 modules/ +``` + +**依赖只能向下**。前端通过 HTTP 调用后端,不通过 import。 + +### 6 条硬规则 + +| # | 规则 | 违反后果 | +| --- | --- | --- | +| 1 | `server/**` 不得 import `modules/**` | 服务端被前端类型/组件绑架,无法独立部署与测试 | +| 2 | `modules/**`、`components/**`、`auth/**`、`app/**` 不得 import `server/**` | 打包会拖入服务端代码与密钥 | +| 3 | `shared/**` 不得 import `modules/**` / `server/**` / `components/**` | 叶子层反向依赖会让依赖图成环 | +| 4 | 不得出现 `vendor/` 引用 | 生产代码必须位于业务域目录,不能藏在"第三方/参考"目录里 | +| 5 | `model.ts` / `model.tsx` 不得 import `react` | 纯模型必须能在 Node 里直接单测,不依赖 DOM | +| 6 | `@ts-nocheck` 只允许出现在已登记的原型快照文件 | 类型门禁对最大文件失效 | + +### 关于第 6 条的豁免清单 + +这三个文件是**逐字节保留**的 8113 验收原型(UI / CSS 冻结,改动需重新做桌面与移动端截图验收): + +``` +modules/energy/hydrogen/board/EnergyBiBoardApp.tsx +modules/energy/hydrogen/station-daily/StationDailyApp.tsx +modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx +``` + +豁免清单是**只减不增**的:新增 `@ts-nocheck` 会被测试拦下,请改为修正类型。 + +## 2. 前端业务域的形状 + +每个域尽量保持同一形状,便于"进一个目录就知道从哪读起": + +``` +modules// + index.tsx 域入口组件(导出给 app/modules.ts 注册) + api.ts 只做 HTTP,不做业务判断 + types.ts DTO + model.ts 纯函数:筛选 / 排序 / 聚合 / 口径(可被 node:test 直接测) + components/ 无状态展示组件 + *.css 域内样式 +``` + +复杂域可以再分子域,例如氢能: + +``` +modules/energy/hydrogen/ + index.tsx 导航入口 + api.ts types.ts 数据访问与 DTO + model/ 纯逻辑(格式化、承担方标签、按日汇总格式) + board/ 经营看板 UI + station-daily/ 单站日报 UI + drill/ 下钻弹层 UI + common/ 域内共享工具与组件 + fonts/ 看板专用字体 +``` + +判定标准很简单:**能在 Node 里直接测的放 `model/`;需要浏览器/DOM 的放 UI 子目录。** + +## 3. 后端结构 + +``` +server/ + config.ts 环境变量集中读取 + assertRuntimeConfig() 启动期校验 + app.ts createApp():装配中间件、挂载路由、静态托管(无副作用,可测试) + index.ts 进程启动:config 校验 → app → bootstrap → listen + local.ts 只读预览启动(不建表、不定时任务) + bootstrap.ts 启动副作用总入口 + db/ mysql / hydrogen / heatmap 连接 + db/schema/ 运行时建表/改表的唯一位置(只读模式下整体跳过) + middleware/ auth(JWT → 注入 user)、read-only + auth/ 登录换票、固定密码、权限过滤与脱敏 + routes// 按业务域的 HTTP 路由 +``` + +### 后端业务域的形状(`server/routes//`) + +命名约定:`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//`:建 `api.ts`(HTTP)、`model.ts`(纯逻辑)、`index.tsx`(入口)、`components/`。 +2. `src/app/modules.ts`:注册导航项,必要时用 `roles.ts` 的判断控制可见性。 +3. `src/server/routes/.ts`(或 `routes//`):实现接口;需要模块级权限就加 **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 set`)而不是裸关键字,避免把 + `UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。 +- **运行时建表已集中到 `server/db/schema/`**,并在 `DB_READ_ONLY=1` 时整体跳过(由架构测试守护, + 已实测不触碰数据库)。它仍由业务接口在首次调用时触发,而不是只由 `bootstrap.ts` 调用—— + 要彻底改成显式迁移,需要先建立数据库变更脚本流程,避免"代码里偷偷建表"。 +- **`src/lib/cn.ts` 刻意不引入 `tailwind-merge`**:全仓只有 2 处调用,为此新增一个 + 传递依赖不划算。若将来条件类覆盖变多,再换 `clsx` + `tailwind-merge`。 +- **前端 `dist` 体积**:氢能看板单个 chunk 约 200 kB(gzip 50 kB),来自原型渲染方式(大量内联样式)。 + +### 已收敛的重复实现(不要退回多份) + +| 能力 | 唯一实现 | 由测试守护 | +| --- | --- | --- | +| Excel 导出(拼装与写出) | `src/shared/xlsx.ts` | 架构测试:`modules/**` 不得再出现 `book_new` / `book_append_sheet` / `writeFile(` | +| 高德 JSAPI 加载与底图 | `src/shared/amap.ts` | 架构测试:只有它可 import `@amap/amap-jsapi-loader` | +| 运行时 DDL(建表/改表) | `src/server/db/schema/*` | 架构测试:其他 server 文件不得出现 `CREATE TABLE` / `ALTER TABLE`;每个 schema 模块必须检查 `ddlAllowed()` | +| 自然日区间(近 N 天/快捷区间) | `src/shared/date-range.ts`、`modules/energy/daily-range/model.ts` | `date-range` 暂无独立测试;改动请补 | +| 氢能数值格式化 | `modules/energy/hydrogen/model/display-format.ts` | `display-format.test.ts` | +| 角色常量与模块可见性 | `src/shared/auth/roles.ts` | `app/modules.test.ts` | +| 环境变量读取与校验 | `src/server/config.ts` | `auth/password.test.ts` 覆盖密码模式;JWT 缺失拒绝启动已实测 | + diff --git a/docs/auth-portainer.md b/docs/auth-portainer.md new file mode 100644 index 0000000..71f61f0 --- /dev/null +++ b/docs/auth-portainer.md @@ -0,0 +1,46 @@ +# 登录方式与 Portainer 配置 + +不设置 `BI_AUTH_MODE` 时默认 `sso`,沿用业务系统 jumpToken 登录。 +设置 `BI_AUTH_MODE=password` 时只开放固定密码登录,关闭 SSO 换票入口。 +浏览器从 `/api/auth/config` 获取模式,密码不会打包到前端,不使用 VITE_ 密码变量。 + +## Portainer Stack + +在现有 BI 服务的 `environment` 中合并以下配置,保留原镜像、端口、数据库等配置: + +```yaml +environment: + BI_AUTH_MODE: ${BI_AUTH_MODE:-sso} + BI_AUTH_PASSWORD: ${BI_AUTH_PASSWORD:-} + JWT_SECRET: ${JWT_SECRET:?必须配置独立的随机签名密钥} + DEV_BYPASS_AUTH: "0" + NODE_ENV: production +``` + +在 Stack 的 Environment variables 区域录入以下名称和值: + +| 名称 | 固定密码模式 | SSO 模式 | +| --- | --- | --- | +| BI_AUTH_MODE | password | sso | +| BI_AUTH_PASSWORD | 非空密码,允许短密码,推荐独立随机密码 | 留空 | +| JWT_SECRET | 独立随机密钥,至少 32 字符 | 保留部署专用密钥 | + +不要把真实密码提交进 Git。Portainer 中填写的 Stack 变量必须通过上面的 environment 映射才能进入容器。 +更新 Stack 并重新创建容器;单纯 Restart 不会更新容器环境变量。 +容器方式部署:Duplicate/Edit → Advanced container settings → Env,填写同样变量,再重新部署。 +需要先构建包含本功能的新镜像;旧镜像不认识这些变量。 + +## 权限、安全和验证 + +- 固定密码身份是共享只读身份,具有全量看板数据读取范围及能源访问权限,无调度/反馈管理角色;服务端拒绝写入方法。 +- 会话有效期 8 小时。更换密码、JWT_SECRET 或切换模式后,原密码会话失效。 +- 每个服务实例 15 分钟最多 20 次密码验证请求;多副本需在网关配置共享限流。共享限流可能被恶意请求耗尽,建议只在内网或受控网络开放。 +- 外网必须使用 HTTPS,避免明文传输密码和令牌。密码/环境变量对拥有容器管理权限的人可见。 +- 密码为空或全为空白,或签名密钥少于 32 字符时拒绝登录;无默认访问密码。不要沿用镜像中的旧默认 JWT_SECRET。 +- 短密码容易被猜中,仅建议在内网或受限访问环境使用;登录限流保持开启。 +- DB_READ_ONLY=1 时密码登录仍可用,业务写入仍被禁止。 +- 测试未登录访问、错误密码、正确密码、刷新恢复会话;切回 sso 后检查跳转登录。 +- 开发免登录仅 SSO 开发模式生效,生产必须保持 DEV_BYPASS_AUTH=0。 + +Portainer 官方说明:https://docs.portainer.io/user/docker/containers/advanced +以及 https://docs.portainer.io/sts/user/docker/stacks/add diff --git a/docs/ln-bi-项目移交交接说明.md b/docs/ln-bi-项目移交交接说明.md new file mode 100644 index 0000000..3354636 --- /dev/null +++ b/docs/ln-bi-项目移交交接说明.md @@ -0,0 +1,671 @@ +# LN-BI 项目移交交接说明 + +> 文档版本:1.0 +> 更新日期:2026-08-21 +> 对应代码版本:`1.1.15` +> 对应分支 / 提交:`main` / `2518d9ee5440c39138427ce76d1f6e0faf8d9f29` +> 项目仓库:`https://gitea.lnh2e.com/shishengliang/ln-bi.git` + +## 1. 文档目的与交接边界 + +本文用于帮助新的研发、测试、运维和产品人员快速接管 LN-BI 项目,覆盖: + +- 系统定位、代码结构和运行拓扑; +- 登录认证、角色鉴权和数据权限; +- 本地开发与免登录调试; +- 前后端技术栈; +- 数据对接方、数据库、外部服务及对应 BI / 报表; +- 当前功能、后台任务、接口边界和部署流程; +- 已知技术债、安全风险和交接检查清单。 + +本文依据当前仓库代码和配置编写,不记录数据库密码、JWT 密钥、API Key、OSS 密钥或 CI 仓库凭据。此类信息应通过密码管理器、Portainer / Docker 环境变量或 CI Secret 单独移交。 + +## 2. 项目概览 + +### 2.1 系统定位 + +LN-BI 是羚牛业务数据的统一 BI Web 应用。目前包含两组主入口: + +| 主入口 | 主要模块 | 默认路由 | +| --- | --- | --- | +| 资产 BI | 资产管理、里程管理、车辆 / 加氢热力图、智能调度 | `/asset` | +| 能源 BI | 氢能、电能、ETC | `/energy` | + +应用还保留两个不在主导航中展示的后台入口: + +| 隐藏入口 | 用途 | 路由 | +| --- | --- | --- | +| 充电记录导入 | 上传并管理电费 XLSX 数据 | `/ele/import` | +| 用户反馈管理 | 查看、回复和推进用户反馈 | `/admin/feedback` | + +页面内部主要使用 Hash 保存模块和子页面状态,例如 `/energy#hydrogen/overview`。入口兼容逻辑位于 `src/app/routing.ts`。 + +### 2.2 总体架构 + +```mermaid +flowchart LR + A[羚牛业务系统] -->|一次性 jumpToken| B[LN-BI React 前端] + B -->|/api/auth/exchange| C[Hono API] + C -->|校验 jumpToken / 获取用户角色| A + C -->|签发 8 小时本地 JWT| B + B -->|Bearer JWT| C + + C --> D[(主业务 MySQL / 跨 Schema)] + C --> E[(氢能 MySQL 连接\n默认复用主库)] + C --> F[(车辆位置 PostgreSQL)] + C --> G[OneOS 里程 API] + C --> H[阿里云 OSS] + B --> I[高德地图 JS API] +``` + +生产环境由一个 Node.js 进程同时提供 REST API 和 Vite 构建后的静态页面。开发环境中,Vite 前端和 Hono 后端分别运行,由 Vite 将 `/api` 代理到后端。 + +### 2.3 关键目录 + +| 路径 | 说明 | +| --- | --- | +| `src/App.tsx` | 应用入口、认证门禁、主路由和隐藏页面装配 | +| `src/app/modules.ts` | 资产 / 能源导航模块注册与角色可见性 | +| `src/auth/` | 前端认证状态、JWT 注入、未授权页面 | +| `src/shared/auth/` | 前后端共用角色常量和模块访问判断 | +| `src/server/` | Hono 服务、认证中间件、数据库连接、API 路由和后台任务 | +| `src/modules/` | 各 BI 页面和交互实现 | +| `src/modules/energy/hydrogen-bi-v2/` | 当前氢能 BI 原型迁入版、真实数据适配器和下钻实现 | +| `docs/` | 接口、重构和交接文档 | +| `Dockerfile` | 前端构建 + Node 运行时镜像 | +| `docker-compose.yml` | 当前容器部署参数样例 | +| `woodpecker.yml` | Woodpecker CI 构建、测试、镜像推送流程 | + +### 2.4 当前质量基线 + +2026-08-21 在当前提交和本地依赖环境完成以下验证: + +| 检查 | 结果 | +| --- | --- | +| `npm run lint` | 通过 | +| `npm test` | 113 项通过,0 项失败 | +| `npm run build` | 通过,Vite 成功生成生产构建 | + +测试和构建会输出 Node.js `DEP0205`(`module.register()` 已弃用)警告,目前不阻断运行;后续升级 `tsx` / Node 工具链时应处理。 + +## 3. 登录认证方式 + +### 3.1 正常登录链路 + +项目自身没有用户名 / 密码登录表单,正常入口依赖羚牛业务系统单点跳转: + +1. 用户先登录羚牛业务系统。 +2. 业务系统跳转到 LN-BI,并在 URL 中携带一次性 `jumpToken`。 +3. 前端调用 `GET /api/auth/exchange?jumpToken=...`。 +4. LN-BI 后端调用业务系统的 `issueTokenByJump` 接口校验一次性令牌并取得用户、部门编码和角色。 +5. 后端根据角色计算数据权限级别,并通过主业务库 `tab_department` 补全部门名称。 +6. 后端使用 `JWT_SECRET` 签发有效期 8 小时的 LN-BI JWT。 +7. 前端将 JWT 和用户信息保存到当前标签页的 `sessionStorage`: + - `bi_jwt` + - `bi_user` +8. 前端从地址栏移除 `jumpToken`,之后所有受保护 API 均发送 `Authorization: Bearer `。 +9. API 返回 `401` 时,前端清空本地会话并显示“会话已过期”。 + +关键实现: + +- 前端:`src/auth/AuthProvider.tsx` +- Token 注入:`src/auth/api-client.ts` +- Token 换取:`src/server/auth/login.ts` +- API 认证:`src/server/auth/middleware.ts` + +### 3.2 公开接口与受保护接口 + +不要求 JWT 的接口: + +- `/api/health` +- `/api/auth/*` + +其余 `/api/*` 默认经过统一认证中间件。能源、智能调度、加氢热力图和反馈管理在统一认证之上还有模块级或接口级角色校验。 + +### 3.3 会话特征 + +| 项目 | 当前实现 | +| --- | --- | +| Token 类型 | HS256 JWT(`jsonwebtoken` 默认签名算法) | +| 有效期 | 8 小时 | +| 浏览器存储 | `sessionStorage`,关闭标签页后失效 | +| 用户来源 | 羚牛业务系统 jumpToken 换取 | +| 部门名称来源 | 主业务库 `tab_department` | +| 退出方式 | 当前无独立退出接口;清除标签页会话或收到 401 后清理 | + +## 4. 角色鉴权与数据权限 + +权限分为两层,接手时必须分别理解: + +1. **模块访问权限**:决定能否进入能源、智能调度或反馈后台。 +2. **数据范围权限**:决定用户可查看全量、部门或个人负责的数据。 + +### 4.1 数据范围角色 + +| 业务系统角色 | JWT `permissionLevel` | 数据范围 | +| --- | --- | --- | +| `所有权限`、`数智中心`、`BI-Leader` | `full` | 全量数据 | +| `BI-Leader-Dep` | `department` | `departmentName / department = user.depName` | +| 其他角色或无上述角色 | `personal` | `managerId = user.userId` | + +数据过滤和客户名称脱敏由 `src/server/auth/permissions.ts` 提供。目前明确接入该过滤的主要范围包括: + +- 资产车辆统计与列表; +- 里程实时监控; +- 里程考核车辆明细; +- 智能调度建议。 + +注意:数据权限不是数据库行级安全,而是路由读取数据后在服务端过滤。新增 API 时必须主动调用统一过滤逻辑或实现等价的 SQL 过滤,不能只依赖前端隐藏。 + +### 4.2 模块访问角色 + +| 模块 / 能力 | 允许角色 | 前端控制 | 后端控制 | +| --- | --- | --- | --- | +| 资产管理、里程管理、车辆热力图 | 已认证用户 | 导航默认展示 | 统一 JWT 中间件 | +| 能源 BI(氢能 / 电能 / ETC) | `BI-LEADER-ENERGY` 或 `所有权限` | `/energy` 门禁 | `/api/energy/*` 守卫 | +| 加氢热力图 | `BI-LEADER-ENERGY` 或 `所有权限` | 无权限时不出现在热力图子导航 | `/api/hydrogen-heatmap/*` 守卫 | +| 智能调度 | `BI-SCHEDULE-OPT` | 无角色时不显示模块 | `/api/scheduling/*` 守卫 | +| 反馈管理 | `BI-ADMIN-FEEDBACK` 或任一全量数据角色 | 隐藏页面,需直接访问 | 管理列表与更新接口校验 | +| 用户反馈提交 / 我的反馈 | 已认证用户 | 页面反馈入口 | 统一 JWT 中间件 | + +角色常量和判断位于 `src/shared/auth/roles.ts`。 + +### 4.3 容易误解的权限边界 + +- `full` 只表示数据范围,不代表自动拥有所有模块权限。 +- `数智中心`、`BI-Leader` 虽会获得全量数据,但当前不会自动进入能源模块;能源仅额外接受 `所有权限`。 +- 智能调度当前仅接受 `BI-SCHEDULE-OPT`,不会因为拥有 `full` 数据权限自动放行。 +- 前端隐藏不是安全措施;任何新增受限能力都必须增加后端守卫。 + +## 5. 本地免登录调试环境 + +### 5.1 环境要求 + +- Node.js 22(Docker 和 CI 均使用 Node 22); +- npm,依赖版本以 `package-lock.json` 为准; +- 能访问所需数据库和外部 API 的网络环境; +- 本地配置文件 `.env`。该文件已被 `.gitignore` 忽略,禁止提交。 + +### 5.2 最小启动步骤 + +```bash +npm ci +npm run dev +``` + +启动后: + +| 服务 | 地址 | 说明 | +| --- | --- | --- | +| Vite 前端 | `http://localhost:3000` | 对局域网开放,`/api` 代理到 3001 | +| Hono 后端 | `http://localhost:3001` | `SERVER_PORT` 未设置时的默认端口 | +| 健康检查 | `http://localhost:3001/api/health` | 只验证进程可访问,不验证数据库 | + +### 5.3 免登录开关 + +本地前后端需要同时开启免登录: + +```dotenv +# 仅用于本地开发,生产环境严禁设置为 1 +VITE_DEV_BYPASS_AUTH=1 +DEV_BYPASS_AUTH=1 +``` + +- `VITE_DEV_BYPASS_AUTH=1`:前端直接构造“本地开发”全权限用户,仅在 Vite dev 模式生效。 +- `DEV_BYPASS_AUTH=1`:后端认证中间件注入本地开发用户,否则前端虽然进入页面,API 仍会返回 401。 +- 本地用户包含 `所有权限`、`BI-SCHEDULE-OPT`、`BI-ADMIN-FEEDBACK`、`BI-LEADER-ENERGY`。 +- 免登录只绕过认证,不会替代数据库、OneOS API、高德地图或 OSS 配置。 + +### 5.4 `.env` 配置模板 + +以下仅列变量名和占位符,不得把真实值写入本文或提交到 Git: + +```dotenv +# 认证 +EXTERNAL_API_BASE=https://<业务系统域名> +JWT_SECRET=<高强度随机密钥> +DEV_BYPASS_AUTH=1 +VITE_DEV_BYPASS_AUTH=1 + +# 主业务 MySQL +DB_HOST= +DB_PORT=3306 +DB_USER= +DB_PASSWORD= +DB_NAME= + +# 氢能 MySQL;不填时复用 DB_* +HYDROGEN_DB_HOST= +HYDROGEN_DB_PORT=3306 +HYDROGEN_DB_USER= +HYDROGEN_DB_PASSWORD= +HYDROGEN_DB_NAME= + +# 历史里程 MySQL 连接配置 +# 当前 src/server/mileage-db.ts 未被运行时代码引用;不要误认为修改后会影响里程页面 +MILEAGE_DB_HOST= +MILEAGE_DB_PORT=3306 +MILEAGE_DB_USER= +MILEAGE_DB_PASSWORD= +MILEAGE_DB_NAME= + +# 车辆位置 PostgreSQL +HEATMAP_DB_HOST= +HEATMAP_DB_PORT=5432 +HEATMAP_DB_USER= +HEATMAP_DB_PASSWORD= +HEATMAP_DB_NAME= +HEATMAP_DB_SSL=false + +# OneOS 里程 API +ONEOS_MILEAGE_API_BASE_URL=https:// +ONEOS_MILEAGE_API_KEY= +ONEOS_MILEAGE_API_TIMEOUT_MS=20000 + +# 高德地图 +AMAP_WEB_KEY= +AMAP_SECURITY_JS_CODE= + +# 用户反馈截图 OSS +OSS_REGION= +OSS_ENDPOINT= +OSS_BUCKET= +OSS_ACCESS_KEY_ID= +OSS_ACCESS_KEY_SECRET= +OSS_BASE_DIR= + +# 运行参数 +SERVER_PORT=3001 +MILEAGE_REPORT_AUTO_ARCHIVE=0 +``` + +本地调试建议将 `MILEAGE_REPORT_AUTO_ARCHIVE=0`,避免开发进程在 06:30 自动生成正式日报快照。 + +## 6. 技术栈 + +### 6.1 前端 + +| 分类 | 技术 | +| --- | --- | +| 语言 | TypeScript、TSX、CSS | +| 框架 | React 19 | +| 构建工具 | Vite 6 | +| 样式 | Tailwind CSS 4 + 模块专用 CSS | +| 图表 | Recharts 3;氢能原型包含自定义 DOM / CSS 图表 | +| 动效 | Motion for React | +| 图标 | Lucide React | +| Excel | SheetJS `xlsx` | +| 地图 | 高德地图 JS API | +| 路由 | 浏览器 Path + Hash 自研轻量路由,无 React Router | + +### 6.2 后端 + +| 分类 | 技术 | +| --- | --- | +| 语言 / 运行时 | TypeScript、Node.js 22、ES Module | +| Web 框架 | Hono 4 + `@hono/node-server` | +| TypeScript 运行 | `tsx` | +| 认证 | `jsonwebtoken` | +| MySQL | `mysql2/promise` | +| PostgreSQL | `pg` | +| 对象存储 | `ali-oss` | +| 配置 | `dotenv` / 容器环境变量 | +| 测试 | Node.js 内置 Test Runner | + +### 6.3 构建与部署 + +- `npm run lint`:TypeScript 静态检查; +- `npm test`:运行 `src/**/*.test.ts`; +- `npm run build`:生成 Vite `dist`; +- `npm run start`:Node 直接通过 `tsx` loader 启动服务; +- Docker:Node 22 Alpine 多阶段构建; +- CI:Woodpecker 执行安装、静态检查、测试、构建、Docker 镜像构建和 Harbor 推送; +- 镜像标签:`<分支名>-`; +- 当前 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 和经营数据正常。 diff --git a/docs/local-preview.md b/docs/local-preview.md new file mode 100644 index 0000000..81100ae --- /dev/null +++ b/docs/local-preview.md @@ -0,0 +1,13 @@ +# 本地只读预览 + +使用 Node.js 22+ 和 npm,在本项目内执行 `npm ci`,避免依赖其他工作目录的 node_modules 软链接。 + +将 `.env.local.example` 复制为 `.env.local` 并填写连接配置,或通过进程环境变量提供配置。使用数据库侧只读账号;不要提交真实凭据。 + +执行 `npm run dev:local`,浏览器访问 http://127.0.0.1:8115/energy#hydrogen 。前后端固定绑定本机 8115 / 3001;端口占用时退出,不自动换端口。停止终端进程即可停止服务;此命令不配置开机自启。 + +本地入口关闭 mock、启用本地免登录,禁用后台建表与定时归档,并拒绝 API 的 POST / PUT / PATCH / DELETE。请勿把免登录预览暴露到公网。氢能查询启用只读 SQL 检查;这不能替代数据库账号的只读权限。 + +`npm run dev` / `npm start` 保留原部署方式;设置 `DB_READ_ONLY=1` 时同样不启动后台任务,且 API 禁止写入。生产登录流程需要写请求,因此不要把本地只读模式当作生产部署配置。 + +验证:`npm test`、`npm run lint`、`npm run build`;健康检查为 `GET http://127.0.0.1:3001/api/health`。健康检查成功仅代表进程可用,还需检查氢能 meta / overview 的真实数据响应。 diff --git a/docs/superpowers/.DS_Store b/docs/superpowers/.DS_Store deleted file mode 100644 index 5115dd2..0000000 Binary files a/docs/superpowers/.DS_Store and /dev/null differ diff --git a/docs/superpowers/plans/2026-04-01-mileage-module.md b/docs/superpowers/plans/2026-04-01-mileage-module.md index 8c5b74a..760da80 100644 --- a/docs/superpowers/plans/2026-04-01-mileage-module.md +++ b/docs/superpowers/plans/2026-04-01-mileage-module.md @@ -43,7 +43,7 @@ const mileagePool = mysql.createPool({ host: '101.133.130.65', port: 3306, user: 'bi_reader_02', - password: 'bi_reader_02_Pass', + password: process.env.MILEAGE_DB_PASSWORD, database: 'hydrogen_energy', waitForConnections: true, connectionLimit: 5, diff --git a/docs/superpowers/specs/2026-04-01-mileage-module-design.md b/docs/superpowers/specs/2026-04-01-mileage-module-design.md index 160065b..2dc39f5 100644 --- a/docs/superpowers/specs/2026-04-01-mileage-module-design.md +++ b/docs/superpowers/specs/2026-04-01-mileage-module-design.md @@ -20,7 +20,7 @@ ### 数据库 2:hydrogen_energy(新增连接) -- 连接信息:`101.133.130.65:3306`,用户 `bi_reader_02`,密码 `bi_reader_02_Pass`,库名 `hydrogen_energy` +- 连接信息:`101.133.130.65:3306`,用户 `bi_reader_02`,密码 `<见环境变量 MILEAGE_DB_PASSWORD>`,库名 `hydrogen_energy` - `v_vehicle_daily_stats` — 1004 辆车的每日里程明细(plate, vin, stat_date, daily_km, total_km, day_hydrogen, daily_run_secs, source) ## 架构 diff --git a/package-lock.json b/package-lock.json index dd5f401..0118704 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ln-bi", - "version": "1.1.14", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ln-bi", - "version": "1.1.14", + "version": "1.2.0", "dependencies": { "@amap/amap-jsapi-loader": "^1.0.1", "@hono/node-server": "^1.13.0", diff --git a/package.json b/package.json index 03b0d3f..f55f368 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,19 @@ { "name": "ln-bi", "private": true, - "version": "1.1.14", + "version": "1.2.0", "type": "module", "scripts": { + "dev:local": "concurrently -k -n server,client \"npm run dev:local:server\" \"npm run dev:local:client\"", + "dev:local:server": "node --import tsx src/server/local.ts", + "dev:local:client": "DEV_MOCK_API=0 VITE_DEV_BYPASS_AUTH=1 vite --host 127.0.0.1 --port 8115 --strictPort", "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", "dev:server": "tsx watch src/server/index.ts", "dev:client": "vite --port=3000 --host=0.0.0.0", "build": "vite build", "start": "node --import tsx src/server/index.ts", "lint": "tsc --noEmit", - "test": "node --import tsx --test $(find src -type f -name '*.test.ts' -print | sort)", + "test": "node --import tsx --test $(find src -type f \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print | sort)", "test:mileage-report": "node --import tsx --test src/server/routes/mileage/daily-report-model.test.ts" }, "dependencies": { diff --git a/public/lingniu-logo-light.svg b/public/lingniu-logo-light.svg new file mode 100644 index 0000000..626c3d8 --- /dev/null +++ b/public/lingniu-logo-light.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts-tmp/excel_plates.txt b/scripts-tmp/excel_plates.txt deleted file mode 100644 index f518b34..0000000 --- a/scripts-tmp/excel_plates.txt +++ /dev/null @@ -1,178 +0,0 @@ -沪A00113F -沪A00220F -沪A00333F -沪A00607F -沪A01056F -沪A01311F -沪A01775F -沪A01813F -沪A01855F -沪A02303F -沪A02311F -沪A02326F -沪A02361F -沪A02720F -沪A03086F -沪A03397F -沪A03565F -沪A03620F -沪A03659F -沪A03801F -沪A03870F -沪A05035F -沪A05113F -沪A05223F -沪A05501F -沪A05675F -沪A05697F -沪A05830F -沪A06335F -沪A06599F -沪A06695F -沪A07006F -沪A07153F -沪A07806F -沪A08037F -沪A08150F -沪A08315F -沪A08598F -沪A08786F -沪A09100F -沪A09251F -沪A09276F -沪A09303F -沪A09313F -沪A09322F -沪A09689F -沪A30010F -沪A30399F -沪A31031F -沪A31211F -沪A31281F -沪A31308F -沪A31381F -沪A31613F -沪A32269F -沪A33216F -沪A35236F -沪A35798F -沪A35879F -沪A35898F -沪A36133F -沪A36169F -沪A36569F -沪A36980F -沪A37785F -沪A38795F -沪A39287F -沪A39289F -沪A39585F -沪A39608F -沪A39626F -沪A39815F -沪A39835F -沪A39912F -沪A50026F -沪A50069F -沪A50309F -沪A51580F -沪A51612F -沪A51677F -沪A51893F -沪A52331F -沪A52511F -沪A53309F -沪A53322F -沪A53506F -沪A53960F -沪A55179F -沪A55297F -沪A55339F -沪A55666F -沪A55695F -沪A56122F -沪A56701F -沪A56959F -沪A56988F -沪A57139F -沪A57167F -沪A57198F -沪A57838F -沪A57850F -沪A57895F -沪A58087F -沪A58159F -沪A58185F -沪A58307F -沪A58533F -沪A58538F -沪A58593F -沪A58922F -沪A59095F -沪A59510F -沪A59613F -沪A59682F -沪A59799F -沪A59932F -沪A60339F -沪A60691F -沪A60820F -沪A61187F -沪A61193F -沪A61312F -沪A61559F -沪A61600F -沪A61711F -沪A61738F -沪A62322F -沪A62772F -沪A62928F -沪A63013F -沪A63305F -沪A63522F -沪A63660F -沪A63697F -沪A65036F -沪A65181F -沪A65522F -沪A65995F -沪A66216F -沪A66256F -沪A66329F -沪A66593F -沪A66710F -沪A66921F -沪A67018F -沪A67033F -沪A67872F -沪A68115F -沪A68139F -沪A68332F -沪A68613F -沪A68658F -沪A68752F -沪A69311F -沪A69826F -沪A69997F -沪A85021F -沪A89315F -沪A89385F -沪A89662F -浙F00885F -浙F08889F -浙F09898F -粤A00255F -粤A02683F -粤A02956F -粤A03502F -粤A03532F -粤A03569F -粤A05106F -粤A05391F -粤A05428F -粤A05839F -粤A05985F -粤A05995F -粤A06569F -粤A06931F -粤A06932F diff --git a/scripts-tmp/find_extra.ts b/scripts-tmp/find_extra.ts deleted file mode 100644 index d145050..0000000 --- a/scripts-tmp/find_extra.ts +++ /dev/null @@ -1,60 +0,0 @@ -import mysql from 'mysql2/promise'; -import fs from 'node:fs'; - -const pool = mysql.createPool({ - host: 'rm-uf65w5v2r77n674x2.mysql.rds.aliyuncs.com', - port: 3306, - user: 'root', - password: 'LN#Passw0rd@2026', - database: 'lingniu_prod', - connectTimeout: 15000, ssl: { rejectUnauthorized: false }, -}); - -async function main() { - const excelPlates = new Set( - fs.readFileSync('/Users/kkfluous/Projects/ai-coding/ln-bi/scripts-tmp/excel_plates.txt', 'utf8').trim().split('\n').map((s) => s.trim()) - ); - console.log('excel plates:', excelPlates.size); - - // 按 dept-stats 逻辑查金可鹏 18T Operating - const [rows] = await pool.query(` - 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); }); diff --git a/src/App.tsx b/src/App.tsx index a40f88d..08b23b9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { Shell } from "./components/Shell"; import AuthProvider from "./auth/AuthProvider"; import { useAuth } from "./auth/useAuth"; import UnauthorizedPage from "./auth/UnauthorizedPage"; +import PasswordLogin from "./auth/PasswordLogin"; import { canAccessEnergy } from "./shared/auth/roles"; import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface"; import { buildModules } from "./app/modules"; @@ -13,10 +14,16 @@ import { const EleImportPage = lazy(() => import("./modules/ele/EleImportPage")); const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage")); +const HydrogenPrototypeBoard = lazy( + () => + import("./modules/energy/hydrogen/board/EnergyBiBoardApp").then( + ({ EnergyBiBoardApp }) => ({ default: EnergyBiBoardApp }), + ), +); normalizeBrowserPath(); function AuthGate() { - const { isLoading, isAuthenticated, error, user } = useAuth(); + const { isLoading, isAuthenticated, error, user, mode } = useAuth(); const [route, setRoute] = useState(readBrowserRoute); const { routeKey, pathSet } = route; @@ -65,6 +72,7 @@ function AuthGate() { } if (!isAuthenticated) { + if (mode === 'password') return ; return ; } @@ -83,6 +91,16 @@ function AuthGate() { ); } + if (routeKey === "energy/hydrogen-board") { + if (!canAccessEnergy(user?.roles)) { + return ; + } + return ( + }> + + + ); + } // /energy 整组按能源权限控制 if (pathSet === "energy" && !canAccessEnergy(user?.roles)) { diff --git a/src/app/modules.test.ts b/src/app/modules.test.ts index 02b8d40..27e2292 100644 --- a/src/app/modules.test.ts +++ b/src/app/modules.test.ts @@ -35,4 +35,5 @@ test('keeps energy heatmap visibility and the independent energy navigation', () buildModules('energy', []).map(module => module.id), ['hydrogen', 'electric', 'etc'], ); + assert.equal(buildModules('energy', [])[0]?.label, '氢费BI'); }); diff --git a/src/app/modules.ts b/src/app/modules.ts index 143c9d3..cfa1ef9 100644 --- a/src/app/modules.ts +++ b/src/app/modules.ts @@ -1,12 +1,12 @@ import { lazy } from 'react'; import { Activity, - BatteryCharging, Fuel, MapPinned, - Receipt, Route, Truck, + Wallet, + Zap, } from 'lucide-react'; import type { ModuleConfig } from '../components/Shell'; import { canAccessEnergy, canAccessScheduling } from '../shared/auth/roles'; @@ -15,7 +15,7 @@ import type { PathSet } from './routing'; const AssetsModule = lazy(() => import('../modules/assets/AssetsModule')); const MileageModule = lazy(() => import('../modules/mileage/MileageModule')); const SchedulingModule = lazy(() => import('../modules/scheduling/SchedulingModule')); -const HydrogenModule = lazy(() => import('../modules/energy/HydrogenModule')); +const HydrogenModule = lazy(() => import('../modules/energy/hydrogen/index')); const ElectricModule = lazy(() => import('../modules/energy/ElectricModule')); const EtcModule = lazy(() => import('../modules/energy/EtcModule')); const VehicleHeatmapModule = lazy(() => import('../modules/vehicle-heatmap/VehicleHeatmapModule')); @@ -48,9 +48,9 @@ const SCHEDULING_MODULE: ModuleConfig = { }; const ENERGY_MODULES: ModuleConfig[] = [ - { id: 'hydrogen', label: '氢能', icon: Fuel, component: HydrogenModule }, - { id: 'electric', label: '电能', icon: BatteryCharging, component: ElectricModule }, - { id: 'etc', label: 'ETC', icon: Receipt, component: EtcModule }, + { id: 'hydrogen', label: '氢费BI', icon: Fuel, component: HydrogenModule }, + { id: 'electric', label: '电能', icon: Zap, component: ElectricModule }, + { id: 'etc', label: 'ETC', icon: Wallet, component: EtcModule }, ]; /** 根据主路径和角色生成导航;返回新数组,避免调用方修改静态注册表。 */ diff --git a/src/app/routing.test.ts b/src/app/routing.test.ts index d2f5a64..a29bb6a 100644 --- a/src/app/routing.test.ts +++ b/src/app/routing.test.ts @@ -9,11 +9,14 @@ test('normalizes legacy asset paths without losing search parameters', () => { test('keeps canonical and hidden administration paths unchanged', () => { assert.equal(resolveCanonicalUrl({ pathname: '/asset', search: '', hash: '#assets' }), null); assert.equal(resolveCanonicalUrl({ pathname: '/admin/feedback', search: '', hash: '' }), null); + assert.equal(resolveCanonicalUrl({ pathname: '/energy/hydrogen-board', search: '', hash: '' }), null); }); test('derives path groups and hidden routes from path or hash', () => { assert.equal(getPathSet('/energy'), 'energy'); + assert.equal(getPathSet('/energy/hydrogen-board'), 'energy'); assert.equal(getPathSet('/asset'), 'asset'); + assert.equal(getRouteKey('/energy/hydrogen-board', ''), 'energy/hydrogen-board'); assert.equal(getRouteKey('/asset', '#/ele/import'), 'ele/import'); assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback'); assert.equal(getRouteKey('/asset', '#mileage'), ''); diff --git a/src/app/routing.ts b/src/app/routing.ts index c7abf66..e241976 100644 --- a/src/app/routing.ts +++ b/src/app/routing.ts @@ -5,7 +5,13 @@ interface BrowserLocation { search: string; hash: string; } -const ROOT_PATHS = new Set(['/asset', '/energy', '/ele/import', '/admin/feedback']); +const ROOT_PATHS = new Set([ + '/asset', + '/energy', + '/energy/hydrogen-board', + '/ele/import', + '/admin/feedback', +]); const LEGACY_PATHS: Record = { '/': { path: '/asset' }, @@ -31,10 +37,13 @@ export function normalizeBrowserPath(): void { } export function getPathSet(pathname: string): PathSet { - return pathname === '/energy' ? 'energy' : 'asset'; + return pathname === '/energy' || pathname === '/energy/hydrogen-board' ? 'energy' : 'asset'; } export function getRouteKey(pathname: string, hash: string): string { + if (pathname === '/energy/hydrogen-board') { + return 'energy/hydrogen-board'; + } if (pathname === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') { return 'ele/import'; } diff --git a/src/architecture.test.ts b/src/architecture.test.ts new file mode 100644 index 0000000..6715ee0 --- /dev/null +++ b/src/architecture.test.ts @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import test from "node:test"; + +/** + * 架构守护测试。 + * + * 这些不是代码风格偏好,而是这个仓库赖以保持"能读懂"的结构约束。 + * 一旦被打破(尤其是 client/server 互相引用、shared 反向依赖上层), + * 依赖图会重新变成无法追踪的网,所以用断言固化。 + */ + +const srcDir = path.dirname(fileURLToPath(import.meta.url)); + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full, out); + else if (/\.(ts|tsx|css)$/.test(entry.name)) out.push(full); + } + return out; +} + +const allFiles = walk(srcDir); +const rel = (file: string) => path.relative(srcDir, file); + +/** 取出文件里所有 import 说明符(含副作用 import / 动态 import / @import)。 */ +function specifiers(file: string): string[] { + const source = readFileSync(file, "utf8"); + const found: string[] = []; + const patterns = [ + /(?:^|[^\w$])(?:import|export)\s+[\s\S]*?\sfrom\s*['"]([^'"]+)['"]/g, + /(?:^|[^\w$])import\s*['"]([^'"]+)['"]/g, + /(?:^|[^\w$])import\s*\(\s*['"]([^'"]+)['"]\s*\)/g, + /@import\s+['"]([^'"]+)['"]/g, + ]; + for (const re of patterns) { + for (const match of source.matchAll(re)) found.push(match[1]); + } + return found; +} + +/** 把相对 import 解析为 src 下的路径(用于判断它指向哪个分区)。 */ +function targetPath(file: string, spec: string): string | null { + if (!spec.startsWith(".")) return null; + return path.relative(srcDir, path.resolve(path.dirname(file), spec)); +} + +/** 去掉注释后再做 SQL 关键字匹配:注释里的 "update status" 之类不应触发守卫。 */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:])\/\/[^\n]*/g, "$1 "); +} + +function filesUnder(prefix: string): string[] { + return allFiles.filter((f) => rel(f).startsWith(prefix)); +} + +function fileContains(file: string, re: RegExp): boolean { + return re.test(readFileSync(file, "utf8")); +} + +test("分层铁律:server 不得依赖前端 modules", () => { + const violations: string[] = []; + for (const file of filesUnder("server/")) { + for (const spec of specifiers(file)) { + const target = targetPath(file, spec); + if (target && target.startsWith("modules/")) violations.push(`${rel(file)} -> ${spec}`); + } + } + assert.deepEqual(violations, [], "server 必须独立于前端:请把共享内容下沉到 src/shared"); +}); + +test("分层铁律:前端不得依赖 server", () => { + const violations: string[] = []; + const clientDirs = ["modules/", "components/", "auth/", "app/"]; + for (const dir of clientDirs) { + for (const file of filesUnder(dir)) { + for (const spec of specifiers(file)) { + const target = targetPath(file, spec); + if (target && target.startsWith("server/")) violations.push(`${rel(file)} -> ${spec}`); + } + } + } + assert.deepEqual(violations, [], "前端不得直接引用服务端模块"); +}); + +test("分层铁律:shared 是最底层,不得反向依赖上层", () => { + const violations: string[] = []; + for (const file of filesUnder("shared/")) { + for (const spec of specifiers(file)) { + const target = targetPath(file, spec); + if (target && (target.startsWith("modules/") || target.startsWith("server/") || target.startsWith("components/"))) { + violations.push(`${rel(file)} -> ${spec}`); + } + } + } + assert.deepEqual(violations, [], "src/shared 必须保持为可被前后端共同依赖的叶子层"); +}); + +test("原型代码不再散落在 vendor 目录", () => { + const violations: string[] = []; + for (const file of allFiles) { + for (const spec of specifiers(file)) { + if (spec.includes("vendor/")) violations.push(`${rel(file)} -> ${spec}`); + } + } + assert.deepEqual(violations, [], "生产代码应位于 src/modules 下的业务域目录,而不是 src/vendor"); +}); + +test("纯模型文件不得依赖 React(保证可被 node:test 直接测)", () => { + const violations: string[] = []; + for (const file of allFiles.filter((f) => /(^|\/)model\.tsx?$/.test(rel(f)))) { + for (const spec of specifiers(file)) { + if (spec === "react" || spec.startsWith("react/")) violations.push(`${rel(file)} -> ${spec}`); + } + } + assert.deepEqual(violations, [], "model.ts 应只包含可在 Node 中直接执行的纯函数"); +}); + +test("高德 SDK 只在 shared/amap.ts 接入", () => { + // 两个热力图曾各自写一份 load + 底图 + 控件 + 色带,导致同类地图长得不一样。 + const importers = allFiles + .filter((f) => rel(f) !== "shared/amap.ts") + .filter((f) => specifiers(f).some((s) => s === "@amap/amap-jsapi-loader")) + .map(rel); + assert.deepEqual(importers, [], "请通过 src/shared/amap.ts 使用高德地图,不要各自加载 SDK"); +}); + +test("Excel 导出只在 shared/xlsx.ts 组装工作簿", () => { + // 允许 server 侧直接读 xlsx(导入解析),但不允许前端再次自行拼装 workbook。 + const offenders = allFiles + .filter((f) => rel(f).startsWith("modules/")) + .filter((f) => { + const source = readFileSync(f, "utf8"); + return /book_new|book_append_sheet|writeFile\(/.test(source); + }) + .map(rel); + assert.deepEqual(offenders, [], "请改用 src/shared/xlsx.ts 的 writeWorkbook / export*Sheet"); +}); + +test("类型豁免清单只减不增:@ts-nocheck 仅限已登记的 8113 原型快照", () => { + // 这三个文件是逐字节保留的验收原型(UI/CSS 冻结),显式登记为唯一豁免。 + const allowed = [ + "modules/energy/hydrogen/board/EnergyBiBoardApp.tsx", + "modules/energy/hydrogen/station-daily/StationDailyApp.tsx", + "modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx", + ]; + const actual = allFiles.filter((f) => fileContains(f, /^\/\/\s*@ts-nocheck/m)).map(rel).sort(); + assert.deepEqual(actual, [...allowed].sort(), "新增 @ts-nocheck 会让类型门禁失效,请改为修正类型"); +}); + +test("运行时 DDL 只允许出现在 server/db/schema", () => { + // 建表原先散落在路由与 store 里,且由 GET 触发,使"只读预览"仍可能写库。 + const offenders = allFiles + .filter((f) => rel(f).startsWith("server/") && !rel(f).startsWith("server/db/schema/")) + .filter((f) => !rel(f).endsWith(".test.ts")) + .filter((f) => /CREATE TABLE|ALTER TABLE/.test(readFileSync(f, "utf8"))) + .map(rel); + assert.deepEqual(offenders, [], "请把建表/改表语句集中到 src/server/db/schema/"); +}); + +test("运行时建表必须尊重只读模式", () => { + const schemaFiles = allFiles.filter((f) => rel(f).startsWith("server/db/schema/") && rel(f) !== "server/db/schema/guard.ts"); + const offenders = schemaFiles + .filter((f) => !readFileSync(f, "utf8").includes("ddlAllowed()")) + .map(rel); + assert.deepEqual(offenders, [], "每个 schema 模块都要在 DB_READ_ONLY=1 时跳过 DDL"); +}); + +test("已完整分层的域:repository.ts 存在,且 SQL 只允许出现在那里", () => { + // 这些域已完成 routes / repository / model 拆分;其余域仍把 SQL 放在路由里 + // (见 docs/ARCHITECTURE.md 的"后端业务域形状"表)。 + const layered = [ + "server/routes/vehicles", + "server/routes/ele", + "server/routes/feedback", + "server/routes/vehicle-heatmap", + "server/routes/hydrogen-heatmap", + "server/routes/scheduling", + "server/routes/energy", + "server/routes/mileage", + ]; + // 用"语句形状"而不是裸关键字:UpdateNotification 或 "update error" 这类标识符/日志 + // 不应误判,而小写 SQL 也依然能被抓到。 + const SQL = /\bselect\b[\s\S]{0,400}?\bfrom\b|\binsert\s+(into|ignore)\b|\bupdate\s+[\w.`]+\s+set\b|\bdelete\s+from\b|\bcreate\s+table\b|\balter\s+table\b/i; + const offenders: string[] = []; + for (const dir of layered) { + const abs = path.join(srcDir, dir); + if (!existsSync(path.join(abs, "repository.ts"))) { + offenders.push(`${dir}/repository.ts 缺失`); + continue; + } + for (const file of readdirSync(abs)) { + if (!file.endsWith(".ts")) continue; + // repository.ts 是 SQL 的归属地;constants.ts 允许存放被多条查询共享的 + // SQL 片段(如公共 WHERE / CTE),否则这些片段会被迫复制到每个查询里。 + if (file === "repository.ts" || file === "constants.ts" || file.endsWith(".test.ts")) continue; + if (SQL.test(stripComments(readFileSync(path.join(abs, file), "utf8")))) { + offenders.push(`${dir}/${file} 仍含 SQL`); + } + } + } + assert.deepEqual(offenders, [], "SQL 必须留在各域的 repository.ts"); +}); diff --git a/src/auth/AuthProvider.tsx b/src/auth/AuthProvider.tsx index 9da7247..7a0b561 100644 --- a/src/auth/AuthProvider.tsx +++ b/src/auth/AuthProvider.tsx @@ -5,6 +5,7 @@ import { setTokenGetter } from './api-client'; const AUTH_API = '/api/auth'; export default function AuthProvider({ children }: { children: ReactNode }) { + const [mode, setMode] = useState<'sso' | 'password'>('sso'); const [state, setState] = useState({ isLoading: true, isAuthenticated: false, @@ -19,8 +20,6 @@ export default function AuthProvider({ children }: { children: ReactNode }) { setTokenGetter(() => tokenRef.current); // 防止 StrictMode 双重调用(jumpToken 一次性使用) - if (authStarted.current) return; - authStarted.current = true; // 监听 401 事件 const onUnauthorized = () => { @@ -30,14 +29,25 @@ export default function AuthProvider({ children }: { children: ReactNode }) { }; window.addEventListener('auth:unauthorized', onUnauthorized); - authenticate(); + if (!authStarted.current) { authStarted.current = true; authenticate(); } return () => window.removeEventListener('auth:unauthorized', onUnauthorized); }, []); async function authenticate() { + let currentMode: 'sso' | 'password'; + try { + const response = await fetch(`${AUTH_API}/config`, { cache: 'no-store' }); + const config = await response.json(); + if (!response.ok || !['sso', 'password'].includes(config.mode)) throw new Error(); + currentMode = config.mode; + setMode(currentMode); + } catch { + setState({ isLoading: false, isAuthenticated: false, user: null, error: '无法获取验证配置,请刷新重试' }); + return; + } // 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效 - if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') { + if (currentMode === 'sso' && import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') { setState({ isLoading: false, isAuthenticated: true, @@ -59,15 +69,15 @@ export default function AuthProvider({ children }: { children: ReactNode }) { tokenRef.current = savedToken; // 验证 token 是否仍然有效(尝试请求 health) try { - const res = await fetch('/api/health', { + const res = await fetch(`${AUTH_API}/me`, { headers: { Authorization: `Bearer ${savedToken}` }, }); if (res.ok) { - const savedUser = sessionStorage.getItem('bi_user'); + const savedUser = await res.json(); setState({ isLoading: false, isAuthenticated: true, - user: savedUser ? JSON.parse(savedUser) : null, + user: savedUser, error: null, }); return; @@ -75,6 +85,12 @@ export default function AuthProvider({ children }: { children: ReactNode }) { } catch { /* token 无效,继续流程 */ } sessionStorage.removeItem('bi_jwt'); sessionStorage.removeItem('bi_user'); + tokenRef.current = null; + } + + if (currentMode === 'password') { + setState({ isLoading: false, isAuthenticated: false, user: null, error: null }); + return; } // 2. 从 URL 提取 jumpToken @@ -119,8 +135,18 @@ export default function AuthProvider({ children }: { children: ReactNode }) { } } + async function loginWithPassword(password: string) { + const response = await fetch(`${AUTH_API}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }); + const data = await response.json(); + if (!response.ok || !data.token) throw new Error(data.message || '登录失败'); + tokenRef.current = data.token; + sessionStorage.setItem('bi_jwt', data.token); + sessionStorage.setItem('bi_user', JSON.stringify(data.user)); + setState({ isLoading: false, isAuthenticated: true, user: data.user, error: null }); + } + return ( - + {children} ); diff --git a/src/auth/PasswordLogin.tsx b/src/auth/PasswordLogin.tsx new file mode 100644 index 0000000..984cda6 --- /dev/null +++ b/src/auth/PasswordLogin.tsx @@ -0,0 +1,25 @@ +import { useState } from 'react'; +import { useAuth } from './useAuth'; + +export default function PasswordLogin() { + const { loginWithPassword, error: sessionError } = useAuth(); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + return
+
{ + 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(''); } + }}> +

羚牛 · 能源 BI

+

访问看板

+

请输入管理员提供的访问密码。此入口仅提供只读访问。

+ + 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 ?

{error || sessionError}

: null} + + +
; +} diff --git a/src/auth/useAuth.ts b/src/auth/useAuth.ts index c869e3a..9c97471 100644 --- a/src/auth/useAuth.ts +++ b/src/auth/useAuth.ts @@ -1,6 +1,8 @@ import { createContext, useContext } from 'react'; export interface AuthState { + mode?: 'sso' | 'password'; + loginWithPassword?: (password: string) => Promise; isLoading: boolean; isAuthenticated: boolean; user: { diff --git a/src/components/Blur.tsx b/src/components/Blur.tsx deleted file mode 100644 index bc3d2cc..0000000 --- a/src/components/Blur.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { createContext, useContext, type ReactNode } from 'react'; - -const DemoModeContext = createContext(false); - -export function DemoModeProvider({ enabled, children }: { enabled: boolean; children: ReactNode }) { - return {children}; -} - -export function useDemoMode() { - return useContext(DemoModeContext); -} - -export default function Blur({ children }: { children: ReactNode }) { - const demo = useContext(DemoModeContext); - if (!demo) return <>{children}; - return {children}; -} diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index fde3a0c..6a76785 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -1,8 +1,7 @@ import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react'; import { motion } from 'motion/react'; -import { Building2, ChevronRight, ShieldCheck } from 'lucide-react'; +import { Building2, ChevronRight } from 'lucide-react'; import { useAuth } from '../auth/useAuth'; -import { DemoModeProvider } from './Blur'; import FeedbackFab from './FeedbackFab'; import { cn } from '../lib/cn'; import { LoadingState } from './ui/surface'; @@ -83,6 +82,9 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) { const { user } = useAuth(); const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板'; + // 氢能经营看板已在模块内提供原型一致的标题与导航,避免桌面端重复显示全局面包屑。 + const showDesktopBreadcrumb = activeModule !== 'hydrogen'; + const isHydrogenPrototype = activeModule === 'hydrogen'; const watermarkText = useMemo(() => { const name = user?.userName || '未登录'; const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-'); @@ -90,16 +92,17 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) { }, [user]); return ( -
- {/* 全局水印 */} -
-
${watermarkText}`)}")`, - backgroundRepeat: 'repeat', - }} /> -
- {/* Web 侧边栏 (md 及以上) */} + {/* 氢费看板按原型交付;其他模块继续保留全局水印。 */} + {!isHydrogenPrototype ? ( +
+
${watermarkText}`)}")`, + backgroundRepeat: 'repeat', + }} /> +
+ ) : null} + {/* Web 侧边栏 (md 及以上):能源各模块使用同一套规格,以电能为基准。 */}
}> {ActiveComponent && } - + {!isHydrogenPrototype ? : null} {/* 移动端底部导航 (md 以下) */} @@ -210,6 +218,5 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
-
); } diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index 00e1415..53b9e37 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -9,7 +9,7 @@ import { MapPin, } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; -import * as XLSX from 'xlsx'; +import { buildJsonSheet, writeWorkbook } from '../../shared/xlsx'; import { BarChart, Bar, @@ -41,7 +41,6 @@ import { } from './model'; import { SearchSelect } from '../../components/SearchSelect'; import { MultiSearchSelect } from '../../components/MultiSearchSelect'; -import Blur from '../../components/Blur'; import RotatingFooterHint from '../../components/RotatingFooterHint'; import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface'; import { AssetsHeader } from './components/AssetsHeader'; @@ -394,7 +393,7 @@ export default function AssetsModule() { 业务负责人: item.manager || '', 客户: item.customerName || '', })); - const ws = XLSX.utils.json_to_sheet(table); + const ws = buildJsonSheet(table); ws['!cols'] = [ { wch: 14 }, { wch: 8 }, @@ -405,9 +404,7 @@ export default function AssetsModule() { { wch: 14 }, { wch: 24 }, ]; - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, '明细'); - XLSX.writeFile(wb, `${title}-${flowRange.start}-${flowRange.end}.xlsx`); + writeWorkbook([{ name: '明细', sheet: ws }], `${title}-${flowRange.start}-${flowRange.end}.xlsx`); }, [flowRange.end, flowRange.start, flowStats]); const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]); @@ -706,7 +703,7 @@ export default function AssetsModule() { >
{isManagerExpanded ? : } - {m.manager} + {m.manager}
- + @@ -1531,7 +1528,7 @@ export default function AssetsModule() {
客户详情
-
{cust.customer}
+
{cust.customer}
主要车型
@@ -1541,7 +1538,7 @@ export default function AssetsModule() {
业务经理
-
{cust.manager}
+
{cust.manager}
资产占比
@@ -1575,7 +1572,7 @@ export default function AssetsModule() {
{isExpanded ? : }
- {cust.customer} + {cust.customer} {cust.region}区域
@@ -1593,7 +1590,7 @@ export default function AssetsModule() {
客户详情
-
{cust.customer}
+
{cust.customer}
主要车型
@@ -1603,7 +1600,7 @@ export default function AssetsModule() {
业务经理
-
{cust.manager}
+
{cust.manager}
资产占比
diff --git a/src/modules/assets/components/VehicleDetailModal.tsx b/src/modules/assets/components/VehicleDetailModal.tsx index fa8b609..aaacaac 100644 --- a/src/modules/assets/components/VehicleDetailModal.tsx +++ b/src/modules/assets/components/VehicleDetailModal.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { ChevronDown, Filter, Loader2, PlusCircle, Truck } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; -import Blur from '../../../components/Blur'; import { SearchSelect } from '../../../components/SearchSelect'; import type { WeeklyDetailItem } from '../api'; import type { ModalVehicleFilters, VehicleModalSelection } from '../model'; @@ -168,8 +167,8 @@ export function VehicleDetailModal({
{filteredModalWeeklyDetail.map((v, i) => ( - - + + ))} @@ -216,12 +215,12 @@ export function VehicleDetailModal({ {showPlateNumbers.source === 'customer' ? ( <> - + - - - + + + - + ) : ( <> - + {showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && ( - + )} diff --git a/src/modules/energy/ETCView.tsx b/src/modules/energy/ETCView.tsx index c218700..ad9e44b 100644 --- a/src/modules/energy/ETCView.tsx +++ b/src/modules/energy/ETCView.tsx @@ -1,79 +1,52 @@ -import { motion } from 'motion/react'; -import { Construction, Hammer } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { BadgeCheck, CarFront, FileText, ReceiptText, WalletCards } from 'lucide-react'; +import { fetchEtcOverview, type EtcOverviewResponse } from './api'; import RotatingFooterHint from '../../components/RotatingFooterHint'; +import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface'; -const ETC_HINTS = [ - 'ETC 通行费数据正在与发卡方系统打通…', - '工人 GG 正在搭脚手架,敬请期待 ~', - '马上能看到每月通行费明细啦', - '想看哪个维度的 ETC?反馈一下嘛', - '上线时机:等数据接通的那一天', -]; +function formatYuan(value: number) { + return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`; +} export default function ETCView() { + const [data, setData] = useState(null); + const [error, setError] = useState(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 ; + if (!data) return ; + + if (!data.hasData) { + return ( +
+ + +
+ ); + } + return ( -
- -
- - - - - - +
+
+ + + + +
+ +
+ +
当前统计直接读取 ETC 通行明细与账单台账;金额、应收和已收均不做推算。
- -
ETC 模块建设中
-
- 通行费明细、按车按月统计、运营成本拆分 -
- 这些数据都在路上啦 -
- - {/* 简单的里程碑进度感 */} -
- {[ - { label: '需求评审', done: true }, - { label: '数据对接', done: true }, - { label: '页面开发', done: false, current: true }, - { label: '正式上线', done: false }, - ].map((m, i) => ( - - - - {m.label} - - {m.done && 已完成} - {m.current && 进行中} - - ))} -
- - - +
+
); } diff --git a/src/modules/energy/ElectricDaily.tsx b/src/modules/energy/ElectricDaily.tsx index 080a174..945662d 100644 --- a/src/modules/energy/ElectricDaily.tsx +++ b/src/modules/energy/ElectricDaily.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; -import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Wallet } from 'lucide-react'; -import { motion, AnimatePresence } from 'motion/react'; +import { BatteryCharging, CalendarDays, ChevronRight, TrendingUp, Wallet } from 'lucide-react'; +import { AnimatePresence, motion } from 'motion/react'; import TrendBadge from './TrendBadge'; import { fetchElectricMonthly } from './api'; import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types'; @@ -28,6 +28,7 @@ export default function ElectricDaily() { useEffect(() => { let cancelled = false; setError(null); + setMonths(null); const query = pick === 'custom' ? { startDate: effectiveRange.start, endDate: effectiveRange.end } : { range: pick }; @@ -38,7 +39,11 @@ export default function ElectricDaily() { // 默认展开最新一个月 if (m.length > 0) setOpenMonths(new Set([m[0].month])); }) - .catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); + .catch(e => { + if (cancelled) return; + setMonths(null); + setError(e instanceof Error ? e.message : String(e)); + }); return () => { cancelled = true; }; }, [customer, pick, effectiveRange.start, effectiveRange.end]); @@ -59,7 +64,6 @@ export default function ElectricDaily() { } = useMemo(() => summarizeElectricMonths(months), [months]); const scopeLabel = getRangeModeLabel(pick); const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`; - const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0; const applyQuickPick = (nextPick: DateQuickPick) => { setPick(nextPick); @@ -72,6 +76,23 @@ export default function ElectricDaily() { setDateRange(prev => ({ ...prev, [field]: value })); }; + if (error) { + return ( +
+ setPick('custom')} + onDateRangeChange={updateDateRange} + onCustomerChange={setCustomer} + /> + +
+ ); + } + return (
0 ? 'rose' : 'slate'} />
- {/* 外部车辆 数据未就绪 */} - {showExternalEmpty && ( - -
- - - -
-
外部车辆 · 数据未就绪
-
- 新系统的外部车辆充电数据还在准备中 -
- 上线后此处将展示完整明细 -
-
- )} - {/* 月份分组表 */} - {!showExternalEmpty && (
月份 / 日期 @@ -185,7 +184,6 @@ export default function ElectricDaily() { ); })}
- )}
); diff --git a/src/modules/energy/ElectricModule.tsx b/src/modules/energy/ElectricModule.tsx index 4f11699..0eb121c 100644 --- a/src/modules/energy/ElectricModule.tsx +++ b/src/modules/energy/ElectricModule.tsx @@ -6,7 +6,7 @@ import { useHashSubTab } from './useHashSubTab'; import { FadeIn, PageFrame } from '../../components/ui/surface'; const SUB_TABS = [ - { id: 'daily', label: '每日', icon: CalendarDays }, + { id: 'daily', label: '按日', icon: CalendarDays }, { id: 'overview', label: '总览', icon: LayoutDashboard }, ] as const satisfies readonly { id: ElectricSubTab; label: string; icon: typeof CalendarDays }[]; @@ -16,11 +16,11 @@ export default function ElectricModule() { const [sub, setSub] = useHashSubTab('electric', SUB_IDS); return ( diff --git a/src/modules/energy/ElectricOverview.tsx b/src/modules/energy/ElectricOverview.tsx index db9609a..7fc2799 100644 --- a/src/modules/energy/ElectricOverview.tsx +++ b/src/modules/energy/ElectricOverview.tsx @@ -34,28 +34,32 @@ export default function ElectricOverview() { const trendData = data.trend; // 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份 const trendMonthLabel = trendData[0]?.date.slice(0, 7); - const currentMonth = new Date().toISOString().slice(0, 7); - const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth - ? `${trendMonthLabel} 每日充电` - : '本月每日充电'; + const now = new Date(); + const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + const dataIsCurrentMonth = trendMonthLabel === currentMonth; + const chartTitle = trendMonthLabel ? `${trendMonthLabel} 每日充电` : '每日充电'; const activeDays = trendData.filter(item => item.kwh > 0).length; const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0; const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 0; const peakDay = trendData.reduce((best, item) => (!best || item.kwh > best.kwh ? item : best), null); + const latestDay = trendData.at(-1) ?? null; + const displayedMonthKwh = dataIsCurrentMonth ? k.monthKwh : trendData.reduce((sum, item) => sum + item.kwh, 0); + const displayedMonthFee = dataIsCurrentMonth ? k.monthFee : trendData.reduce((sum, item) => sum + item.fee, 0); const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0; - const monthPrice = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 0; + const monthPrice = displayedMonthKwh > 0 ? displayedMonthFee / displayedMonthKwh : 0; + const dataAsOf = data.latestChargeTime ?? '暂无充电记录'; return (
-
- 龙王路停车场充电站,期初 2025-01-01,手工导入每日更新 +
+ 充电账本最新记录:{dataAsOf} · {dataIsCurrentMonth ? '本月数据' : `${trendMonthLabel ?? '暂无'} 数据`}
{/* 横向 mini KPI 头 */}
- - - = 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} /> + + + = 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%` : `${latestDay?.date ?? '暂无记录'} · ${fmtYuan(latestDay?.fee ?? 0)}`} tone={dataIsCurrentMonth && Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
@@ -71,8 +75,8 @@ export default function ElectricOverview() {
月度占比
-
{k.totalFee > 0 ? (k.monthFee / k.totalFee * 100).toFixed(1) : '0.0'}%
-
本月费用 / 累计费用
+
{k.totalFee > 0 ? (displayedMonthFee / k.totalFee * 100).toFixed(1) : '0.0'}%
+
{dataIsCurrentMonth ? '本月' : '最新月'}费用 / 累计费用
diff --git a/src/modules/energy/EtcModule.tsx b/src/modules/energy/EtcModule.tsx index 977e35b..cac7fa9 100644 --- a/src/modules/energy/EtcModule.tsx +++ b/src/modules/energy/EtcModule.tsx @@ -6,10 +6,10 @@ export default function EtcModule() { return ( diff --git a/src/modules/energy/HydrogenDaily.tsx b/src/modules/energy/HydrogenDaily.tsx deleted file mode 100644 index d162d10..0000000 --- a/src/modules/energy/HydrogenDaily.tsx +++ /dev/null @@ -1,278 +0,0 @@ -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('last15'); - const [dateRange, setDateRange] = useState(() => getQuickRange('last15')); - const [customer, setCustomer] = useState('lingniu'); - const [expanded, setExpanded] = useState>(new Set()); - const [rows, setRows] = useState(null); - const [error, setError] = useState(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 ( -
- setPick('custom')} - onDateRangeChange={updateDateRange} - onCustomerChange={setCustomer} - /> - -
- - - - -
- - {/* 外部车辆:新系统数据还没准备好 */} - {customer === 'external' && rows !== null && totalKg === 0 && ( - -
- - - -
-
外部车辆 · 数据未就绪
-
- 新系统的外部车辆加氢数据还在准备中 -
- 上线后此处将展示完整明细 -
-
- )} - - {/* 时段加氢量柱图(外部车辆无数据时不渲染) */} - {!(customer === 'external' && totalKg === 0) && trendData.length > 0 && ( - -
- 每日加氢量 - 时间单位:日 · 单位 Kg -
-
-
-
峰值日
-
- {peakDay ? `${peakDay.date.slice(5)} · ${peakDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'} -
-
-
-
低谷日
-
- {lowDay ? `${lowDay.date.slice(5)} · ${lowDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'} -
-
-
-
零数据日
-
0 ? 'text-amber-600' : 'text-emerald-600'}`}> - {zeroDays} 天 -
-
-
-
- - - v.slice(5)} - tick={{ fontSize: 10, fill: '#94a3b8' }} - tickLine={false} - axisLine={false} - interval="preserveStartEnd" - minTickGap={8} - /> - v >= 1000 ? `${Math.round(v / 1000)}k` : `${Math.round(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 && ( - - )} - - {trendData.map((_, i) => ( - - ))} - - - - - - - - - -
-
- )} - - {/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */} - {!(customer === 'external' && rows !== null && totalKg === 0) && ( -
- {/* 表头 */} -
- 日期 / 加氢站 - 单价 (元/Kg) - 加氢量 (Kg) - 环比 -
- {/* 合计行 */} -
- 合计 - - {totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} - -
- {/* 主行 + 子行 */} - {error ? ( -
- ) : rows === null ? ( -
- ) : rows.length === 0 ? ( -
- ) : 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 ( -
- - - {open && ( - - {r.stations.map(s => ( -
-
-
- {s.name} -
- {s.pricePerKg > 0 && ( -
- - 单价 {s.pricePerKg} 元/Kg - -
- )} -
- {s.pricePerKg > 0 ? s.pricePerKg : '—'} - - {s.kg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} - - -
- ))} -
- )} -
-
- ); - })} -
- )} - -
- ); -} diff --git a/src/modules/energy/HydrogenModule.tsx b/src/modules/energy/HydrogenModule.tsx deleted file mode 100644 index 47f6d90..0000000 --- a/src/modules/energy/HydrogenModule.tsx +++ /dev/null @@ -1,33 +0,0 @@ -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('hydrogen', SUB_IDS); - return ( - - - - - - - - - ); -} diff --git a/src/modules/energy/HydrogenOverview.tsx b/src/modules/energy/HydrogenOverview.tsx deleted file mode 100644 index add9f0f..0000000 --- a/src/modules/energy/HydrogenOverview.tsx +++ /dev/null @@ -1,106 +0,0 @@ -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(null); - const [error, setError] = useState(null); - const [year, setYear] = useState(null); - const [refreshing, setRefreshing] = useState(false); - const [lastRefreshAt, setLastRefreshAt] = useState(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
加载失败:{error}
; - } - if (!data) { - return ; - } - - 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 ( -
- void load(year, true)} - /> - - - - - - - - - - - - -
- ); -} diff --git a/src/modules/energy/HydrogenView.tsx b/src/modules/energy/HydrogenView.tsx deleted file mode 100644 index c5a24af..0000000 --- a/src/modules/energy/HydrogenView.tsx +++ /dev/null @@ -1,12 +0,0 @@ -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' ? : ; -} diff --git a/src/modules/energy/api.ts b/src/modules/energy/api.ts index 3885ff8..7a303fa 100644 --- a/src/modules/energy/api.ts +++ b/src/modules/energy/api.ts @@ -1,59 +1,70 @@ import { fetchJson } from '../../auth/api-client'; import type { - HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow, - HydrogenCustomerRow, HydrogenStationFull, ElectricKpi, ElectricDailyRow, ElectricMonthGroup, CustomerType, DateQuickPick, + HydrogenStationBoardResponse, } from './types'; const BASE = '/api/energy'; -export interface HydrogenOverviewResponse { - kpi: HydrogenKpi; - top5: HydrogenStationTop[]; - regions: HydrogenRegionShare[]; - monthly: HydrogenMonthlyPoint[]; - customers: HydrogenCustomerRow[]; - stations: HydrogenStationFull[]; - availableYears: number[]; - year: number; +/** + * 氢能单站日报数据源。 + * 注意:该接口路径仍带 `/hydrogen` 前缀(历史命名),但为当前单站日报页面在用, + * 与已下线的 `/hydrogen/overview|daily|settlement` 等旧接口无关。 + */ +export interface HydrogenStationBoardQuery { + startDate: string; + endDate: string; + stationId?: number | null; + force?: boolean; } -export function fetchHydrogenOverview(year?: number, force = false): Promise { - const params = new URLSearchParams(); - if (year) params.set('year', String(year)); - if (force) params.set('force', '1'); - const q = params.toString(); - return fetchJson(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`); +export function fetchHydrogenStationBoard(query: HydrogenStationBoardQuery): Promise { + const params = new URLSearchParams({ startDate: query.startDate, endDate: query.endDate }); + if (query.stationId) params.set('stationId', String(query.stationId)); + if (query.force) params.set('force', '1'); + return fetchJson(`${BASE}/hydrogen/station-board?${params.toString()}`); } -export interface HydrogenDailyQuery { +/** 电能与 ETC 共用的自然日区间查询参数。 */ +export interface EnergyRangeQuery { range?: DateQuickPick; startDate?: string; endDate?: string; } -export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise { - 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(`${BASE}/hydrogen/daily?${q.toString()}`); -} - export interface ElectricOverviewResponse { kpi: ElectricKpi; trend: ElectricDailyRow[]; + latestChargeTime: string | null; } export function fetchElectricOverview(): Promise { return fetchJson(`${BASE}/electric/overview`); } -export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise { - 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(`${BASE}/electric/monthly?${q.toString()}`); +export function fetchElectricMonthly( + customer: CustomerType, + query: EnergyRangeQuery = { range: 'last15' }, +): Promise { + const params = new URLSearchParams({ customer }); + if (query.range) params.set('range', query.range); + if (query.startDate) params.set('startDate', query.startDate); + if (query.endDate) params.set('endDate', query.endDate); + return fetchJson(`${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 { + return fetchJson(`${BASE}/etc/overview${force ? '?force=1' : ''}`); } diff --git a/src/modules/energy/daily-range/DailyRangeControls.tsx b/src/modules/energy/daily-range/DailyRangeControls.tsx index 3df5f19..73392ab 100644 --- a/src/modules/energy/daily-range/DailyRangeControls.tsx +++ b/src/modules/energy/daily-range/DailyRangeControls.tsx @@ -1,5 +1,4 @@ -import { Truck } from 'lucide-react'; -import { SurfaceCard } from '../../../components/ui/surface'; +import { Calendar, Fuel, RefreshCw, Truck } from 'lucide-react'; import type { CustomerType, DateQuickPick } from '../types'; import { QUICK_PICK_OPTIONS, type RangeMode } from './model'; @@ -7,86 +6,138 @@ interface DailyRangeControlsProps { pick: RangeMode; dateRange: { start: string; end: string }; customer: CustomerType; + stations?: { id: number; name: string }[]; + selectedStationId?: number | null; + updatedAt?: string | null; + loading?: boolean; onQuickPick: (pick: DateQuickPick) => void; onCustomPick: () => void; onDateRangeChange: (field: 'start' | 'end', value: string) => void; onCustomerChange: (customer: CustomerType) => void; + onStationChange?: (stationId: number | null) => void; + onRefresh?: () => void; } export default function DailyRangeControls({ pick, dateRange, customer, + stations = [], + selectedStationId = null, + updatedAt, + loading = false, onQuickPick, onCustomPick, onDateRangeChange, onCustomerChange, + onStationChange, + onRefresh, }: DailyRangeControlsProps) { return ( - -
- {QUICK_PICK_OPTIONS.map(opt => ( - - ))} - -
+
+
+
+
+ {QUICK_PICK_OPTIONS.map(option => ( + + ))} + +
-
- - -
+ onDateRangeChange('start', value)} /> + onDateRangeChange('end', value)} /> -
- {(['lingniu', 'external'] as const).map(option => ( - - ))} + {onStationChange ? ( + + ) : null} +
+ +
+
+ {(['lingniu', 'external'] as const).map(option => ( + + ))} +
+ +
+ {updatedAt ? {updatedAt} : null} + {onRefresh ? ( + + ) : null} +
+
- +
+ ); +} + +function DateField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { + return ( + ); } diff --git a/src/modules/energy/hydrogen-daily/model.test.ts b/src/modules/energy/hydrogen-daily/model.test.ts deleted file mode 100644 index 651ed1d..0000000 --- a/src/modules/energy/hydrogen-daily/model.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -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, - }); -}); diff --git a/src/modules/energy/hydrogen-daily/model.ts b/src/modules/energy/hydrogen-daily/model.ts deleted file mode 100644 index e2403e8..0000000 --- a/src/modules/energy/hydrogen-daily/model.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { HydrogenDailyRow } from '../types'; - -export { - formatYmd, - getQuickRange, - getRangeModeLabel, - normalizeRange, - QUICK_PICK_OPTIONS, - type RangeMode, -} from '../daily-range/model'; - -export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) { - const source = rows ?? []; - // 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。 - const trendData = [...source].sort((left, right) => left.date.localeCompare(right.date)); - const totalKg = source.reduce((total, row) => total + row.totalKg, 0); - const activeDays = source.filter(row => row.totalKg > 0).length; - const stationNames = new Set(); - source.forEach(row => row.stations.forEach(station => stationNames.add(station.name))); - const peakDay = trendData.reduce( - (best, item) => (!best || item.totalKg > best.totalKg ? item : best), - null, - ); - const lowDay = trendData - .filter(item => item.totalKg > 0) - .reduce( - (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, - }; -} diff --git a/src/modules/energy/hydrogen-overview/components/DistributionCharts.tsx b/src/modules/energy/hydrogen-overview/components/DistributionCharts.tsx deleted file mode 100644 index 93d145d..0000000 --- a/src/modules/energy/hydrogen-overview/components/DistributionCharts.tsx +++ /dev/null @@ -1,133 +0,0 @@ -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 ( - - - - {index + 1} - - - {payload?.value} - - - ); -} - -interface DistributionChartsProps { - top5: HydrogenStationTop[]; - regions: HydrogenRegionShare[]; - yearKg: number; -} - -export function DistributionCharts({ top5, regions, yearKg }: DistributionChartsProps) { - return ( -
-
-
- 加氢站加注量 Top5 - 单位 Kg -
- - - - } - tickLine={false} - axisLine={false} - /> - `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`} - contentStyle={{ borderRadius: 12, fontSize: 12 }} - /> - - {top5.map((_, i) => ( - - ))} - `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`} - fill="#475569" - fontSize={11} - fontWeight={700} - /> - - - - - - - - - -
- -
- 各区域加氢占比 -
-
- - - - {regions.map((_, i) => ( - - ))} - - `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} /> - - -
-
年合计
-
{(yearKg / 1000).toFixed(2)}T
-
-
-
- {regions.map((r, i) => ( -
- - {r.region} - {(r.share * 100).toFixed(1)}% -
- ))} -
-
-
-
- ); -} diff --git a/src/modules/energy/hydrogen-overview/components/HydrogenOverviewSkeleton.tsx b/src/modules/energy/hydrogen-overview/components/HydrogenOverviewSkeleton.tsx deleted file mode 100644 index 1195e14..0000000 --- a/src/modules/energy/hydrogen-overview/components/HydrogenOverviewSkeleton.tsx +++ /dev/null @@ -1,80 +0,0 @@ -export function HydrogenOverviewSkeleton() { - return ( -
-
-
-
- - {/* 5 卡占位 */} -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
-
-
-
-
-
-
-
-
-
- ))} -
- - {/* 月度柱图占位 */} -
-
-
-
-
-
- {[60, 75, 50, 80, 35, 90, 45].map((h, i) => ( -
- ))} -
-
- -
-
-
-
-
-
-
- {[100, 78, 56, 40, 28].map((w, i) => ( -
-
-
-
-
-
- ))} -
-
-
-
-
-
-
-
-
- {Array.from({ length: 5 }).map((_, i) => ( -
-
-
-
-
- ))} -
-
-
-
- -
- - 正在加载氢能总览… -
-
- ); -} diff --git a/src/modules/energy/hydrogen-overview/components/InsightCards.tsx b/src/modules/energy/hydrogen-overview/components/InsightCards.tsx deleted file mode 100644 index 3e3d899..0000000 --- a/src/modules/energy/hydrogen-overview/components/InsightCards.tsx +++ /dev/null @@ -1,88 +0,0 @@ -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 ( -
-
-
-
-
月度动能
-
- {monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`} -
-
- - - -
-
- {latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'} - {bestMonth ? ` · 峰值 ${bestMonth.month}` : ''} - {monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''} -
-
-
-
-
-
站点集中度
-
Top5 {top5Share.toFixed(1)}%
-
- - - -
-
- 共 {stationCount} 站 · 单站年均 {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit} - {top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'} -
-
-
-
-
-
收支健康度
-
= 0 ? 'text-emerald-600' : 'text-rose-600'}`}> - {profitYield.toFixed(1)}% -
-
- = 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}> - - -
-
- 时享获利 {yearProfitValue}{yearProfitUnit} · 客户收入 {yearRevenueValue}{yearRevenueUnit} - {profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'} -
-
-
- ); -} diff --git a/src/modules/energy/hydrogen-overview/components/KpiSection.tsx b/src/modules/energy/hydrogen-overview/components/KpiSection.tsx deleted file mode 100644 index 9fb2ada..0000000 --- a/src/modules/energy/hydrogen-overview/components/KpiSection.tsx +++ /dev/null @@ -1,114 +0,0 @@ -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 ( -
-
-
- {icon} -
- {label} -
-
- {hero.value} - {hero.unit} -
-
- {rows.map((r, i) => ( -
- {r.label} - {r.value} -
- ))} -
-
- ); -} - -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 ( -
- } - iconBg="bg-cyan-50" - accentClass="text-slate-800" - label="累计加氢量" - hero={yearKgFmt} - rows={[ - { label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` }, - { label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` }, - ]} - /> - } - 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}` }, - ]} - /> - } - 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}` }, - ]} - /> - } - 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'}%` }, - ]} - /> - } - 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'}%` }, - ]} - /> -
- ); -} diff --git a/src/modules/energy/hydrogen-overview/components/MonthlyCharts.tsx b/src/modules/energy/hydrogen-overview/components/MonthlyCharts.tsx deleted file mode 100644 index 0f319ff..0000000 --- a/src/modules/energy/hydrogen-overview/components/MonthlyCharts.tsx +++ /dev/null @@ -1,101 +0,0 @@ -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 && ( -
-
- {activeYear} 年月度加氢量 - 单位 Kg -
- - - - - [`${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)' }} - /> - - {monthlyDual.map((_, i) => ( - - ))} - - - - - - - - - -
- )} - - {monthly.length > 0 && ( -
-
- {activeYear} 年月度收支对比 - 单位 元 -
- - - - - - { - 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)' }} - /> - - - - -
- )} - - ); -} diff --git a/src/modules/energy/hydrogen-overview/components/OverviewHeader.tsx b/src/modules/energy/hydrogen-overview/components/OverviewHeader.tsx deleted file mode 100644 index e890e91..0000000 --- a/src/modules/energy/hydrogen-overview/components/OverviewHeader.tsx +++ /dev/null @@ -1,53 +0,0 @@ -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 ( -
- {lastRefreshAt ? `更新于 ${formatRefreshTime(lastRefreshAt)}` : '数据自 2025-01-01 起'} -
-
- {availableYears.map(y => { - const active = y === activeYear; - return ( - - ); - })} -
- -
-
- ); -} diff --git a/src/modules/energy/hydrogen-overview/components/RefreshOverlay.tsx b/src/modules/energy/hydrogen-overview/components/RefreshOverlay.tsx deleted file mode 100644 index 4adcc93..0000000 --- a/src/modules/energy/hydrogen-overview/components/RefreshOverlay.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { AnimatePresence, motion } from 'motion/react'; - -interface RefreshOverlayProps { - refreshing: boolean; - hasData: boolean; -} - -export function RefreshOverlay({ refreshing, hasData }: RefreshOverlayProps) { - return ( - - {refreshing && hasData && ( - - - - )} - - ); -} diff --git a/src/modules/energy/hydrogen-overview/components/SummaryTables.tsx b/src/modules/energy/hydrogen-overview/components/SummaryTables.tsx deleted file mode 100644 index da78384..0000000 --- a/src/modules/energy/hydrogen-overview/components/SummaryTables.tsx +++ /dev/null @@ -1,123 +0,0 @@ -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 && ( -
-
- 加氢站加氢汇总 - 共 {stations.length} 站 -
-
-
{isManagerExpanded ? : } - {m.manager} + {m.manager} {m.department}
{isManagerExpanded ? : } - {m.manager} + {m.manager}
{isExpanded ? : } - {cust.customer} + {cust.customer} {cust.region} {cust.manager}{cust.manager} { 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} { 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} { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '18T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 18T` }); }}>{cust.t18}
{v.plate_number}{v.customer_name || '—'}{v.plate_number}{v.customer_name || '—'} {v.handover_date ? v.handover_date.slice(0, 10) : '—'}
{v.departmentName || '—'}{v.customerManager || '—'}{v.customerManager || '—'} {v.brandLabel || '—'} {v.type}{v.subjectOrg || '—'}{v.customerName || '—'}{v.plateNumber || v.vin || '—'}{v.subjectOrg || '—'}{v.customerName || '—'}{v.plateNumber || v.vin || '—'} {'—'} {v.location === '其他' ? '对接中' : v.location} {'—'}{v.orgName || '—'}{v.orgName || '—'}{v.plateNumber || v.vin || '—'}{v.plateNumber || v.vin || '—'}{v.customerName || '—'}{v.customerName || '—'}{v.brandLabel || '—'} {v.type}
- - - - - - - - - - - - {stations.map((s, i) => { - const kgFmt = fmtKg(s.kg); - const revFmt = fmtYuan(s.revenue); - return ( - - - - - - - - - ); - })} - -
#加氢站加氢量占比氢费收入收入占比
{i + 1}{s.name} - {kgFmt.value}{kgFmt.unit} - -
-
-
-
- {(s.share * 100).toFixed(1)}% -
-
- ¥{revFmt.value}{revFmt.unit} - -
-
-
-
- {(s.revenueShare * 100).toFixed(1)}% -
-
- - - )} - - ); -} - -export function CustomerSummaryTable({ customers }: { customers: HydrogenCustomerRow[] }) { - return ( - <> - {customers.length > 0 && ( -
-
- 客户账单汇总 - Top {customers.length} -
-
- - - - - - - - - - - - - {customers.map((c2, i) => { - const kgFmt = fmtKg(c2.kg); - const costFmt = fmtYuan(c2.cost); - const revFmt = fmtYuan(c2.revenue); - return ( - - - - - - - - - ); - })} - -
#客户承担方加氢量成本支出应收
{i + 1}{c2.name} - {c2.payer === 'lingniu' ? ( - 羚牛 - ) : ( - 客户 - )} - - {kgFmt.value}{kgFmt.unit} - - ¥{costFmt.value}{costFmt.unit} - - ¥{revFmt.value}{revFmt.unit} -
-
-
- )} - - ); -} diff --git a/src/modules/energy/hydrogen-overview/model.test.ts b/src/modules/energy/hydrogen-overview/model.test.ts deleted file mode 100644 index d91fa2a..0000000 --- a/src/modules/energy/hydrogen-overview/model.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -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月']); -}); diff --git a/src/modules/energy/hydrogen-overview/model.ts b/src/modules/energy/hydrogen-overview/model.ts deleted file mode 100644 index 4b5de8f..0000000 --- a/src/modules/energy/hydrogen-overview/model.ts +++ /dev/null @@ -1,75 +0,0 @@ -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( - (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/, '')}月`, - })), - }; -} diff --git a/src/modules/energy/hydrogen/api.ts b/src/modules/energy/hydrogen/api.ts new file mode 100644 index 0000000..8328a33 --- /dev/null +++ b/src/modules/energy/hydrogen/api.ts @@ -0,0 +1,182 @@ +import { fetchJson } from '../../../auth/api-client'; +import type { + H2BiDailyResponse, + H2BiDailyTreeQuery, + H2BiDailyTreeResponse, + H2BiDrillReadOptions, + H2BiFullDrillOptions, + H2BiFullDrillResponse, + H2BiDrillQuery, + H2BiDrillResponse, + H2BiMetaResponse, + H2BiOverviewResponse, + H2BiQuery, +} from './types'; + +const BASE = '/api/energy/h2/v2'; + +function queryString(query: Record) { + 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(path: string, query: object = {}, options?: RequestInit) { + const qs = queryString(query as Record); + return fetchJson(`${BASE}/${path}${qs ? `?${qs}` : ''}`, options); +} + +export function fetchH2BiMeta() { + return request('meta'); +} + +export function fetchH2BiOverview(query: H2BiQuery) { + return request('overview', query); +} + +export function fetchH2BiDaily(query: H2BiQuery) { + return request('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('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('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( + '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, + options: H2BiFullDrillOptions = {}, +): Promise { + 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, + options?: H2BiFullDrillOptions, +) { + return fetchAllH2BiDrill({ ...query, groupBy: 'record' }, options); +} diff --git a/src/modules/energy/hydrogen/board/.spec/2026-08-29-mobile-overview-layout.md b/src/modules/energy/hydrogen/board/.spec/2026-08-29-mobile-overview-layout.md new file mode 100644 index 0000000..3b20f2c --- /dev/null +++ b/src/modules/energy/hydrogen/board/.spec/2026-08-29-mobile-overview-layout.md @@ -0,0 +1,41 @@ +# 移动端经营总览布局决策 + +## 问题 + +- 顶部筛选区域占用首屏过多。 +- 累计加氢量与累计加氢费使用两个大卡片并排,数字和承担结构拥挤。 +- 利润卡片与本月、本日卡片高度不一致,形成大面积无效留白。 +- 移动端需要先回答经营结果,再提供费用结构与近期指标。 + +## 用户选择 + +- 仅优化移动端布局,保留现有数据、筛选和下钻交互。 +- 使用克制、专业、低饱和的视觉方向。 +- 以管理层快速查看经营结果、费用结构和近期表现为核心任务。 + +## 最终设计决策 + +1. 新增单个移动端「经营总览」容器,集中展示累计加氢量、累计加氢费及我司承担、客户承担、待核准三行对照数据。 +2. 桌面端继续使用原有 KPI 栅格,移动端隐藏原累计量费双卡,避免重复信息。 +3. 加氢利润改为全宽紧凑卡,本月加氢量与今日加氢量并排呈现。 +4. 压缩移动端页头、筛选容器和范围提示的垂直空间,不改变筛选状态与即时生效逻辑。 +5. 保持 44px 最小触控热区、等宽数字及 375px/390px 视口无横向溢出。 + +## 参考稿细化确认 + +用户追加确认以参考截图优化移动端首屏: + +1. 顶部只保留年份、视图、车辆范围和筛选四项快捷入口,订单范围收进展开筛选。 +2. 累计经营概览增加我司、客户、待核准三段加氢量构成条,并展示吨数与占比。 +3. 增加「查看构成」入口,继续复用累计加氢量明细。 +4. 利润卡改为左侧利润、右侧收入与成本的横向结构。 +5. 本月与今日指标使用等宽双卡,经营诊断延后至趋势内容之后。 + +## 单站页与累计明细补充确认 + +1. 单站页把站点数、统计加氢总量、车次、统计金额和现结金额收进一张经营概览,不再把桌面端四卡压成手机两列。 +2. 日期范围与更新时间保留在同一块紧凑查询区;各站概况改为纵向卡片,竖屏不展示无必要的横屏入口。 +3. 累计明细顶部改为双主指标:数据归集总量、数据总金额;覆盖站点数和来源完整度降为一行辅助信息。 +4. 累计明细筛选默认收起为范围摘要,点击后展开完整筛选;竖屏只保留左上返回,不再重复提供关闭和横屏入口。 +5. 层级数据优先保证站点、客户、车辆与订单摘要在首列可读;详细字段继续在表格内部横向查看,不允许撑宽整页。 +6. 移动端竖屏明细页头保留左侧返回;站点与客户宽表明细同时保留右侧横屏图标,但不显示重复关闭按钮。业务标题按内容增高并换行,任何入口不得覆盖站点名、客户名或统计时间。 diff --git a/src/modules/energy/hydrogen/board/.spec/requirements-prd.md b/src/modules/energy/hydrogen/board/.spec/requirements-prd.md new file mode 100644 index 0000000..d709845 --- /dev/null +++ b/src/modules/energy/hydrogen/board/.spec/requirements-prd.md @@ -0,0 +1,151 @@ +# 能源氢费经营看板 · 产品需求说明(PRD) + +> 原型路径:`src/prototypes/energy-h2-bi-board` +> 宿主嵌入:`https://bi-next.lnh2e.com/energy#hydrogen/overview` +> 宿主视觉单源:`src/resources/prd/energy-bi-host-ref/` +> 方案底稿:`src/resources/prd/energy-board-plan-20260806.md` +> 口令:`lingniu`(轻门禁 · 本会话记住) +> 拍板:嵌入总览 · 我司成本三维度 · 站月/客户汇总表 · **禁 OneOS V2** · D1 无自动解读 +> **2026-08-12**:顶栏白卡右上角 **全局 / 单站**;单站嵌加氢站日报(起止查询日期自管);单站模式**不显示**看板标题旁时间芯片;现结登记仍独立 OneOS 模块 +> **2026-08-13**:全局视图**去掉**「加氢站日报 / 站日现结登记」关联入口卡;站日报仅经顶栏「单站」;现结登记走独立模块,本页不再挂跳转摘要 + +> 二期:双视角全路径 · 充耗存侧卡 · **预充值能源账户扣款(体系 B)** +> 作者:OneOS + +--- + +## 0. 嵌入与设计约定 + +| 项 | 口径 | +|---|---| +| 口令 | `lingniu`(轻门禁;本会话 `sessionStorage` 记住) | +| 宿主页 | `#hydrogen/overview` | +| 功能 | 独立嵌入块「我司成本」:三维度 + 两张汇总表 + 订单明细;**单站**嵌加氢站日报 | +| 设计 | 只跟 bi-next 能源 BI;禁止 `V2*`;字体对齐 V2 §2.2 | + +### 视图决策 + +| 决策 | 结论 | 理由 | +|---|---|---| +| 顶栏范围 | **全局 / 单站**(白卡右上角) | 单站回嵌经营看板 | +| 全局内视图 | `按日` / `总览` | 原看板能力保留 | +| 总览筛选 | 默认**全部车辆**;可切**仅羚牛车辆** / **仅外部车辆**;与年份、全量订单/仅已核对联动 | 外层筛选与 KPI / 图表 / 钻取同一口径 | +| 承担方式 | **我司承担 / 客户承担 / 待核准**三类;「自行」并入「客户承担」 | 首页拆分、穿透筛选、明细列和导出口径一致 | +| 核心 KPI | PC 展示全部 5 项;移动端前 2 项突出,其余 3 项紧凑保留且均可点击穿透 | 不隐藏旧版指标能力 | +| 单站形态 | 加氢站日报:起止查询日期 + KPI 钻取 + 各站单卡(行内加氢量占比 · 按量降序) | 一眼可查 | +| 单站时间 | **不展示**看板标题旁「统计时间范围」芯片 | 与全局统计维度不同 | +| 单站明细 | 日汇总、区间趋势、客户月量/费、收支、现结;导出取证 `.xlsx` | 查看与取证 | +| 现结登记 | **独立** `energy-spot-cash-intake`;本页不办理 | 1C 手维入口唯一 | +| 预充值能源账户 | **二期 · 体系 B** | 与现结完全独立 | + +### 关联独立模块 + +| 模块 | 路径 | 本看板角色 | +|---|---|---| +| 加氢站日报 | `energy-h2-station-daily` | 顶栏「单站」嵌入;可独立外链;**不**再挂全局关联卡 | +| 站日现结登记 | `energy-spot-cash-intake` | 独立模块办理;本页**不**挂跳转入口与近窗摘要 | +| 加氢客户(外部) | `energy-h2-external-customer` | 外部客户主数据;与租赁客户隔离 | +| 外部车辆明细 | `energy-h2-external-vehicle` | 外部车牌主数据 + 导入;API 分流「仅外部」数据源 | + +> **2026-08-13 分流口径:** 南海站 API 流水命中外部车辆台账 → `fleetCategory=external`,**只进本看板/站日报**,不进车辆氢费明细;命中自有/租赁车队 → 进氢费明细并可核对。主数据种子车牌与 `mockDaily` 外部牌对齐(见 `src/common/energy-h2-external-fleet`)。 + +--- + +## 0.1 现结 ≠ 预充值(双体系硬隔离) + +| | **体系 A · 现结** | **体系 B · 预充值(以后)** | +|---|---|---| +| 一句话 | 到站加完氢 → 当场在线支付 | 预充进账户 → 加氢扣款 | +| 本期 | 独立模块手维;看板/站日报只读 | **不做** | +| 禁止 | 「账户充值」指现结;订单金额冒充现结 | 用现结台账冒充账户流水 | + +--- + +## 1. 模块定位 + +| 项 | 说明 | +|---|---| +| 名称 | 能源氢费经营看板(嵌入:我司成本) | +| 核心任务 | 全局:三维度结构 → 站月/客户汇总 → 订单解释;单站:站日经营读数与取证 | +| 不做 | 现结登记表单;体系 B | + +--- + +## 2. 我司成本三维度(仅全局) + +| 维度 | 二级 | +|---|---| +| 租赁成本 | 我司承担 · 包氢项目 | +| 物流成本 | — | +| 运维成本 | 异动 · 调拨 | + +客户承担不进三维度。核对 ≠ 对账。 + +--- + +## 3. 用户故事 + +### 经营看板 · 全局 + +- **起点:** 打开看板(口令后)→ 顶栏「全局」→ 按日 / 总览。 +- **怎么运作:** 看我司成本与汇总;站日报经顶栏「单站」进入。 +- **闭环:** 本页解释成本结构。 + +### 经营看板 · 单站 + +- **起点:** 顶栏切「单站」→ 驾驶舱各站卡片。 +- **怎么运作:** 点站点进入汇报口径明细(日汇总/趋势/客户月量费/收支/车辆/现结);可导出取证 Excel。 +- **闭环:** 数字可查、明细可追到车与现结笔;登记仍去现结模块。 + +--- + +## 4. KPI 穿透表 · 类型标签口径 + +穿透钻取表(加氢站 → 客户 → 车辆): + +表头支持「**按加氢站**」与「**按客户**」两种组织方式;切换只改变层级顺序,均可下钻到车辆和单笔订单。 + +| 层级 | 类型 / 归属列 | +|---|---| +| 加氢站 | **不展示**自用消费 / 对外销售(站侧不再分自用与对外) | +| 客户 | **不展示**内部客户 / 外部客户标签 | +| 车辆 | 无法识别车牌 → 名称统一「无车牌」(与有牌车辆同级),标签「外部车辆」;能识别且为羚牛车队 → 标签「羚牛车辆」;能识别的外部车 → 标签「外部车辆」 | +| 车辆下订单行 | 展示标签「订单编号」+ 单号;字段口径为**加氢订单编号**(导出列亦用「订单编号」) | +| 加氢站核对状态 | 按下属羚牛车辆汇总:**已核对**(全部已核)/ **部分核对**(有已核也有未核,或任一带部分)/ **未核对**(一条未核);无参与核对车辆时显示 `-` | +| 穿透筛 | **加氢站 / 客户 / 车辆**可搜索选择器(级联)+ **车辆归属**单选按钮组(全部 / 仅羚牛 / 仅外部)+ **承担方式**(全部 / 我司承担 / 客户承担 / 待核准);禁 V2 | +| 加氢利润穿透 | 关键列展示**收入 / 成本 / 利润**;顶栏汇总亦为收入·成本·利润 | +| 本月加氢穿透 | 关键列展示**本月加氢量 / 本月加氢费 / 加氢费占年比** | +| 本日加氢穿透 | 关键列展示**本日加氢量 / 本日加氢费 / 加氢费占月比** | +| 月度柱钻取 | 点月度加氢量柱 → 该月**各站**内部客户加氢总量 / 外部客户加氢总量 / 合计 | +| 月度收支柱钻取 | 点**客户收入**柱 → 该月各站客户收入;点**成本支出**柱 → 该月各站成本支出 | +| Top5 站横条 | 点条 → 该站**内部/外部客户加氢总量**(表可竖滚) | +| 区域环图/图例 | 点市或省 → 该区域**各站加氢总量与占比**(表可竖滚) | +| 站汇总表钻取 | 默认按日展示:**日期 / 车辆数与加氢笔数 / 加氢量 / 较前日增减 / 氢费收入 / 平均单价**;展开后展示客户、车牌、车辆归属、加氢时间和单笔明细 | +| 客户汇总表钻取 | 关键字段:**承担方 / 加氢量 / 成本支出 / 应收 / 已收 / 未收**(按日展开) | +| 头部加氢站占比 | 点击展开**加氢量排名**下拉(高→低、可滚动);点站可进穿透 | +| 按日经营指标 | 默认展示**区间加氢量 / 日均加氢量 / 较上一周期 / 活跃加氢站**;活跃站点按“当前有记录站点 / 全网络站点”展示并给出覆盖率;车辆构成降级为筛选区间说明,不占主指标卡 | +| 全局时间范围 | 每个页面、列表、全屏明细与钻取弹窗必须展示明确的开始与结束时间;**经营汇总到天、当日实时到分钟、订单与加氢流水到秒**;“本日 / 本月 / 年度”等业务名称不可替代具体时间范围 | +| 穿透标签提示 | 车辆归属 / 数据来源 / 核对状态标签均有悬浮说明 | + +--- + +## 5. 验收 + +1. 顶栏白卡右上角有 **全局 / 单站**;全局下仍有按日/总览。 +2. 单站:站点卡片 → 明细含汇报 Excel 对应区块;导出 `.xlsx`。 +3. 空态不填 0;现结 ≠ 预充值。 +4. 口令 `lingniu`;标注壳在门外。 +5. 禁 OneOS V2 控件。 +6. KPI 穿透:站/客户无自用·内外标签;无车牌归「无车牌」+「外部车辆」。 +7. 总览顶栏:默认全部车辆;切仅羚牛/仅外部/年份/核对后,KPI、图表、钻取数同步变化。 +8. 承担方式仅有我司承担、客户承担、待核准三类;不再单列「自行」,且首页、筛选、明细、导出一致。 +9. KPI 穿透可按加氢站或客户组织,两种方式都可追到车辆和单笔订单。 +10. PC 可见 5 项核心 KPI;移动端 5 项均可见、可点击,前 2 项保持主视觉层级。 + +--- + +## 6. 明确不做(本期) + +- 体系 B 预充值账户 +- 现结在本页办理 +- 云效建单(默认不上) diff --git a/src/modules/energy/hydrogen/board/DEVELOPER-HANDOFF.md b/src/modules/energy/hydrogen/board/DEVELOPER-HANDOFF.md new file mode 100644 index 0000000..8dead67 --- /dev/null +++ b/src/modules/energy/hydrogen/board/DEVELOPER-HANDOFF.md @@ -0,0 +1,30 @@ +# 氢能经营看板开发交付说明 + +## 页面入口 + +- 本地地址:`http://127.0.0.1:51720/prototypes/energy-h2-bi-board` +- 访问口令:`lingniu` +- 启动方式:在项目目录执行 `npm ci`,再执行 `npm run dev` + +## 本次交付范围 + +- PC 与移动端全局经营总览、日期视图、单站视角。 +- 年份、日期区间、车辆归属、订单范围、站点、客户等筛选交互。 +- KPI 下钻、表格逐级展开、Tab 切换、更多数据、横屏查看与退出。 +- 单站经营明细(日加氢、客户月度、收支、进账)与日期查询。 +- Excel 导出入口及前端下载逻辑。 + +## 数据与接口边界 + +- 当前为可运行原型,经营数据来自 `data/mockBoard.ts`、`data/mockDaily.ts` 和单站模块的 `data/mockStationDaily.ts`。 +- 正式开发需将模拟数据替换为接口返回值,但应保留现有筛选、空状态、下钻层级、双端响应式布局与横屏交互。 +- 查询条件为即时生效;日期弹层点击“确定”后更新页面统计范围。 + +## 验证命令 + +```bash +npx vitest run tests/energy-h2-bi-board.ui.test.ts tests/energy-h2-bearer-model.test.ts tests/energy-h2-dual-end-interaction.test.ts tests/energy-h2-mobile-kpi-tone.test.ts tests/energy-h2-mobile-layout-polish.test.ts tests/energy-bi-board.visual.test.ts tests/common/EnergyBiBoardMobileLayout.test.ts tests/common/MobileListFullscreenButton.test.tsx +ENTRY_KEY=prototypes/energy-h2-bi-board npx vite build +``` + +交付前结果:8 个测试文件、97 项检查全部通过,目标页面生产构建通过。 diff --git a/src/modules/energy/hydrogen/board/EnergyBiBoardApp.tsx b/src/modules/energy/hydrogen/board/EnergyBiBoardApp.tsx new file mode 100644 index 0000000..e1fb6a7 --- /dev/null +++ b/src/modules/energy/hydrogen/board/EnergyBiBoardApp.tsx @@ -0,0 +1,3765 @@ +// @ts-nocheck — verbatim 8113 prototype source; runtime DOM/CSS is intentionally preserved. +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + Activity, + AlertTriangle, + Calendar, + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronsUpDown, + Download, + Fuel, + Maximize2, + ReceiptText, + RefreshCw, + Search, + TrendingUp, + Truck, + Wallet, + X, + Zap, +} from 'lucide-react'; +import { exportAoaSheet } from '../../../../shared/xlsx'; +import { HYDROGEN_VERIFY_START_DATE } from '../../../../shared/hydrogen-verify'; +import { MobileListFullscreenButton } from '../common/MobileListFullscreenButton'; +import { + SOURCE_LABEL, + companyRowsForStats, + computeHostKpi, + costDimCards, + costDimLabel, + customerAttrAgg, + filterOrders, + formatKg, + formatYuan, + stationMonthAgg, + type DimFilter, + unverified, +} from './data/aggregates'; +import { DEFAULT_YEAR, HOST_KPI, MOCK_ORDERS } from './data/mockBoard'; +import { + DAILY_VERIFY_LABEL, + MOCK_DAILY_15DAYS, + SOURCE_TYPE_LABEL, + STATION_TYPE_LABEL, + calculateDailyKpis, + filterDailyDataByFleet, + getDailyDataForRange, + type FleetCategory, + type FleetCategoryFilter, +} from './data/mockDaily'; +import { + BORNE_BY_LABEL, + BORNE_BY_ORDER, + type BorneBy, + type FleetScope, + type HostView, + type H2OrderRow, +} from './types'; +import { StationDailyApp } from '../station-daily/StationDailyApp'; +import '../station-daily/styles.css'; +import './styles/energy-bi-board.css'; +import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiMeta, fetchH2BiOverview } from '../api'; +import { PrototypeRealDailyView } from '../drill/prototype-real-daily'; +import { PrototypeDrillModal, prototypeFleetScope } from '../drill/prototype-real-drills'; + +type BoardScope = 'global' | 'station'; +type StatsTab = 'siteMonth' | 'customer'; +type DailyRangePreset = 'week' | 'month' | '15days' | 'custom'; +type DrillTreeAxis = 'station' | 'customer'; + +const CHART_BLUE = '#2f6bff'; +const CHART_EXTERNAL = '#8fb4ff'; +const CHART_INCOME = '#2f9fb3'; +const CHART_COST = '#7c83e6'; + +// Keep the compact desktop treatment while ensuring keyboard/touch users get a reliable target. +const ACCESSIBLE_CONTROL_STYLE = { minHeight: 44 }; + +const DRILL_CUSTOMER_BORNE: Record = { + 'c-zp': 'customer', + 'c-ys': 'customer', + 'c-zq': 'customer', + 'c-ln': 'company', + 'c-qb': 'customer', + 'c-yj': 'customer', + 'c-js': 'pending', + 'c-gz': 'customer', +}; + +function renderBorneTag(borneBy: BorneBy | null | undefined) { + if (!borneBy) return -; + const compactLabel: Record = { + company: '羚牛', + customer: '客户', + pending: '待核', + }; + return ( + + {compactLabel[borneBy]} + + ); +} + +function summaryBorneBy(bearer: 'cust' | 'lingniu', customerName: string): BorneBy { + if (customerName === '车辆异动') return 'pending'; + return bearer === 'cust' ? 'customer' : 'company'; +} + +function mobileProvinceLabel(province: string) { + return province === 'all' ? '全国' : province.slice(0, 2); +} + +interface BiYearSelectProps { + value: number; + onChange: (year: number) => void; +} + +function BiYearSelect({ value, onChange }: BiYearSelectProps) { + const [isOpen, setIsOpen] = useState(false); + const ref = useRef(null); + + // 提供从 2026 到 2020 年份列表,满足历史多年数据查阅诉求 + const years = [2026, 2025, 2024, 2023, 2022, 2021, 2020]; + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setIsOpen(false); + } + } + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + return ( +
+ + + {isOpen && ( +
+
切换数据年份
+
+ {years.map((y) => ( + + ))} +
+
+ )} +
+ ); +} + +/** BI 皮 · 可搜索选择器(禁 V2;穿透筛专用) */ +interface BiSearchSelectOption { + value: string; + label: string; +} + +function BiSearchSelect({ + value, + onChange, + options, + allLabel, + placeholder = '搜索…', + width = 180, + disabled = false, +}: { + value: string; + onChange: (next: string) => void; + options: BiSearchSelectOption[]; + allLabel: string; + placeholder?: string; + width?: number; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const ref = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + function onDoc(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + if (open) document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, [open]); + + useEffect(() => { + if (open) { + setQuery(''); + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [open]); + + const selectedLabel = + value === 'all' ? allLabel : options.find((o) => o.value === value)?.label || allLabel; + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return options; + return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q)); + }, [options, query]); + + return ( +
+ + {open && !disabled && ( +
+
+ + setQuery(e.target.value)} + placeholder={placeholder} + onClick={(e) => e.stopPropagation()} + /> +
+
+ + {filtered.map((o) => ( + + ))} + {filtered.length === 0 &&
无匹配项
} +
+
+ )} +
+ ); +} + +interface OverviewTrendsProps { + year: number; + fleetScope: FleetScope; + verifyScope: 'all' | 'verified'; + onOpenDrill: (label: string) => void; + onOpenCustomerBill: (custName: string) => void; + onOpenStationBill: (stName: string, province: string) => void; + overview: any; +} + +function OverviewTrendsDashboard({ year, fleetScope, verifyScope, onOpenDrill, onOpenCustomerBill, onOpenStationBill, overview }: OverviewTrendsProps) { + const overviewRangeText = overview?.range?.startDate && overview?.range?.endDate + ? `${overview.range.startDate} 至 ${overview.range.endDate}` + : `${year}-01-01 至 ${year}-12-31`; + // 月度加氢量数据 (根据年份、车辆范围、核对范围加权) + const monthlyData = useMemo(() => { + if (overview) { + return overview.monthly.map((item: any) => ({ + month: `${Number(String(item.month).slice(-2))}月`, + ownKg: Number(item.lingniuKg) || 0, + extKg: Number(item.externalKg) || 0, + totalKg: Number(item.totalKg) || 0, + })); + } + const is2026 = year === 2026; + const is2025 = year === 2025; + const factor = is2026 ? 1 : is2025 ? 0.85 : 0.7; + // 仅已核对:外部车在示意数据中不参与核对 → 外部归零;羚牛约 3/4 已核 + const ownVerifyFactor = verifyScope === 'verified' ? 0.75 : 1; + const extVerifyFactor = verifyScope === 'verified' ? 0 : 1; + + const baseMonths = [ + { m: '1月', own: 56800, ext: 28400 }, + { m: '2月', own: 34600, ext: 17400 }, + { m: '3月', own: 75200, ext: 37600 }, + { m: '4月', own: 90000, ext: 45000 }, + { m: '5月', own: 85300, ext: 42700 }, + { m: '6月', own: 78600, ext: 39400 }, + { m: '7月', own: 81300, ext: 40700 }, + { m: '8月', own: 18600, ext: 9400 }, + ]; + + return baseMonths.map((item) => { + let ownKg = Math.round(item.own * factor * ownVerifyFactor); + let extKg = Math.round(item.ext * factor * extVerifyFactor); + if (fleetScope === 'own') extKg = 0; + if (fleetScope === 'external') ownKg = 0; + const totalKg = ownKg + extKg; + return { month: item.m, ownKg, extKg, totalKg }; + }); + }, [year, fleetScope, verifyScope, overview]); + + const maxMonthlyKg = useMemo(() => { + return Math.max(...monthlyData.map((d) => d.totalKg), 1); + }, [monthlyData]); + + // 基础客户列表池,用于计算月度客户加氢金额排行 + const baseCustomers = [ + { name: '嘉兴市乍浦港口经营有限公司', ratio: 0.408 }, + { name: '嘉兴益顺冷链物流有限公司', ratio: 0.176 }, + { name: '嘉兴智奇供应链管理有限公司', ratio: 0.118 }, + { name: '四川群彬物流有限公司', ratio: 0.078 }, + { name: '浙江洋井供应链管理有限公司', ratio: 0.053 }, + { name: '重庆金时源供应链有限公司', ratio: 0.039 }, + { name: '四川拱照物流有限公司', ratio: 0.030 }, + { name: '嘉兴羚利供应链科技有限公司', ratio: 0.027 }, + { name: '宁波港集装箱运输嘉兴分公司', ratio: 0.023 }, + { name: '成都诺和物流有限公司', ratio: 0.015 }, + { name: '嘉兴市飞宇物流有限公司', ratio: 0.012 }, + { name: '嘉兴港区众通快递有限公司', ratio: 0.010 }, + { name: '浙江集佑供应链有限公司', ratio: 0.008 }, + { name: '日邮物流(中国)有限公司', ratio: 0.003 }, + ]; + + // 月度收支对比数据及客户加氢金额明细 & 成本支出结构明细 + const monthlyRevenueData = useMemo(() => { + if (overview) { + return overview.monthly.map((item: any) => { + const income = Number(item.customerRevenue) || 0; + const cost = Number(item.cost) || 0; + return { + m: `${Number(String(item.month).slice(-2))}月`, income, cost, + top9: [], restAmount: 0, restCount: 0, + costDetails: [ + { label: '客户承担', amount: Number(item.customerCost) || 0 }, + { label: '我司承担', amount: Number(item.companyCost) || 0 }, + { label: '其他成本', amount: Number(item.otherCost) || 0 }, + ], + }; + }); + } + const is2026 = year === 2026; + const factor = is2026 ? 1 : 0.8; + const fleetFactor = fleetScope === 'all' ? 1 : fleetScope === 'own' ? 0.67 : 0.33; + const verifyFactor = verifyScope === 'verified' ? (fleetScope === 'external' ? 0 : 0.75) : 1; + const scale = factor * fleetFactor * verifyFactor; + const base = [ + { m: '1月', income: 82000, cost: 78000 }, + { m: '2月', income: 38000, cost: 36000 }, + { m: '3月', income: 98000, cost: 92000 }, + { m: '4月', income: 105000, cost: 99000 }, + { m: '5月', income: 112000, cost: 104000 }, + { m: '6月', income: 118000, cost: 109000 }, + { m: '7月', income: 115000, cost: 108000 }, + { m: '8月', income: 16500, cost: 15800 }, + ]; + + return base.map((b) => { + const income = Math.round(b.income * scale); + const cost = Math.round(b.cost * scale); + + // 计算每个客户当月收入金额 + const rawList = baseCustomers.map((c) => ({ + name: c.name, + amount: Math.round(income * c.ratio), + })); + + // 按从高到低排序 + rawList.sort((x, y) => y.amount - x.amount); + + // 截取 TOP9 + const top9 = rawList.slice(0, 9); + const restList = rawList.slice(9); + const restAmount = restList.reduce((sum, item) => sum + item.amount, 0); + + // 计算成本支出结构细项 (包氢、我司承担、物流、运维异动、运维调拨) + const costDetails = [ + { label: '包氢项目', amount: Math.round(cost * 0.36) }, + { label: '我司承担', amount: Math.round(cost * 0.32) }, + { label: '物流成本', amount: Math.round(cost * 0.18) }, + { label: '运维异动', amount: Math.round(cost * 0.08) }, + { label: '运维调拨', amount: Math.round(cost * 0.06) }, + ]; + + return { + m: b.m, + income, + cost, + top9, + restAmount, + restCount: restList.length, + costDetails, + }; + }); + }, [year, fleetScope, verifyScope, overview]); + + const maxRevenueVal = useMemo(() => { + return Math.max(...monthlyRevenueData.flatMap((d) => [d.income, d.cost]), 1); + }, [monthlyRevenueData]); + + // Top5 站列表 (带内部 vs 外部堆积;跟随车辆/核对筛选) + const topStations = useMemo(() => { + if (overview) { + const rows = overview.topStations.slice(0, 5).map((item: any, index: number) => ({ + rank: index + 1, + name: item.name, + ownKg: Number(item.lingniuKg) || 0, + extKg: Number(item.externalKg) || 0, + val: Number(item.kg) || 0, + })); + const maxVal = Math.max(...rows.map((item: any) => item.val), 1); + return rows.map((item: any) => ({ ...item, pct: Math.round(item.val / maxVal * 100) })); + } + const ownVerifyFactor = verifyScope === 'verified' ? 0.75 : 1; + const extVerifyFactor = verifyScope === 'verified' ? 0 : 1; + const raw = [ + { rank: 1, name: '嘉兴中石化滨海加氢站', ownKg: 163250, extKg: 80411 }, + { rank: 2, name: '嘉兴嘉锦加氢站', ownKg: 128020, extKg: 54869 }, + { rank: 3, name: '嘉兴嘉燃加氢站', ownKg: 18350, extKg: 9884 }, + { rank: 4, name: '桐乡中石化绿能加氢站', ownKg: 15648, extKg: 10432 }, + { rank: 5, name: '成都中石化天府机场北站', ownKg: 16050, extKg: 6879 }, + ]; + return raw + .map((st) => { + let ownKg = Math.round(st.ownKg * ownVerifyFactor); + let extKg = Math.round(st.extKg * extVerifyFactor); + if (fleetScope === 'own') extKg = 0; + if (fleetScope === 'external') ownKg = 0; + const val = ownKg + extKg; + return { ...st, ownKg, extKg, val, pct: 100 }; + }) + .map((st, _, arr) => { + const maxVal = Math.max(...arr.map((x) => x.val), 1); + return { ...st, pct: Math.round((st.val / maxVal) * 100) }; + }); + }, [fleetScope, verifyScope, overview]); + + // 区域维度控制: 按市 ('city') | 按省 ('province') + const [regionGranularity, setRegionGranularity] = useState<'province' | 'city'>('city'); + + // 省份筛选控制: 'all' | '浙江省' | '四川省' | '广东省' | '江苏省' | '湖北省' 等 + const [selectedProvince, setSelectedProvince] = useState('all'); + const [mobileDetailTab, setMobileDetailTab] = useState<'station' | 'customer'>('station'); + const [stationFullscreenOpen, setStationFullscreenOpen] = useState(false); + + useEffect(() => { + if (!stationFullscreenOpen) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setStationFullscreenOpen(false); + }; + const onFullscreenChange = () => { + if (!document.fullscreenElement) setStationFullscreenOpen(false); + }; + document.addEventListener('keydown', onKeyDown); + document.addEventListener('fullscreenchange', onFullscreenChange); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('fullscreenchange', onFullscreenChange); + document.documentElement.classList.remove('ehb-landscape-session'); + screen.orientation?.unlock?.(); + }; + }, [stationFullscreenOpen]); + + const openStationFullscreen = async () => { + setStationFullscreenOpen(true); + document.documentElement.classList.add('ehb-landscape-session'); + const root = document.documentElement; + if (root.requestFullscreen && !document.fullscreenElement) { + await root.requestFullscreen().catch(() => undefined); + } + const orientation = screen.orientation as ScreenOrientation & { + lock?: (mode: string) => Promise; + }; + if (orientation?.lock) await orientation.lock('landscape').catch(() => undefined); + }; + + const closeStationFullscreen = () => { + setStationFullscreenOpen(false); + document.documentElement.classList.remove('ehb-landscape-session'); + if (document.fullscreenElement) void document.exitFullscreen().catch(() => undefined); + }; + + // 已有加氢站的省份去重列表 + const availableProvinces = useMemo(() => { + const list: string[] = ['all']; + (overview?.stations ?? []).forEach((st: any) => { + const province = st.province; + if (province && !list.includes(province)) { + list.push(province); + } + }); + return list; + }, [overview]); + + // 根据选定省份精准过滤加氢站列表 + const filteredStationList = useMemo(() => { + const source = overview + ? overview.stations.map((st: any, idx: number) => ({ + rank: idx + 1, name: st.name, province: st.province || '未归属', + kgT: ((Number(st.kg) || 0) / 1000).toFixed(2), + kgPct: Number(st.share) || 0, + incomeWan: ((Number(st.customerRevenue) || 0) / 10000).toFixed(2), + incomePct: overview.kpis.customerRevenue + ? (Number(st.customerRevenue) || 0) / Number(overview.kpis.customerRevenue) * 100 : 0, + })) + : []; + if (selectedProvince === 'all') return source; + return source.filter((st: any) => st.province === selectedProvince); + }, [selectedProvince, overview]); + + // 根据过滤结果计算总站数 (全国 65 站基准,按比例联动) + const stationCountDisplay = useMemo(() => { + if (overview) return `共 ${filteredStationList.length} 站`; + if (selectedProvince === 'all') return '共 65 站'; + if (selectedProvince === '浙江省') return '共 28 站'; + if (selectedProvince === '广东省') return '共 16 站'; + if (selectedProvince === '四川省') return '共 11 站'; + if (selectedProvince === '江苏省') return '共 7 站'; + return `共 ${filteredStationList.length} 站`; + }, [selectedProvince, filteredStationList, overview]); + + // 按市区域占比数据 (规范地级市名称) + const cityRegions = [ + { label: '嘉兴市', pct: '65.2%', color: CHART_BLUE, dashArray: '155 238', dashOffset: '0' }, + { label: '成都市', pct: '7.4%', color: CHART_EXTERNAL, dashArray: '18 238', dashOffset: '-156' }, + { label: '佛山市', pct: '3.7%', color: '#35a889', dashArray: '9 238', dashOffset: '-175' }, + { label: '昆山市', pct: '2.8%', color: '#f09a61', dashArray: '7 238', dashOffset: '-185' }, + { label: '常熟市', pct: '2.2%', color: '#8c7bd6', dashArray: '5 238', dashOffset: '-193' }, + { label: '广州市', pct: '2.1%', color: '#d47c9b', dashArray: '5 238', dashOffset: '-199' }, + { label: '深圳市', pct: '1.9%', color: '#55aebc', dashArray: '4 238', dashOffset: '-205' }, + { label: '无锡市', pct: '1.9%', color: '#8dbd68', dashArray: '4 238', dashOffset: '-210' }, + { label: '其他城市', pct: '12.7%', color: '#9aa7b8', dashArray: '30 238', dashOffset: '-215' }, + ]; + + // 按省区域占比数据 + const provinceRegions = [ + { label: '浙江省', pct: '73.2%', color: CHART_BLUE, dashArray: '175 238', dashOffset: '0' }, + { label: '四川省', pct: '11.8%', color: CHART_EXTERNAL, dashArray: '28 238', dashOffset: '-176' }, + { label: '广东省', pct: '7.5%', color: '#35a889', dashArray: '18 238', dashOffset: '-205' }, + { label: '江苏省', pct: '5.4%', color: '#f09a61', dashArray: '13 238', dashOffset: '-224' }, + { label: '其他省份', pct: '2.1%', color: '#9aa7b8', dashArray: '5 238', dashOffset: '-238' }, + ]; + + const liveCityRegions = overview?.regions?.map((item: any, index: number) => ({ + label: item.region || '未归属', pct: `${Number(item.share || 0).toFixed(1)}%`, + kg: Number(item.kg) || 0, + color: [CHART_BLUE, CHART_EXTERNAL, '#35a889', '#f09a61', '#8c7bd6', '#d47c9b', '#55aebc', '#8dbd68', '#9aa7b8'][index % 9], + dashArray: `${Math.max(0, Number(item.share || 0) * 2.38)} 238`, dashOffset: '0', + })); + const liveProvinceRegions = overview ? Object.values(overview.stations.reduce((acc: any, st: any) => { + const label = st.province || '未归属'; + acc[label] = acc[label] || { label, kg: 0 }; + acc[label].kg += Number(st.kg) || 0; + return acc; + }, {})).sort((a: any, b: any) => b.kg - a.kg).map((item: any, index: number) => { + const share = overview.kpis.totalKg ? item.kg / overview.kpis.totalKg * 100 : 0; + return { label: item.label, kg: item.kg, pct: `${share.toFixed(1)}%`, color: [CHART_BLUE, CHART_EXTERNAL, '#35a889', '#f09a61', '#9aa7b8'][index % 5], dashArray: `${share * 2.38} 238`, dashOffset: '0' }; + }) : null; + const uncollapsedRegionBase = regionGranularity === 'province' + ? (liveProvinceRegions ?? provinceRegions) + : (liveCityRegions ?? cityRegions); + const regionLimit = regionGranularity === 'province' ? 4 : 8; + const activeRegionBase = overview && uncollapsedRegionBase.length > regionLimit + ? [ + ...uncollapsedRegionBase.slice(0, regionLimit), + { + label: '其他', + kg: uncollapsedRegionBase.slice(regionLimit).reduce((sum: number, item: any) => sum + Number(item.kg || 0), 0), + pct: `${uncollapsedRegionBase.slice(regionLimit).reduce((sum: number, item: any) => sum + parseFloat(item.pct || '0'), 0).toFixed(1)}%`, + color: '#9aa7b8', + dashArray: '0 238', + dashOffset: '0', + }, + ] + : uncollapsedRegionBase; + let liveDashCursor = 0; + const activeRegions = activeRegionBase.map((region: any) => { + if (!overview) return region; + const segment = Math.max(0, parseFloat(region.pct) * 2.38); + const mapped = { ...region, dashArray: `${segment} 238`, dashOffset: `${-liveDashCursor}` }; + liveDashCursor += segment; + return mapped; + }); + + return ( +
+ {/* 1. 月度加氢量趋势柱图 */} +
+
+
{year} 年月度加氢量
+
+ + + 羚牛车辆羚牛车辆 + + + + 外部车辆外部车辆 + + + 统计范围:{overviewRangeText} · 单位 Kg + +
+
+ +
+ {monthlyData.map((d) => { + const heightPct = Math.min(100, Math.round((d.totalKg / maxMonthlyKg) * 100)); + const ownRatio = d.totalKg > 0 ? Math.round((d.ownKg / d.totalKg) * 100) : 60; + const extRatio = Math.max(0, 100 - ownRatio); + + return ( +
onOpenDrill(`${year}年${d.month}加氢量`)} + style={{ cursor: 'pointer' }} + title="查看该月各加氢站内部/外部车辆加氢量" + > + {/* 悬浮柱状图时显示羚牛车辆、外部车辆加氢量卡片 */} +
+
+ {year}年{d.month} +
+
+ + + 羚牛车辆 + + + {d.ownKg.toLocaleString('zh-CN')} Kg + +
+
+ + + 外部车辆 + + + {d.extKg.toLocaleString('zh-CN')} Kg + +
+
+ + 月度合计 + + + {d.totalKg.toLocaleString('zh-CN')} Kg + +
+
+ +
{(d.totalKg / 1000).toFixed(1)}k
+
+
+
+
+
{d.month}
+
+ ); + })} +
+
+ + {/* 2. 月度收支对比柱图 */} +
+
+
{year} 年月度收支对比
+
+ + + 客户收入 + + + + 成本支出 + + + 统计范围:{overviewRangeText} · 单位 元 + +
+
+ +
+ {monthlyRevenueData.map((d) => { + const incPct = Math.min(100, Math.round((d.income / maxRevenueVal) * 100)); + const costPct = Math.min(100, Math.round((d.cost / maxRevenueVal) * 100)); + + return ( +
+
+
onOpenDrill(`${year}年${d.m}成本支出`)} + title="查看该月各加氢站成本支出明细" + > + {/* 悬浮成本支出时,显示包氢、物流、运维异动等成本结构明细 */} +
+
+ {year}年{d.m} 成本支出明细 + 成本构成 +
+ +
+ {d.costDetails.map((item) => ( +
+ + + {item.label} + + + ¥{item.amount.toLocaleString('zh-CN')} + +
+ ))} +
+ +
+ 成本合计 + + ¥{d.cost.toLocaleString('zh-CN')} + +
+
+
+
onOpenDrill(`${year}年${d.m}客户收入`)} + title="查看该月各加氢站客户收入明细" + > + {/* 悬浮客户收入时,显示按金额从高到低排列的客户明细 (TOP9 + 其他客户) */} +
+
+ {year}年{d.m} 客户加氢收入明细 + 金额高→低 +
+ +
+ {d.top9.map((item, idx) => ( +
+ + {idx + 1}. {item.name} + + + ¥{item.amount.toLocaleString('zh-CN')} + +
+ ))} + + {d.restCount > 0 && ( +
+ + 10. 其他客户 ({d.restCount}家) + + + ¥{d.restAmount.toLocaleString('zh-CN')} + +
+ )} +
+ +
+ 收入合计 + + ¥{d.income.toLocaleString('zh-CN')} + +
+
+
+
+
{d.m}
+
+ ); + })} +
+
+ + {/* 3 & 4. 下方并排:Top5 站加氢量 + 各区域加氢占比 */} +
+ {/* Top5 站 */} +
+
+
加氢站加氢量 Top5
+
+ + + 羚牛车辆羚牛车辆 + + + + 外部车辆外部车辆 + + + 统计范围:{overviewRangeText} · 单位 Kg + +
+
+ +
+ {topStations.map((st) => { + const ownRatio = Math.round((st.ownKg / st.val) * 100); + const extRatio = 100 - ownRatio; + + return ( +
onOpenDrill(`加氢站客户量:${st.name}`)} + style={{ cursor: 'pointer' }} + title="查看该加氢站内部/外部车辆加氢总量" + > + 2 ? 'is-sub' : ''}`}>{st.rank} + + {st.name} + +
+ {/* 精细高保真 Hover 悬浮弹出卡片 */} +
+
{st.name}
+
+ + + 羚牛车辆 + + + {st.ownKg.toLocaleString('zh-CN')} Kg ({ownRatio}%) + +
+
+ + + 外部车辆 + + + {st.extKg.toLocaleString('zh-CN')} Kg ({extRatio}%) + +
+
+ 加氢总量 + + {st.val.toLocaleString('zh-CN')} Kg + +
+
+ +
+
+
+
+
+ {st.val.toLocaleString('zh-CN')} +
+ ); + })} +
+
+ + {/* 各区域加氢占比 (支持按省 / 按市快速切换) */} +
+
+
各区域加氢占比
+
+ + +
+
+ +
+
+ + + {activeRegions.map((reg) => ( + + onOpenDrill( + `区域${regionGranularity === 'city' ? '市' : '省'}:${reg.label}`, + ) + } + > + {`查看${reg.label}各加氢站加氢总量与占比`} + + ))} + +
+
年合计
+
{overview ? `${(Number(overview.kpis.totalKg || 0) / 1000).toFixed(2)}T` : '697.17T'}
+
+
+ +
+ {activeRegions.map((reg) => ( +
+ onOpenDrill(`区域${regionGranularity === 'city' ? '市' : '省'}:${reg.label}`) + } + style={{ cursor: 'pointer' }} + title={`查看${reg.label}各加氢站加氢总量与占比`} + > +
+ + {reg.label} +
+ {reg.pct} +
+ ))} +
+
+
+
+ +
+
+
数据明细
+ +
+ + +
+
+ + {/* 5. 趋势图下方:加氢站加氢汇总表 (支持区域按省筛选切换) */} +
+
+
+
加氢站加氢汇总
+ {/* 区域省份切换控制 (仅展示已有加氢站的省份) */} +
+ {availableProvinces.map((prov) => ( + + ))} +
+
+ +
+ 统计范围:{overviewRangeText} · {stationCountDisplay} +
+
+ +
+ + + + + + + + + + + + + + {filteredStationList.map((st, idx) => ( + onOpenStationBill(st.name, st.province)} + style={{ cursor: 'pointer' }} + title="查看加氢量、占比、氢费收入和收入占比" + > + + + + + + + + + ))} + +
#加氢站(查看明细)所属省份加氢量占比氢费收入收入占比
{idx + 1} + {st.name}{' '} + 查看 › + + + {st.province} + + + {st.kgT} T + +
+
+
+
+ {st.kgPct.toFixed(1)}% +
+
+ ¥{st.incomeWan} 万元 + +
+
+
+
+ {st.incomePct.toFixed(1)}% +
+
+
+
+ + {/* 6. 趋势图下方:客户费用汇总表 (Top 30) */} +
+
+
+ 客户费用汇总 +
+ +
+ 统计范围:{overviewRangeText} · 共 {overview?.customers?.length ?? 30} 家 +
+
+ +
+ + + + + + + + + + + + + {(overview ? overview.customers.map((item: any, index: number) => ({ + rank: index + 1, name: item.name, + bearer: item.bearer === 'company' ? 'lingniu' : 'cust', + kgT: ((Number(item.kg) || 0) / 1000).toFixed(2), + costWan: ((Number(item.cost) || 0) / 10000).toFixed(2), + receivable: `¥${((Number(item.customerRevenue) || 0) / 10000).toFixed(2)} 万元`, + })) : []).map((cust: any) => ( + onOpenCustomerBill(cust.name)} + style={{ cursor: 'pointer' }} + title="查看承担方、加氢量、成本支出与收款明细" + > + + + + + + + + ))} + +
#客户(查看明细)承担方加氢量成本支出应收
{cust.rank} + {cust.name}{' '} + 查看 › + + {renderBorneTag(summaryBorneBy(cust.bearer, cust.name))} + + {cust.kgT} T + + ¥{cust.costWan} 万元 + + {cust.receivable} +
+
+
+
+ + {stationFullscreenOpen ? ( +
+
+
+
+ 加氢站加氢汇总 + 统计范围:{overviewRangeText} · {stationCountDisplay} +
+ +
+
+ {availableProvinces.map((prov) => ( + + ))} +
+
+ + + + {filteredStationList.map((st, idx) => ( + + + + + + + + + + + ))} + +
#加氢站所属省份加氢量占比氢费收入收入占比操作
{idx + 1}{st.name}{st.province}{st.kgT} T{st.kgPct.toFixed(1)}%¥{st.incomeWan} 万元{st.incomePct.toFixed(1)}%
+
+
+
+ ) : null} +
+ ); +} + +function RegionRemainderModal({ + kind, + items, + onSelect, + onClose, +}: { + kind: '市' | '省'; + items: Array<{ label: string; kg: number; share: number }>; + onSelect: (label: string) => void; + onClose: () => void; +}) { + const totalKg = items.reduce((sum, item) => sum + item.kg, 0); + return ( +
+
event.stopPropagation()}> +
+
+ +
+
其他{kind === '市' ? '城市' : '省份'}明细
+
点击区域继续查看其加氢站明细
+
+
+ +
+
+
+
归并区域{items.length} 个
+
归并加氢量{(totalKg / 1000).toFixed(2)} T
+
+
+ + + + {items.map((item) => ( + onSelect(item.label)} style={{ cursor: 'pointer' }} title={`查看${item.label}加氢站明细`}> + + + + + + ))} + +
区域加氢量 (Kg)全局占比操作
{item.label}{item.kg.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}{item.share.toFixed(1)}%继续下钻 ›
+
+
+
+
+ ); +} + +/** + * 嵌入 bi-next `#hydrogen/overview`: + * - 壳 / KPI / 洞察对齐宿主 zip 视觉 + * - 独立功能块:三维度 + 站月/客户汇总 + 订单明细 · 禁用 OneOS V2 + */ +export const EnergyBiBoardApp: React.FC = () => { + const [boardScope, setBoardScope] = useState('global'); + const [hostView, setHostView] = useState('overview'); + const [year, setYear] = useState(DEFAULT_YEAR); + const [fleetScope, setFleetScope] = useState('all'); + const [verifyScope, setVerifyScope] = useState<'all' | 'verified'>('all'); + const [filtersOpen, setFiltersOpen] = useState(false); + const [dimFilter, setDimFilter] = useState(null); + const [statsTab, setStatsTab] = useState('siteMonth'); + const [stationId, setStationId] = useState(null); + const [stationLabel, setStationLabel] = useState(null); + const [customerId, setCustomerId] = useState(null); + const [customerLabel, setCustomerLabel] = useState(null); + const [liveOverview, setLiveOverview] = useState(null); + const [livePendingOverview, setLivePendingOverview] = useState(null); + const [liveMeta, setLiveMeta] = useState(null); + const [liveError, setLiveError] = useState(null); + const [liveLoading, setLiveLoading] = useState(true); + const [liveReloadToken, setLiveReloadToken] = useState(0); + + // KPI 点击下钻 Modal 状态 + const [kpiDrillType, setKpiDrillType] = useState(null); + // 头部加氢站占比 → 加氢量排名下拉 + const [stationRankOpen, setStationRankOpen] = useState(false); + const stationRankRef = useRef(null); + + // 客户账单专属下钻 Modal 状态 (客户 → 日期 → 车牌加氢记录) + const [selectedBillCustomer, setSelectedBillCustomer] = useState(null); + + // 加氢站账单专属下钻 Modal 状态(按日经营汇总 → 单笔加氢明细) + const [selectedStationForDrill, setSelectedStationForDrill] = useState<{ name: string; province: string } | null>(null); + + // 按日视角日期区间状态 (提升至顶层供标题旁时间范围联动) + const localIsoDate = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + const [dailyEndDate, setDailyEndDate] = useState(() => localIsoDate(new Date())); + const [dailyStartDate, setDailyStartDate] = useState(() => { + const date = new Date(); date.setDate(date.getDate() - 14); return localIsoDate(date); + }); + const [dailyRangePreset, setDailyRangePreset] = useState('15days'); + const [dailyFleetType, setDailyFleetType] = useState('all'); + const [dailyReloadToken, setDailyReloadToken] = useState(0); + const [dailyRefreshing, setDailyRefreshing] = useState(false); + + useEffect(() => { + let active = true; + setLiveLoading(true); + setLiveError(null); + setLiveMeta(null); + setLiveOverview(null); + setLivePendingOverview(null); + const vehicleScope = fleetScope === 'own' ? 'lingniu' : fleetScope; + Promise.all([ + fetchH2BiMeta(), + fetchH2BiOverview({ year, vehicleScope, verifyScope, regionGranularity: 'city' }), + fetchH2BiOverview({ year, vehicleScope, verifyScope: 'unverified', regionGranularity: 'city' }), + ]).then(([meta, overview, pendingOverview]) => { + if (!active) return; + setLiveMeta(meta); + setLiveOverview(overview); + setLivePendingOverview(pendingOverview); + setLiveLoading(false); + }).catch((error) => { + if (!active) return; + setLiveMeta(null); + setLiveOverview(null); + setLivePendingOverview(null); + setLiveError(error instanceof Error ? error.message : String(error)); + setLiveLoading(false); + }); + return () => { active = false; }; + }, [year, fleetScope, verifyScope, liveReloadToken]); + + const handleDailyPresetChange = (preset: DailyRangePreset) => { + setDailyRangePreset(preset); + const end = new Date(); + const start = new Date(end); + if (preset === 'week') { + const weekday = (end.getDay() + 6) % 7; + start.setDate(end.getDate() - weekday); + } else if (preset === 'month') { + start.setDate(1); + } else if (preset === '15days') { + start.setDate(end.getDate() - 14); + } else { + return; + } + setDailyStartDate(localIsoDate(start)); + setDailyEndDate(localIsoDate(end)); + }; + + // 全局看板时间范围(单站模式不展示:维度不同,由站内查询日期自管) + const timeRangeLabel = '统计时间范围'; + const timeRangeText = useMemo(() => { + // 单站内容由 StationDailyApp 使用 dailyStartDate/dailyEndDate 驱动;不能沿用全局年度 overview 范围。 + if (boardScope === 'station' || hostView === 'daily') { + return `${dailyStartDate} 至 ${dailyEndDate}`; + } + if (liveOverview?.range?.startDate && liveOverview?.range?.endDate) { + return `${liveOverview.range.startDate} 至 ${liveOverview.range.endDate}`; + } + return `${year}-01-01 至 ${year}-12-31`; + }, [boardScope, hostView, dailyStartDate, dailyEndDate, year, liveOverview]); + const mobileView: HostView = boardScope === 'station' ? 'daily' : hostView; + const activeMobileFleet = mobileView === 'daily' ? dailyFleetType : fleetScope; + const mobileFilterCount = mobileView === 'daily' + ? (dailyRangePreset === 'custom' ? 1 : 0) + (dailyFleetType === 'all' ? 0 : 1) + : (verifyScope === 'verified' ? 1 : 0) + (fleetScope === 'all' ? 0 : 1); + const handleMobileViewChange = (nextView: HostView) => { + if (boardScope === 'station' && nextView === 'overview') { + setBoardScope('global'); + setHostView('overview'); + } else { + setHostView(nextView); + } + setFiltersOpen(false); + }; + + const clearEntity = () => { + setStationId(null); + setStationLabel(null); + setCustomerId(null); + setCustomerLabel(null); + }; + + const rows = useMemo( + () => filterOrders(MOCK_ORDERS, year, verifyScope, fleetScope), + [year, verifyScope, fleetScope], + ); + const hostKpi = useMemo(() => { + if (!liveOverview) return computeHostKpi(rows, year, MOCK_ORDERS, HOST_KPI); + const k = liveOverview.kpis; + return { + totalKgT: Number((k.totalKg / 1000).toFixed(2)), + companyKgT: Number((k.companyBearingKg / 1000).toFixed(2)), + customerKgT: Number((k.customerBearingKg / 1000).toFixed(2)), + pendingKgT: Number((k.otherBearingKg / 1000).toFixed(2)), + totalFeeWan: Number((k.totalCost / 10000).toFixed(2)), + companyFeeWan: Number((k.companyCost / 10000).toFixed(2)), + customerFeeWan: Number((k.customerCost / 10000).toFixed(2)), + pendingFeeWan: Number((k.otherCost / 10000).toFixed(2)), + profitWan: Number((k.customerGrossProfit / 10000).toFixed(2)), + incomeWan: Number((k.customerRevenue / 10000).toFixed(2)), + costWan: Number((k.customerCost / 10000).toFixed(2)), + monthKgT: Number((k.monthKg / 1000).toFixed(2)), + monthFeeWan: Number((k.monthCost / 10000).toFixed(2)), + monthYearPct: Number(k.monthShareOfRange || 0).toFixed(2), + dayKg: Number(k.todayKg || 0), + dayFee: Number(k.todayCost || 0), + dayMonthPct: Number(k.todayShareOfMonth || 0).toFixed(2), + }; + }, [rows, year, liveOverview]); + const totalKgForShare = hostKpi.totalKgT || 1; + const bearerShares = { + company: Number(((hostKpi.companyKgT / totalKgForShare) * 100).toFixed(2)), + customer: Number(((hostKpi.customerKgT / totalKgForShare) * 100).toFixed(2)), + pending: Number(((hostKpi.pendingKgT / totalKgForShare) * 100).toFixed(2)), + }; + + // 加氢站加氢量排名(高→低),跟随年份/车辆/核对筛选 + const stationRankList = useMemo(() => { + if (liveOverview) { + const list = liveOverview.stations.map((st: any) => ({ + name: st.name, province: st.province || '未归属', kg: Number(st.kg) || 0, + })).filter((st: any) => st.kg > 0).sort((a: any, b: any) => b.kg - a.kg); + const maxKg = list[0]?.kg || 1; + const totalKg = list.reduce((sum: number, item: any) => sum + item.kg, 0) || 1; + return list.map((st: any, index: number) => ({ ...st, rank: index + 1, barPct: Math.round(st.kg / maxKg * 100), sharePct: Math.round(st.kg / totalKg * 1000) / 10 })); + } + // 没有真实数据时返回空列表:不再用原型演示数据按年份/归属系数编造排名。 + return []; + }, [liveOverview]); + + const top5SharePct = useMemo(() => { + const top5 = stationRankList.slice(0, 5).reduce((s, x) => s + x.kg, 0); + const total = stationRankList.reduce((s, x) => s + x.kg, 0) || 1; + return Math.round((top5 / total) * 1000) / 10; + }, [stationRankList]); + + useEffect(() => { + if (!stationRankOpen) return; + function onDoc(e: MouseEvent) { + if (stationRankRef.current && !stationRankRef.current.contains(e.target as Node)) { + setStationRankOpen(false); + } + } + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, [stationRankOpen]); + + const dims = useMemo(() => costDimCards(rows), [rows]); + const risk = livePendingOverview + ? { + count: Number(livePendingOverview.kpis.recordCount || 0), + amount: Number(livePendingOverview.kpis.totalCost || 0), + } + : { count: 0, amount: 0 }; + const unitProfitYuan = hostKpi.totalKgT > 0 + ? ((hostKpi.profitWan * 10000) / (hostKpi.totalKgT * 1000)).toFixed(2) + : '—'; + const unverifiedWan = (risk.amount / 10000).toFixed(2); + const liveMonthComparison = useMemo(() => { + if (!liveOverview?.monthly?.length) return null; + let points = liveOverview.monthly.filter((item: any) => Number(item.totalKg) > 0); + const endMonth = String(liveOverview.range?.endDate || '').slice(0, 7); + if (points.length > 2 && points[points.length - 1]?.month === endMonth) points = points.slice(0, -1); + const current = points[points.length - 1]; + const previous = points[points.length - 2]; + if (!current || !previous || !Number(previous.totalKg)) return null; + const value = (Number(current.totalKg) - Number(previous.totalKg)) / Number(previous.totalKg) * 100; + return { + value, + label: `${Number(String(current.month).slice(-2))}月较${Number(String(previous.month).slice(-2))}月`, + }; + }, [liveOverview]); + + const companyScoped = useMemo( + () => companyRowsForStats(rows, dimFilter), + [rows, dimFilter], + ); + + /** 客户归属表:无维度筛时看全量归属;有维度筛时只看对应我司成本行 */ + const customerSource = useMemo(() => { + if (!dimFilter) return rows; + return companyScoped; + }, [rows, dimFilter, companyScoped]); + + const siteMonthRows = useMemo(() => stationMonthAgg(companyScoped), [companyScoped]); + const customerRows = useMemo(() => customerAttrAgg(customerSource), [customerSource]); + + const detailRows = useMemo(() => { + let list = companyScoped; + if (stationId) list = list.filter((r) => r.stationId === stationId); + if (customerId) list = list.filter((r) => r.customerId === customerId); + return list; + }, [companyScoped, stationId, customerId]); + + const remainderRegions = useMemo(() => { + if (!liveOverview) return { city: [], province: [] }; + const city = (liveOverview.regions ?? []) + .map((item: any) => ({ + label: String(item.region || '未归属'), + kg: Number(item.kg) || 0, + share: Number(item.share) || 0, + })) + .sort((left: any, right: any) => right.kg - left.kg) + .slice(8); + const totalKg = Number(liveOverview.kpis?.totalKg) || 0; + const provinceMap = new Map(); + (liveOverview.stations ?? []).forEach((station: any) => { + const label = String(station.province || '未归属'); + provinceMap.set(label, (provinceMap.get(label) || 0) + (Number(station.kg) || 0)); + }); + const province = [...provinceMap.entries()] + .map(([label, kg]) => ({ label, kg, share: totalKg ? kg / totalKg * 100 : 0 })) + .sort((left, right) => right.kg - left.kg) + .slice(4); + return { city, province }; + }, [liveOverview]); + + const externalEmpty = fleetScope === 'external' && rows.length === 0; + const [updatedAt, setUpdatedAt] = useState('—'); + + useEffect(() => { + if (liveOverview?.watermark?.ledgerAt) setUpdatedAt(liveOverview.watermark.ledgerAt); + }, [liveOverview]); + + useEffect(() => { + if (boardScope === 'station' && !stationId && liveOverview?.stations?.length) { + setStationId(String(liveOverview.stations[0].id)); + setStationLabel(liveOverview.stations[0].name); + } + }, [boardScope, liveOverview, stationId]); + + const handleRefreshData = () => { + if (mobileView === 'daily') { + setDailyRefreshing(true); + setDailyReloadToken((value) => value + 1); + return; + } + setLiveReloadToken((value) => value + 1); + }; + + if (liveLoading || liveError || !liveOverview) { + return ( +
+
+ {liveError ? ( +
+
+ +
+ 数据服务暂时不可用 +

后端接口请求失败,本页已停止展示业务数据,避免将缓存值或模拟值误认为真实结果。

+ {liveError} +
+ +
+
+ ) : ( +
+ + 氢能数据加载中 + 请稍候 +
+ )} +
+
+ ); + } + + return ( +
+ + +
+ {liveLoading ? ( +
+ + 氢能数据加载中 + 请稍候 +
+ ) : null} + {liveError ?
统计数据加载失败:{liveError}
: null} +
+
+ +
+
羚牛氢能 BI / 氢能
+
+

氢能经营看板

+ 实时运营 + {boardScope === 'global' ? ( + + 📅 {timeRangeLabel}:{timeRangeText} + + ) : null} +
+
统计时间范围:{timeRangeText}
+
数据更新:{updatedAt}
+
+ +
+
+
+ 范围 +
+ + +
+
+ {boardScope === 'global' ? ( +
+ 全局视图 +
+ + +
+
+ ) : null} +
+
+
+ 查看方式 +
+ + +
+
+ +
+ {boardScope === 'global' ? { setYear(y); clearEntity(); }} /> : null} + + {boardScope === 'global' ? : null} + {boardScope === 'global' ? : null} + +
+
+ 当前范围:{boardScope === 'global' ? '全部站点' : '当前站点'} · {mobileView === 'daily' ? `${dailyStartDate} 至 ${dailyEndDate}` : `${activeMobileFleet === 'all' ? '全部车辆' : activeMobileFleet === 'own' ? '羚牛车辆' : '外部车辆'} · ${verifyScope === 'all' ? '全量订单' : '仅已核对订单'}`} +
+ {mobileView === 'daily' ? ( +
+ ) : null} + + {filtersOpen ? ( +
+ {mobileView === 'overview' ? ( + <> +
订单范围
+
车辆范围
+ + ) : ( + <> +
{ setDailyStartDate(val); setDailyRangePreset('custom'); }} /> { setDailyEndDate(val); setDailyRangePreset('custom'); }} />
+ {boardScope === 'global' ?
车辆范围
: null} + + + )} +
+ ) : null} +
+
+ + {boardScope === 'station' ? ( + { setDailyStartDate(value); setDailyRangePreset('custom'); }} + onEndDateChange={(value: string) => { setDailyEndDate(value); setDailyRangePreset('custom'); }} + refreshToken={dailyReloadToken} + onLoadingChange={setDailyRefreshing} + /> + ) : ( + <> + {hostView === 'daily' ? ( + + ) : ( + <> + {/* 总览视角筛选条 (包含年份选择、核对筛选、车辆归属及刷新,样式与按日视角全面对齐) */} + +
+
+
+ { + setYear(y); + clearEntity(); + }} + /> +
+ + + +
+
+ +
+
+ + +
+ + + {updatedAt} + + + +
+
+
+ +
+

核心经营指标

+
+
+ 累计经营概览 + {year} 年累计 +
+
+ + +
+
+ + + +
+
+
我司{hostKpi.companyKgT} T({bearerShares.company}%)
+
客户{hostKpi.customerKgT} T({bearerShares.customer}%)
+
待核准{hostKpi.pendingKgT} T({bearerShares.pending}%)
+
+ +
+ +
+ + +
+
+
+ } + tone="blue" + label="累计加氢量" + value={hostKpi.totalKgT} + unit="T" + parts={[ + { label: '我司承担', value: `${hostKpi.companyKgT} T` }, + { label: '客户承担', value: `${hostKpi.customerKgT} T` }, + { label: '待核准', value: `${hostKpi.pendingKgT} T` }, + ]} + onClick={() => setKpiDrillType('累计加氢量')} + /> + } + tone="blue" + label="累计加氢费" + prefix="¥" + value={hostKpi.totalFeeWan} + unit="万" + parts={[ + { label: '我司承担', value: `¥${hostKpi.companyFeeWan} 万` }, + { label: '客户承担', value: `¥${hostKpi.customerFeeWan} 万` }, + { label: '待核准', value: `¥${hostKpi.pendingFeeWan} 万` }, + ]} + onClick={() => setKpiDrillType('累计加氢费')} + /> + } + tone="green" + label="加氢利润" + prefix="¥" + value={hostKpi.profitWan} + unit="万" + left={`收入 ¥${hostKpi.incomeWan} 万`} + right={`成本 ¥${hostKpi.costWan} 万`} + onClick={() => setKpiDrillType('加氢利润')} + /> +
+ } + tone="amber" + label="本月加氢量" + value={hostKpi.monthKgT} + unit="T" + left={`加氢费 ¥${hostKpi.monthFeeWan} 万`} + right={`占累计 ${hostKpi.monthYearPct}%`} + onClick={() => setKpiDrillType('本月加氢')} + /> + } + tone="purple" + label="今日加氢量" + value={hostKpi.dayKg} + unit="Kg" + left={`加氢费 ¥${hostKpi.dayFee.toLocaleString('zh-CN')}`} + right={`占本月 ${hostKpi.dayMonthPct}%`} + onClick={() => setKpiDrillType('本日加氢')} + /> +
+
+ +
+ 经营诊断 +
+ 月度环比 + + {liveMonthComparison ? `${liveMonthComparison.value >= 0 ? '+' : ''}${liveMonthComparison.value.toFixed(1)}%` : '—'} + + {liveMonthComparison?.label ?? '暂无可比月份'} +
+
+ 单公斤毛利 + ¥{unitProfitYuan}/kg + 按累计加氢量计算 +
+ + ))} + {stationRankList.length === 0 && ( +
当前筛选下暂无站点数据
+ )} +
+
+ )} + + + +
+ + +
+ + 经营诊断 + + 展开查看 + 收起 + + + +
+
月度环比{liveMonthComparison ? `${liveMonthComparison.value >= 0 ? '+' : ''}${liveMonthComparison.value.toFixed(1)}%` : '—'}{liveMonthComparison?.label ?? '暂无可比月份'}
+
单公斤毛利¥{unitProfitYuan}/kg按累计加氢量计算
+ + +
+
+ + {/* 经营趋势图表大盘:月度加氢量、月度收支对比、Top5站加氢量、各区域加氢占比 */} + setKpiDrillType(lbl)} + onOpenCustomerBill={(custName) => setSelectedBillCustomer(custName)} + onOpenStationBill={(stName, prov) => setSelectedStationForDrill({ name: stName, province: prov })} + /> + + )} + + )} + + {/* KPI 点击下钻数据来源穿透 Modal */} + {boardScope === 'global' && kpiDrillType === '区域市:其他' && ( + setKpiDrillType(`区域市:${label}`)} + onClose={() => setKpiDrillType(null)} + /> + )} + {boardScope === 'global' && kpiDrillType === '区域省:其他' && ( + setKpiDrillType(`区域省:${label}`)} + onClose={() => setKpiDrillType(null)} + /> + )} + {boardScope === 'global' && kpiDrillType && !/^区域(?:市|省):其他$/.test(kpiDrillType) && ( + setKpiDrillType(null)} + /> + )} + + {/* 客户账单专属下钻 Modal (客户 → 日期 → 车牌加氢记录) */} + {boardScope === 'global' && selectedBillCustomer && ( + setSelectedBillCustomer(null)} + /> + )} + + {/* 加氢站账单专属下钻 Modal(按日经营汇总 → 单笔加氢明细) */} + {boardScope === 'global' && selectedStationForDrill && ( + station.name === selectedStationForDrill.name)?.id ?? null, + }} + onClose={() => setSelectedStationForDrill(null)} + /> + )} + + +
+
+ ); +}; + +function PlugZapHint() { + return ; +} + +function HostKpi({ + icon, + tone, + label, + value, + prefix, + unit, + left, + right, + parts, + onClick, +}: { + icon: React.ReactNode; + tone: 'blue' | 'green' | 'amber' | 'purple' | 'cyan'; + label: string; + value: React.ReactNode; + prefix?: string; + unit?: string; + left?: string; + right?: string; + parts?: Array<{ label: string; value: string }>; + onClick?: () => void; +}) { + return ( +
{ + if (onClick && (event.key === 'Enter' || event.key === ' ')) onClick(); + }} + role={onClick ? 'button' : undefined} + tabIndex={onClick ? 0 : undefined} + title="点击展开站 → 客户 → 车牌数据来源穿透明细" + > +
+ + {label} + + 查看明细 + + + {icon} +
+
+ {prefix && {prefix}} + {value} + {unit && {unit}} +
+
+ {parts?.length + ? parts.map((part) => ( + {part.label}{part.value} + )) + : <>{left}{right}} +
+
+ ); +} + +function makeVehicleOrders( + certPrefix: string, + fleetCategory: FleetCategory, + mode: 'verified' | 'unverified' | 'partial', + totalKg: number, +) { + const isOwn = fleetCategory === 'own'; + const unitPrice = 4.5; + const list = [ + { time: '2026-08-08 09:15:00', factor: 1.2, source: 'api' as const }, + { time: '2026-08-08 14:30:00', factor: 0.9, source: 'api' as const }, + { time: '2026-08-07 11:20:00', factor: 1.1, source: 'station_report' as const }, + { time: '2026-08-06 16:45:00', factor: 0.8, source: 'lingniu_report' as const }, + { time: '2026-08-05 10:10:00', factor: 1.05, source: 'api' as const }, + { time: '2026-08-04 15:25:00', factor: 0.95, source: 'station_report' as const }, + { time: '2026-08-03 08:50:00', factor: 1.15, source: 'api' as const }, + { time: '2026-08-02 17:05:00', factor: 0.85, source: 'lingniu_report' as const }, + { time: '2026-08-01 12:40:00', factor: 1.0, source: 'station_report' as const }, + { time: '2026-07-31 09:30:00', factor: 0.9, source: 'api' as const }, + ]; + + return list.map((item, idx) => { + const seq = String(idx + 1).padStart(2, '0'); + const kg = Math.round(((totalKg / 100) * item.factor) * 10) / 10; + const certNo = + item.source === 'api' + ? `API-20260808-${certPrefix}-${seq}` + : item.source === 'station_report' + ? `ST-20260808-${certPrefix}-${seq}` + : `LN-20260808-${certPrefix}-${seq}`; + + let verifyStatus: 'verified' | 'unverified' | null = null; + if (isOwn) { + if (mode === 'verified') verifyStatus = 'verified'; + else if (mode === 'unverified') verifyStatus = 'unverified'; + else { + verifyStatus = idx % 2 === 0 ? 'verified' : 'unverified'; + } + } + + return { + orderId: `ORD-20260808-${certPrefix}-${seq}`, + time: item.time, + kg, + unitPrice, + amount: Math.round(kg * unitPrice), + source: item.source, + certNo, + verifyStatus, + }; + }); +} + +function computeVehicleVerifyStatus( + orders: { verifyStatus?: 'verified' | 'unverified' | null }[], + fleetCategory: FleetCategory, +): 'verified' | 'unverified' | 'partial' | null { + if (fleetCategory !== 'own') return null; + if (!orders || orders.length === 0) return 'unverified'; + const verifiedCount = orders.filter((o) => o.verifyStatus === 'verified').length; + const unverifiedCount = orders.filter((o) => o.verifyStatus === 'unverified').length; + if (verifiedCount > 0 && unverifiedCount > 0) return 'partial'; + if (verifiedCount > 0 && unverifiedCount === 0) return 'verified'; + return 'unverified'; +} + +/** 站/客户层:按下属车辆核对态汇总。全已核→已核对;全未核→未核对;有混杂或任一带部分→部分核对。外部车不参与。 */ +function aggregateVehiclesVerifyStatus( + vehicles: { orders: { verifyStatus?: 'verified' | 'unverified' | null }[]; fleetCategory: FleetCategory; plateNo?: string }[], +): 'verified' | 'unverified' | 'partial' | null { + const statuses = vehicles + .map((vh) => computeVehicleVerifyStatus(vh.orders, vh.fleetCategory)) + .filter((s): s is 'verified' | 'unverified' | 'partial' => s !== null); + if (statuses.length === 0) return null; + if (statuses.every((s) => s === 'verified')) return 'verified'; + if (statuses.every((s) => s === 'unverified')) return 'unverified'; + return 'partial'; +} + +/** 穿透表标签悬浮说明 */ +const FLEET_TAG_TIP = { + own: '羚牛车辆:车牌可识别,且归属羚牛自有/合作车队', + external: '外部车辆:非羚牛车队;无法识别车牌的归入「无车牌」并标外部车辆', +} as const; + +const SOURCE_TAG_TIP: Record<'api' | 'station_report' | 'lingniu_report', string> = { + api: 'API接入:加氢数据由接口自动归集,可按接口流水追溯', + station_report: '站点上报:由加氢站报送的加氢数据', + lingniu_report: '羚牛上报:从 OneOS 归集的加氢记录', +}; + +const VERIFY_TAG_TIP = { + verified: '已核对:范围内加氢订单均已完成核对', + partial: '部分核对:范围内既有已核对,也有未核对订单', + unverified: '未核对:范围内加氢订单均尚未核对', + order_verified: '已核对:该笔加氢订单已完成核对', + order_unverified: '未核对:该笔加氢订单尚未核对', + external_skip: '外部车辆不参与核对,故无核对状态', +} as const; + +function renderFleetTag(isOwnFleet: boolean) { + return ( + + {isOwnFleet ? '羚牛车辆' : '外部车辆'} + + ); +} + +function renderSourceTag(source: 'api' | 'station_report' | 'lingniu_report' | string) { + const key = source === 'api' || source === 'station_report' || source === 'lingniu_report' ? source : 'api'; + const mod = key === 'api' ? 'api' : key === 'station_report' ? 'station' : 'lingniu'; + return ( + + {SOURCE_TYPE_LABEL[key] || source} + + ); +} + +function renderOrderVerifyTag( + fleetCategory: FleetCategory, + verifyStatus: 'verified' | 'unverified' | null | undefined, +) { + if (fleetCategory !== 'own') { + return ( + + - + + ); + } + if (verifyStatus === 'verified') { + return ( + + 已核对 + + ); + } + return ( + + 未核对 + + ); +} + +function renderVehicleVerifyTag( + fleetCategory: FleetCategory, + status: 'verified' | 'unverified' | 'partial' | null, +) { + if (fleetCategory !== 'own' || status === null) { + return ( + + - + + ); + } + if (status === 'verified') { + return ( + + 已核对 + + ); + } + if (status === 'partial') { + return ( + + 部分核对 + + ); + } + return ( + + 未核对 + + ); +} + +function renderAggVerifyTag( + status: 'verified' | 'unverified' | 'partial' | null, + titlePrefix = '下属车辆', +) { + if (status === 'verified') { + return ( + + 已核对 + + ); + } + if (status === 'partial') { + return ( + + 部分核对 + + ); + } + if (status === 'unverified') { + return ( + + 未核对 + + ); + } + return ( + + - + + ); +} + +type DrillPeriodMode = 'month' | 'custom'; + +function resolveDrillPeriod(mode: DrillPeriodMode, monthValue: string, customStart: string, customEnd: string) { + if (mode === 'custom') { + const start = customStart <= customEnd ? customStart : customEnd; + const end = customStart <= customEnd ? customEnd : customStart; + return { start, end, label: `${start} 至 ${end}` }; + } + + const [yearText, monthText] = monthValue.split('-'); + const lastDay = new Date(Number(yearText), Number(monthText), 0).getDate(); + const monthEnd = `${monthValue}-${String(lastDay).padStart(2, '0')}`; + const dataSnapshotEnd = '2026-08-08'; + const end = monthEnd > dataSnapshotEnd ? dataSnapshotEnd : monthEnd; + return { start: `${monthValue}-01`, end, label: `${monthValue}-01 至 ${end}` }; +} + +function StationMonthTable({ + rows, + activeId, + onOpen, +}: { + rows: ReturnType; + activeId: string | null; + onOpen: (id: string, label: string) => void; +}) { + if (!rows.length) return
本筛选下暂无站月发生额
; + return ( +
+ + + + + + + + + + + + + {rows.map((r, i) => ( + onOpen(r.stationId, r.stationName)} + > + + + + + + + + ))} + +
#加氢站月份发生额加氢量未核金额
{i + 1}{r.stationName}{r.month}{formatYuan(r.amount)}{formatKg(r.quantityKg)}{formatYuan(r.unverifiedAmount)}
+
+ ); +} + +function CustomerAttrTable({ + rows, + activeId, + onOpen, +}: { + rows: ReturnType; + activeId: string | null; + onOpen: (id: string, label: string) => void; +}) { + if (!rows.length) return
本筛选下暂无客户归属
; + return ( +
+ + + + + + + + + + + + + {rows.map((r, i) => ( + onOpen(r.customerId, r.customerName)} + > + + + + + + + + ))} + +
#客户承担方加氢量我司成本未核
{i + 1}{r.customerName}{r.borneLabel}{formatKg(r.quantityKg)}{formatYuan(r.companyCost)}{formatYuan(r.unverifiedAmount)}
+
+ ); +} + +function OrderTable({ rows }: { rows: H2OrderRow[] }) { + if (!rows.length) { + return
本筛选下暂无我司成本明细
; + } + return ( +
+ + + + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + + + + ))} + +
时间加氢站车牌客户加氢量金额成本维度核对来源
{r.occurredAt}{r.stationName}{r.plateNo}{r.customerName}{formatKg(r.quantityKg)}{formatYuan(r.amount)}{costDimLabel(r)} + + {r.verifyStatus === 'verified' ? '已核对' : '未核对'} + + {SOURCE_LABEL[r.source]}
+
+ ); +} + +interface BiCustomDatePickerProps { + label: string; + value: string; // YYYY-MM-DD | YYYY-MM | YYYY + onChange: (dateStr: string) => void; +} + +function BiCustomDatePicker({ label, value, onChange }: BiCustomDatePickerProps) { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + // 面板视图模式:'day' | 'month' | 'year' + const [pickerMode, setPickerMode] = useState<'day' | 'month' | 'year'>('day'); + + const parsedDate = useMemo(() => { + const parts = value.split('-'); + const year = parseInt(parts[0], 10) || 2026; + const month = parseInt(parts[1], 10) || 8; + const day = parseInt(parts[2], 10) || 1; + return { year, month, day }; + }, [value]); + + const [viewYear, setViewYear] = useState(parsedDate.year); + const [viewMonth, setViewMonth] = useState(parsedDate.month); + + useEffect(() => { + if (isOpen) { + setViewYear(parsedDate.year); + setViewMonth(parsedDate.month); + const parts = value.split('-'); + if (parts.length === 1 && value.length === 4) { + setPickerMode('year'); + } else if (parts.length === 2) { + setPickerMode('month'); + } else { + setPickerMode('day'); + } + } + }, [isOpen, value, parsedDate]); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + } + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const yearsList = [2026, 2025, 2024, 2023, 2022, 2021, 2020]; + const monthsList = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + + const handleSelectYear = (y: number, e: React.MouseEvent) => { + e.stopPropagation(); + setViewYear(y); + if (pickerMode === 'year') { + onChange(`${y}`); + setIsOpen(false); + } else { + setPickerMode(pickerMode === 'day' ? 'month' : 'day'); + } + }; + + const handleSelectMonth = (m: number, e: React.MouseEvent) => { + e.stopPropagation(); + setViewMonth(m); + const mm = m < 10 ? `0${m}` : `${m}`; + if (pickerMode === 'month') { + onChange(`${viewYear}-${mm}`); + setIsOpen(false); + } else { + setPickerMode('day'); + } + }; + + const handleSelectDay = (d: number, e: React.MouseEvent) => { + e.stopPropagation(); + const mm = viewMonth < 10 ? `0${viewMonth}` : `${viewMonth}`; + const dd = d < 10 ? `0${d}` : `${d}`; + onChange(`${viewYear}-${mm}-${dd}`); + setIsOpen(false); + }; + + const handlePrev = (e: React.MouseEvent) => { + e.stopPropagation(); + if (pickerMode === 'day') { + if (viewMonth === 1) { + setViewYear((prev) => prev - 1); + setViewMonth(12); + } else { + setViewMonth((prev) => prev - 1); + } + } else { + setViewYear((prev) => prev - 1); + } + }; + + const handleNext = (e: React.MouseEvent) => { + e.stopPropagation(); + if (pickerMode === 'day') { + if (viewMonth === 12) { + setViewYear((prev) => prev + 1); + setViewMonth(1); + } else { + setViewMonth((prev) => prev + 1); + } + } else { + setViewYear((prev) => prev + 1); + } + }; + + const daysInMonth = new Date(viewYear, viewMonth, 0).getDate(); + const firstDayWeek = new Date(viewYear, viewMonth - 1, 1).getDay(); + const daysArray = Array.from({ length: daysInMonth }, (_, i) => i + 1); + const emptyPrefixSlots = Array.from({ length: firstDayWeek }, (_, i) => i); + + return ( +
+
setIsOpen(!isOpen)} + > + {label} + {value} + +
+ + {isOpen && ( +
+ {/* 1. 粒度模式选择器: 按日 | 按月 | 按年 */} +
+ + + +
+ + {/* 2. 标头快速切年月 */} +
+ +
+ + {pickerMode === 'day' && ( + + )} +
+ +
+ + {/* 3. 日视图 */} + {pickerMode === 'day' && ( + <> +
+ + + + + + + +
+ +
+ {emptyPrefixSlots.map((s) => ( + + ))} + {daysArray.map((d) => { + const mm = viewMonth < 10 ? `0${viewMonth}` : `${viewMonth}`; + const dd = d < 10 ? `0${d}` : `${d}`; + const isSelected = value === `${viewYear}-${mm}-${dd}`; + + return ( + + ); + })} +
+ + )} + + {/* 4. 月视图 */} + {pickerMode === 'month' && ( +
+ {monthsList.map((m) => { + const mm = m < 10 ? `0${m}` : `${m}`; + const isSelected = value === `${viewYear}-${mm}` || (value.split('-').length === 3 && parsedDate.year === viewYear && parsedDate.month === m); + + return ( + + ); + })} +
+ )} + + {/* 5. 年视图 */} + {pickerMode === 'year' && ( +
+ {yearsList.map((y) => { + const isSelected = value === `${y}` || parsedDate.year === y; + + return ( + + ); + })} +
+ )} +
+ )} +
+ ); +} + +interface HostDailyViewProps { + updatedAt?: string; + onRefresh?: () => void; + startDate: string; + endDate: string; + onStartDateChange: (val: string) => void; + onEndDateChange: (val: string) => void; + rangePreset: DailyRangePreset; + onRangePresetChange: (preset: DailyRangePreset) => void; + fleetType: FleetCategoryFilter; + onFleetTypeChange: (fleet: FleetCategoryFilter) => void; +} + +function HostDailyView({ + updatedAt, + onRefresh, + startDate, + endDate, + onStartDateChange, + onEndDateChange, + rangePreset, + onRangePresetChange, + fleetType, + onFleetTypeChange, +}: HostDailyViewProps) { + const [remoteDaily, setRemoteDaily] = useState(null); + const [remoteAllDaily, setRemoteAllDaily] = useState(null); + const [remotePreviousTotal, setRemotePreviousTotal] = useState(null); + const [remoteTrees, setRemoteTrees] = useState>({}); + const [remoteDailyError, setRemoteDailyError] = useState(null); + + // 上方时间预设连动 KPI 卡片标题 + const kpiRangeTitle = useMemo(() => { + if (rangePreset === 'week') return '本周加氢量'; + if (rangePreset === 'month') return '本月加氢量'; + if (rangePreset === '15days') return '近 15 天加氢量'; + return '自定义区间加氢量'; + }, [rangePreset]); + + // 日期归一化转换(兼容手选 年 YYYY、月 YYYY-MM、日 YYYY-MM-DD) + const normalizeDateStr = (dateStr: string, isEnd: boolean) => { + if (!dateStr) return isEnd ? '9999-12-31' : '0000-01-01'; + const parts = dateStr.split('-'); + if (parts.length === 1) { + return isEnd ? `${parts[0]}-12-31` : `${parts[0]}-01-01`; + } + if (parts.length === 2) { + const y = parseInt(parts[0], 10); + const m = parseInt(parts[1], 10); + if (isEnd) { + const lastDay = new Date(y, m, 0).getDate(); + const dd = lastDay < 10 ? `0${lastDay}` : `${lastDay}`; + return `${parts[0]}-${parts[1]}-${dd}`; + } + return `${parts[0]}-${parts[1]}-01`; + } + return dateStr; + }; + + const normStart = useMemo(() => normalizeDateStr(startDate, false), [startDate]); + const normEnd = useMemo(() => normalizeDateStr(endDate, true), [endDate]); + + useEffect(() => { + let active = true; + const vehicleScope = fleetType === 'own' ? 'lingniu' : fleetType; + const currentStart = new Date(`${normStart}T00:00:00`); + const currentEnd = new Date(`${normEnd}T00:00:00`); + const rangeDays = Math.max(1, Math.round((currentEnd.getTime() - currentStart.getTime()) / 86400000) + 1); + const previousEnd = new Date(currentStart); + previousEnd.setDate(previousEnd.getDate() - 1); + const previousStart = new Date(currentStart); + previousStart.setDate(previousStart.getDate() - rangeDays); + const toIso = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`; + const base = { year: Number(normEnd.slice(0, 4)), startDate: normStart, endDate: normEnd, vehicleScope, verifyScope: 'all' as const }; + setRemoteDailyError(null); + Promise.all([ + fetchH2BiDaily(base), + vehicleScope === 'all' ? fetchH2BiDaily(base) : fetchH2BiDaily({ ...base, vehicleScope: 'all' }), + fetchH2BiDaily({ ...base, startDate: toIso(previousStart), endDate: toIso(previousEnd) }), + ]).then(([daily, allDaily, previous]) => { + if (!active) return; + setRemoteDaily(daily); + setRemoteAllDaily(allDaily); + setRemotePreviousTotal(Number(previous.kpis.totalKg) || 0); + }).catch((error) => { + if (!active) return; + setRemoteDailyError(error instanceof Error ? error.message : String(error)); + }); + return () => { active = false; }; + }, [normStart, normEnd, fleetType]); + + // 1. 根据 startDate & endDate 动态生成或提取指定日期范围内的全量每日加氢数据列表 + const dateFilteredList = useMemo(() => { + if (remoteDaily) { + return remoteDaily.days.map((item: any) => { + const tree = remoteTrees[item.date]; + const stations = tree?.stations?.map((station: any) => ({ + stationId: String(station.id), stationName: station.name, stationType: 'self_use', + unitPrice: station.kg ? station.cost / station.kg : 0, + quantityKg: Number(station.kg) || 0, amountYuan: Number(station.cost) || 0, + customers: station.customers.map((customer: any) => ({ + customerId: String(customer.id), customerName: customer.name, + customerCategory: 'internal', quantityKg: Number(customer.kg) || 0, + amountYuan: Number(customer.cost) || 0, vehicles: [], + })), + })) ?? []; + return { + date: item.date, shortDate: item.date.slice(5), + unitPrice: item.kg ? item.cost / item.kg : 0, + quantityKg: Number(item.kg) || 0, amountYuan: Number(item.cost) || 0, + momPct: item.chainPct === null || item.chainPct === undefined ? null : Number(item.chainPct), + stations, _stationCount: Number(item.stationCount) || 0, + _ownKg: Number(item.lingniuKg) || 0, _extKg: Number(item.externalKg) || 0, + }; + }); + } + return getDailyDataForRange(normStart, normEnd); + }, [normStart, normEnd, remoteDaily, remoteTrees]); + + // 2. 根据 fleetType 过滤出对应车辆归属下的加氢列表 ('all' 时包含内部与外部合并显示) + const filteredDailyList = useMemo(() => { + if (remoteDaily) return dateFilteredList; + return filterDailyDataByFleet(dateFilteredList, fleetType); + }, [dateFilteredList, fleetType, remoteDaily]); + + // 2. 动态计算关联的 KPI 及柱图统计数据 + const dailyKpis = useMemo(() => { + const calculated = calculateDailyKpis(filteredDailyList, fleetType); + if (!remoteDaily) return calculated; + const nonZero = filteredDailyList.filter((item: any) => item.quantityKg > 0); + const peak = nonZero.slice().sort((a: any, b: any) => b.quantityKg - a.quantityKg)[0]; + const trough = nonZero.slice().sort((a: any, b: any) => a.quantityKg - b.quantityKg)[0]; + return { + ...calculated, + totalQuantityKg: Number(remoteDaily.kpis.totalKg) || 0, + dailyAvgKgNum: Number(remoteDaily.kpis.averageDailyKg) || 0, + dailyAvgKg: `${Number(remoteDaily.kpis.averageDailyKg || 0).toLocaleString('zh-CN')} Kg`, + activeDays: `${Number(remoteDaily.kpis.activeDays) || 0} 天`, + stationCount: Number(remoteDaily.kpis.stationCount) || 0, + ownKg: filteredDailyList.reduce((sum: number, item: any) => sum + item._ownKg, 0), + extKg: filteredDailyList.reduce((sum: number, item: any) => sum + item._extKg, 0), + peakDayLabel: peak ? `${peak.shortDate} · ${Math.round(peak.quantityKg).toLocaleString('zh-CN')}` : '-', + troughDayLabel: trough ? `${trough.shortDate} · ${Math.round(trough.quantityKg).toLocaleString('zh-CN')}` : '-', + zeroDaysCount: Math.max(0, filteredDailyList.length - Number(remoteDaily.kpis.activeDays || 0)), + }; + }, [filteredDailyList, fleetType, remoteDaily]); + const [peakDate = '-', peakValue = '-'] = dailyKpis.peakDayLabel.split(' · '); + const [troughDate = '-', troughValue = '-'] = dailyKpis.troughDayLabel.split(' · '); + + const rangeFleetKpis = useMemo(() => { + if (remoteAllDaily) { + return { + ownKg: remoteAllDaily.days.reduce((sum: number, item: any) => sum + Number(item.lingniuKg || 0), 0), + extKg: remoteAllDaily.days.reduce((sum: number, item: any) => sum + Number(item.externalKg || 0), 0), + }; + } + return calculateDailyKpis(dateFilteredList, 'all'); + }, [dateFilteredList, remoteAllDaily]); + + const previousPeriod = useMemo(() => { + if (remotePreviousTotal !== null) { + const changeKg = Math.round((dailyKpis.totalQuantityKg - remotePreviousTotal) * 10) / 10; + const changePct = remotePreviousTotal > 0 ? Math.round(changeKg / remotePreviousTotal * 1000) / 10 : 0; + return { changeKg, changePct }; + } + const toDate = (value: string) => new Date(`${value}T00:00:00`); + const toIso = (value: Date) => { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; + const currentStart = toDate(normStart); + const currentEnd = toDate(normEnd); + const rangeDays = Math.max(1, Math.round((currentEnd.getTime() - currentStart.getTime()) / 86400000) + 1); + const previousEnd = new Date(currentStart); + previousEnd.setDate(previousEnd.getDate() - 1); + const previousStart = new Date(currentStart); + previousStart.setDate(previousStart.getDate() - rangeDays); + const previousItems = filterDailyDataByFleet( + getDailyDataForRange(toIso(previousStart), toIso(previousEnd)), + fleetType, + ); + const previousTotalKg = Math.round( + previousItems.reduce((sum, item) => sum + item.quantityKg, 0) * 10, + ) / 10; + const changeKg = Math.round((dailyKpis.totalQuantityKg - previousTotalKg) * 10) / 10; + const changePct = previousTotalKg > 0 ? Math.round((changeKg / previousTotalKg) * 1000) / 10 : 0; + return { changeKg, changePct }; + }, [dailyKpis.totalQuantityKg, fleetType, normEnd, normStart, remotePreviousTotal]); + + const totalNetworkStations = 65; + const stationCoveragePct = Math.round((dailyKpis.stationCount / totalNetworkStations) * 1000) / 10; + const fleetTotalKg = rangeFleetKpis.ownKg + rangeFleetKpis.extKg; + const ownFleetPct = fleetTotalKg > 0 ? Math.round((rangeFleetKpis.ownKg / fleetTotalKg) * 1000) / 10 : 0; + const externalFleetPct = fleetTotalKg > 0 ? Math.round((rangeFleetKpis.extKg / fleetTotalKg) * 1000) / 10 : 0; + + // 深层折叠/展开状态 + const [expandedDate, setExpandedDate] = useState('2026-08-08'); // 默认展开最新一天 + const [expandedStations, setExpandedStations] = useState>({ + '2026-08-08_st-jx': true, // 默认展开嘉兴站,演示效果 + }); + const [expandedCustomers, setExpandedCustomers] = useState>({ + '2026-08-08_st-jx_c-ln': true, // 默认展开羚牛客户,直观展示车辆与数据来源 + }); + + // 点击柱状图后的高亮锚点状态 + const [highlightedDate, setHighlightedDate] = useState(null); + + // 保证当前选中的展开日期始终在当前过滤数据集中 + useEffect(() => { + if (filteredDailyList.length > 0 && (!expandedDate || !filteredDailyList.some((d) => d.date === expandedDate))) { + setExpandedDate(filteredDailyList[0].date); + } + }, [filteredDailyList, expandedDate]); + + useEffect(() => { + if (!remoteDaily || !expandedDate || remoteTrees[expandedDate]) return; + const vehicleScope = fleetType === 'own' ? 'lingniu' : fleetType; + fetchH2BiDailyTree(expandedDate, { vehicleScope, verifyScope: 'all', stationId: null }) + .then((tree) => setRemoteTrees((current) => ({ ...current, [expandedDate]: tree }))) + .catch((error) => setRemoteDailyError(error instanceof Error ? error.message : String(error))); + }, [expandedDate, fleetType, remoteDaily, remoteTrees]); + + const maxQty = useMemo(() => { + if (!filteredDailyList.length) return 4000; + return Math.max(...filteredDailyList.map((d) => d.quantityKg), 3000); + }, [filteredDailyList]); + + const totalSum = useMemo(() => { + const sum = filteredDailyList.reduce((acc, item) => acc + item.quantityKg, 0); + return sum.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + }, [filteredDailyList]); + + /** 点击柱状图上的柱子:展开该日、锚点平滑滚动并高亮 */ + const handleBarClick = (date: string) => { + setExpandedDate(date); + setHighlightedDate(date); + + setTimeout(() => { + const el = document.getElementById(`daily-row-${date}`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 60); + + setTimeout(() => { + setHighlightedDate((prev) => (prev === date ? null : prev)); + }, 2000); + }; + + const toggleStation = (dateKey: string, stationId: string, e: React.MouseEvent) => { + e.stopPropagation(); + const key = `${dateKey}_${stationId}`; + setExpandedStations((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + const toggleCustomer = (dateKey: string, stationId: string, customerId: string, e: React.MouseEvent) => { + e.stopPropagation(); + const key = `${dateKey}_${stationId}_${customerId}`; + setExpandedCustomers((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + /** 导出按日加氢数据明细为 Excel (.xlsx) */ + const handleExportExcel = () => { + const aoa: (string | number)[][] = [ + ['日期', '加氢站名称', '加氢站类型', '客户名称', '客户属性', '加氢时间', '车牌号', '车辆归属', '数据来源', '核对状态', '单价(元/Kg)', '加氢量(Kg)', '加氢金额(元)', '预充值余额'], + ]; + + filteredDailyList.forEach((d) => { + d.stations.forEach((st) => { + const stationPrecharge = st.prechargeBalance ?? (st.stationId.includes('jx') ? 128500 : st.stationId.includes('tx') ? 86200 : 45000); + st.customers?.forEach((cust) => { + const isInternalCust = cust.customerCategory === 'internal' || cust.customerId === 'c-ln'; + cust.vehicles?.forEach((vh) => { + aoa.push([ + d.date, + st.stationName, + STATION_TYPE_LABEL[st.stationType] || st.stationType, + cust.customerName, + isInternalCust ? '羚牛车辆' : '外部车辆', + vh.time, + vh.plateNo || '无车牌(散车)', + vh.fleetCategory === 'own' ? '羚牛车辆' : '外部车辆', + SOURCE_TYPE_LABEL[vh.source] || vh.source, + vh.fleetCategory === 'own' ? (DAILY_VERIFY_LABEL[vh.verifyStatus || 'unverified'] || '未核对') : '-', + vh.unitPrice, + vh.quantityKg, + vh.amountYuan, + `¥${stationPrecharge.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} (对接站点管理)`, + ]); + }); + }); + }); + }); + + const fileDateStr = `${startDate.replace(/[/]/g, '')}-${endDate.replace(/[/]/g, '')}`; + const fleetName = fleetType === 'all' ? '全部车辆' : fleetType === 'own' ? '羚牛车辆' : '外部车辆'; + exportAoaSheet(aoa, `每日加氢数据明细_${fleetName}_${fileDateStr}.xlsx`, '每日加氢明细'); + }; + + return ( +
+ {remoteDailyError ?
按日统计加载失败:{remoteDailyError}
: null} + {/* 1. 顶栏时间/范围筛选器 */} +
+
+
+
+ + + + +
+ + { + onStartDateChange(val); + onRangePresetChange('custom'); + }} + /> + { + onEndDateChange(val); + onRangePresetChange('custom'); + }} + /> +
+ +
+
+ + + +
+ + {updatedAt && ( + + {updatedAt} + + )} + + +
+
+
+ 区间车辆构成 + 羚牛车辆 {rangeFleetKpis.ownKg.toLocaleString('zh-CN')} Kg({ownFleetPct}%) + · + 外部车辆 {rangeFleetKpis.extKg.toLocaleString('zh-CN')} Kg({externalFleetPct}%) +
+
+ + {/* 2. 4卡 Bento KPI */} +
+
+
+ {kpiRangeTitle} + + + +
+
+ {dailyKpis.totalQuantityKg.toLocaleString('zh-CN')} + Kg +
+
{dailyKpis.dateRange}
+
+ +
+
+ 日均加氢量 + + + +
+
+ {dailyKpis.dailyAvgKgNum.toLocaleString('zh-CN')} + Kg +
+
{dailyKpis.activeDays}有加氢记录
+
+ +
+
+ 较上一周期 + = 0 ? 'is-green' : 'is-amber'}`}> + + +
+
+ = 0 ? 'ehb-value-up' : 'ehb-value-down'}`}> + {previousPeriod.changePct >= 0 ? '+' : ''}{previousPeriod.changePct}% + +
+
+ {previousPeriod.changeKg >= 0 ? '增加' : '减少'} {Math.abs(previousPeriod.changeKg).toLocaleString('zh-CN')} Kg +
+
+ +
+
+ 活跃加氢站 + + + +
+
+ {dailyKpis.stationCount} / {totalNetworkStations} + +
+
覆盖率 {stationCoveragePct}% · 有加氢记录
+
+
+ + {/* 3. 每日加氢量堆积柱状图(分别显示羚牛车辆与外部车辆加氢量,点击柱子下锚定位) */} +
+
+
+ 每日加氢量 + (点击柱体下锚定位到对应日期明细) + (点击柱体定位) +
+
+
+ + + 羚牛车辆羚牛车辆 + + + + 外部车辆外部车辆 + +
+ 时间单位:日 · 单位 Kg +
+
+ +
+
+ 峰值 + {peakDate} + {peakValue} Kg +
+
+ 低谷 + {troughDate} + {troughValue} Kg +
+
+ 零记录 + 统计区间 + {dailyKpis.zeroDaysCount} +
+
+ +
‹ 左右滑动查看每日加氢趋势 ›
+ +
+
0 ? Math.min(92, Math.round((dailyKpis.dailyAvgKgNum / maxQty) * 100)) : 50}%`, + }} + > + 均值 {dailyKpis.dailyAvgKg} +
+ + {[...filteredDailyList].reverse().map((item) => { + // 计算当天羚牛车辆加氢量与外部车辆加氢量 + let dayOwnKg = 0; + let dayExtKg = 0; + item.stations.forEach((st) => { + st.customers.forEach((cust) => { + cust.vehicles.forEach((vh) => { + if (vh.fleetCategory === 'own') { + dayOwnKg += vh.quantityKg; + } else { + dayExtKg += vh.quantityKg; + } + }); + }); + }); + if (remoteDaily) { + dayOwnKg = Number(item._ownKg) || 0; + dayExtKg = Number(item._extKg) || 0; + } + dayOwnKg = Math.round(dayOwnKg * 10) / 10; + dayExtKg = Math.round(dayExtKg * 10) / 10; + + const totalKg = item.quantityKg > 0 ? item.quantityKg : 1; + const pct = Math.min(100, Math.round((item.quantityKg / maxQty) * 100)); + const ownRatio = Math.round((dayOwnKg / totalKg) * 100); + const extRatio = Math.max(0, 100 - ownRatio); + + const isBarActive = expandedDate === item.date; + + const tooltipText = `${item.date} 加氢总量 ${Math.round(item.quantityKg).toLocaleString('zh-CN')} Kg\n├─ 羚牛车辆: ${Math.round(dayOwnKg).toLocaleString('zh-CN')} Kg (${ownRatio}%)\n└─ 外部车辆: ${Math.round(dayExtKg).toLocaleString('zh-CN')} Kg (${extRatio}%)\n(点击下锚定位到该日明细)`; + + return ( +
handleBarClick(item.date)} + > +
+ {Math.round(item.quantityKg)} +
+ + {/* 堆积柱体:上部外部车辆,下部羚牛车辆 */} +
+ {dayExtKg > 0 && ( +
+ )} + {dayOwnKg > 0 && ( +
+ )} +
+ +
+ {item.shortDate} +
+
+ ); + })} +
+
+ + {/* 4. 每日数据明细多层钻取表格 */} +
+
+
+ 每日加氢数据明细 + (可多层下钻:按日 → 加氢站 → 客户 → 车辆及数据源) + (可逐级下钻) +
+ + +
+ +
+ + + + + + + + + + + + {/* 合计行 */} + + + + + + + + + {filteredDailyList.map((row) => { + const isDateExpanded = expandedDate === row.date; + const isHighlighted = highlightedDate === row.date; + + return ( + + {/* Level 1: 日期行 */} + setExpandedDate(isDateExpanded ? null : row.date)} + > + + + + + + + + {/* Level 2: 加氢站层 */} + {isDateExpanded && + row.stations.map((st) => { + const stKey = `${row.date}_${st.stationId}`; + const isStExpanded = !!expandedStations[stKey]; + const stPrecharge = st.prechargeBalance ?? (st.stationId.includes('jx') ? 128500 : st.stationId.includes('tx') ? 86200 : 45000); + + return ( + + toggleStation(row.date, st.stationId, e)} + > + + + + + + + + {/* Level 3: 客户层 */} + {isStExpanded && + st.customers?.map((cust) => { + const custKey = `${row.date}_${st.stationId}_${cust.customerId}`; + const isCustExpanded = !!expandedCustomers[custKey]; + const isInternalCust = cust.customerCategory === 'internal' || cust.customerId === 'c-ln'; + + return ( + + toggleCustomer(row.date, st.stationId, cust.customerId, e)} + > + + + + + + + + {/* Level 4: 车辆及数据来源明细层 */} + {isCustExpanded && + cust.vehicles?.map((vh) => ( + + + + + + + + ))} + + ); + })} + + ); + })} + + ); + })} + +
日期 / 加氢站 / 客户 / 车辆明细单价 (元/Kg)加氢量 (Kg)金额 (元) / 环比预充值余额
+ 合计 + {totalSum} + 对接站点管理 +
+ + {(row._stationCount ?? row.stations.length) > 0 ? (isDateExpanded ? '▼' : '►') : '•'} + + {row.date} + + ({row._stationCount ?? row.stations.length} 个加氢站) + + {row.unitPrice.toFixed(2)} + {row.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} + + {row.momPct !== null ? ( + + {row.momPct > 0 ? `+${row.momPct.toFixed(1)}%` : `${row.momPct.toFixed(1)}%`} + + ) : ( + '-' + )} + -
+ + {st.customers?.length ? (isStExpanded ? '▼' : '►') : '•'} + + └ {st.stationName} + + {st.unitPrice.toFixed(2)} + + {st.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} + + ¥{st.amountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} + + ¥{stPrecharge.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} +
+
+ + + {cust.vehicles?.length ? (isCustExpanded ? '▼' : '►') : '•'} + + └─ 客户:{cust.customerName} + + {isInternalCust ? ( + + 羚牛车辆 + {cust.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} Kg + ¥{cust.amountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} + + ) : ( + + 外部车辆 + {cust.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} Kg + ¥{cust.amountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} + + )} +
+
- + {cust.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} + + ¥{cust.amountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} + -
+ └── + + {vh.plateNo || '无车牌(散车)'} + {vh.time.slice(0, 5)} + + + {renderFleetTag(vh.fleetCategory === 'own')} + + + {renderSourceTag(vh.source)} + + {/* 规则:仅内部车辆(羚牛车辆)展示核对状态;外部车辆不参与核对 */} + {vh.fleetCategory === 'own' && vh.verifyStatus && ( + {renderOrderVerifyTag(vh.fleetCategory, vh.verifyStatus)} + )} + + {vh.unitPrice.toFixed(2)} + + {vh.quantityKg.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} + + ¥{vh.amountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} + -
+
+
+
+ ); +} diff --git a/src/modules/energy/hydrogen/board/data/aggregates.ts b/src/modules/energy/hydrogen/board/data/aggregates.ts new file mode 100644 index 0000000..346c8a4 --- /dev/null +++ b/src/modules/energy/hydrogen/board/data/aggregates.ts @@ -0,0 +1,287 @@ +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(); + 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(); + 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 = { + 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, + }; +} diff --git a/src/modules/energy/hydrogen/board/data/mockBoard.ts b/src/modules/energy/hydrogen/board/data/mockBoard.ts new file mode 100644 index 0000000..9716523 --- /dev/null +++ b/src/modules/energy/hydrogen/board/data/mockBoard.ts @@ -0,0 +1,191 @@ +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; diff --git a/src/modules/energy/hydrogen/board/data/mockDaily.ts b/src/modules/energy/hydrogen/board/data/mockDaily.ts new file mode 100644 index 0000000..7efe409 --- /dev/null +++ b/src/modules/energy/hydrogen/board/data/mockDaily.ts @@ -0,0 +1,1044 @@ +/** 宿主按日视图 (Daily View) 模拟数据 - 包含加氢站、客户、车辆及数据来源穿透明细 + * + * 外部车牌主数据对齐:`src/common/energy-h2-external-fleet` 种子 + * (粤B12345D / 粤A99888D / 粤E88111D / 粤B99111D / 粤B33888D / 粤E99999D)。 + * 分流规则:own → 车辆氢费明细;external → 仅 BI(见 resolveFleetRoute)。 + */ + +export type StationType = 'self_use' | 'external_sale'; // 自用消费 | 对外销售 +export type SourceType = 'api' | 'station_report' | 'lingniu_report'; // API接入 | 站点上报 | 羚牛上报 +export type FleetCategory = 'own' | 'external'; // 羚牛车辆 | 外部车辆 +export type FleetCategoryFilter = 'all' | 'own' | 'external'; // 全量合并 | 羚牛车辆 | 外部车辆 +export type CustomerCategory = 'internal' | 'external'; // 内部客户 | 外部客户 +export type DailyVerifyStatus = 'verified' | 'unverified'; // 已核对 | 未核对 + +export const SOURCE_TYPE_LABEL: Record = { + api: 'API接入', + station_report: '站点上报', + lingniu_report: '羚牛上报', +}; + +export const STATION_TYPE_LABEL: Record = { + self_use: '自用消费', + external_sale: '对外销售', +}; + +export const CUSTOMER_CATEGORY_LABEL: Record = { + internal: '内部客户', + external: '外部客户', +}; + +export const DAILY_VERIFY_LABEL: Record = { + verified: '已核对', + unverified: '未核对', +}; + +export interface VehicleRefDetail { + id: string; + time: string; + plateNo: string | null; // 车牌号(内部车必有,外部车可能无) + fleetCategory: FleetCategory; // 羚牛车辆(内部) | 外部车辆 + quantityKg: number; + unitPrice: number; + amountYuan: number; + source: SourceType; // 内部: API接入/站点上报/羚牛上报;外部: 仅API接入/站点上报 + verifyStatus?: DailyVerifyStatus | null; // 内部车辆必有(已核对/未核对);外部车辆无核对状态 +} + +export interface CustomerDetail { + customerId: string; + customerName: string; + customerCategory?: CustomerCategory; // 内部客户 | 外部客户 + quantityKg: number; + amountYuan: number; + vehicles: VehicleRefDetail[]; +} + +export interface DailyStationDetail { + stationId: string; + stationName: string; + stationType: StationType; // 自用消费 | 对外销售 + unitPrice: number; + quantityKg: number; + amountYuan: number; + prechargeBalance?: number; // 对接站点管理预充值余额 + customers: CustomerDetail[]; +} + +export interface DailyItem { + date: string; // YYYY-MM-DD + shortDate: string; // MM-DD + unitPrice: number; + quantityKg: number; + amountYuan: number; + momPct: number | null; // 环比 + stations: DailyStationDetail[]; +} + +export const MOCK_DAILY_15DAYS: DailyItem[] = [ + { + date: '2026-08-08', + shortDate: '08-08', + unitPrice: 30, + quantityKg: 3139.12, + amountYuan: 94173.6, + momPct: 1626.9, + stations: [ + { + stationId: 'st-jx', + stationName: '嘉兴中石化滨海加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 1800.0, + amountYuan: 54000.0, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 1100.0, + amountYuan: 33000.0, + vehicles: [ + { id: 'v-101', time: '08:15:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 45.2, unitPrice: 30, amountYuan: 1356, source: 'api', verifyStatus: 'verified' }, + { id: 'v-102', time: '09:30:00', plateNo: '浙A66666F', fleetCategory: 'own', quantityKg: 54.8, unitPrice: 30, amountYuan: 1644, source: 'lingniu_report', verifyStatus: 'verified' }, + { id: 'v-103', time: '11:20:00', plateNo: '浙A33333F', fleetCategory: 'own', quantityKg: 40.0, unitPrice: 30, amountYuan: 1200, source: 'station_report', verifyStatus: 'unverified' }, + { id: 'v-104', time: '14:00:00', plateNo: '浙A99999F', fleetCategory: 'own', quantityKg: 960.0, unitPrice: 30, amountYuan: 28800, source: 'api', verifyStatus: 'verified' }, + ], + }, + { + customerId: 'c-qd', + customerName: '广东氢动力科技服务有限公司', + quantityKg: 700.0, + amountYuan: 21000.0, + vehicles: [ + { id: 'v-201', time: '10:10:00', plateNo: '粤B12345D', fleetCategory: 'external', quantityKg: 350.0, unitPrice: 30, amountYuan: 10500, source: 'station_report', verifyStatus: null }, + { id: 'v-202', time: '15:45:00', plateNo: null, fleetCategory: 'external', quantityKg: 350.0, unitPrice: 30, amountYuan: 10500, source: 'api', verifyStatus: null }, + ], + }, + ], + }, + { + stationId: 'st-fs', + stationName: '佛山南海加氢站', + stationType: 'external_sale', + unitPrice: 38, + quantityKg: 1339.12, + amountYuan: 50886.56, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 500.0, + amountYuan: 19000.0, + vehicles: [ + { id: 'v-300', time: '06:50:00', plateNo: '粤B88666D', fleetCategory: 'own', quantityKg: 500.0, unitPrice: 38, amountYuan: 19000, source: 'api', verifyStatus: 'verified' }, + ], + }, + { + customerId: 'c-qy', + customerName: '广东清运科技有限公司', + quantityKg: 300.0, + amountYuan: 11400.0, + vehicles: [ + { id: 'v-301', time: '07:40:00', plateNo: '粤E88111D', fleetCategory: 'external', quantityKg: 300.0, unitPrice: 38, amountYuan: 11400, source: 'station_report', verifyStatus: null }, + ], + }, + { + customerId: 'c-dp', + customerName: '东展供应链(广州)有限公司', + quantityKg: 539.12, + amountYuan: 20486.56, + vehicles: [ + { id: 'v-401', time: '16:00:00', plateNo: '粤A99888D', fleetCategory: 'external', quantityKg: 539.12, unitPrice: 38, amountYuan: 20486.56, source: 'api', verifyStatus: null }, + ], + }, + ], + }, + ], + }, + { + date: '2026-08-07', + shortDate: '08-07', + unitPrice: 30, + quantityKg: 1181.78, + amountYuan: 35453.4, + momPct: -62.3, + stations: [ + { + stationId: 'st-jx', + stationName: '嘉兴中石化滨海加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 681.78, + amountYuan: 20453.4, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 181.78, + amountYuan: 5453.4, + vehicles: [ + { id: 'v-501', time: '09:00:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 81.78, unitPrice: 30, amountYuan: 2453.4, source: 'lingniu_report', verifyStatus: 'verified' }, + { id: 'v-502', time: '14:30:00', plateNo: '浙A66666F', fleetCategory: 'own', quantityKg: 100.0, unitPrice: 30, amountYuan: 3000.0, source: 'api', verifyStatus: 'verified' }, + ], + }, + { + customerId: 'c-qy', + customerName: '广东清运科技有限公司', + quantityKg: 500.0, + amountYuan: 15000.0, + vehicles: [ + { id: 'v-503', time: '16:10:00', plateNo: null, fleetCategory: 'external', quantityKg: 500.0, unitPrice: 30, amountYuan: 15000, source: 'station_report', verifyStatus: null }, + ], + }, + ], + }, + { + stationId: 'st-jj', + stationName: '嘉兴嘉锦加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 500.0, + amountYuan: 15000.0, + customers: [ + { + customerId: 'c-qd', + customerName: '广东氢动力科技服务有限公司', + quantityKg: 500.0, + amountYuan: 15000.0, + vehicles: [ + { id: 'v-504', time: '11:00:00', plateNo: '粤B99111D', fleetCategory: 'external', quantityKg: 500.0, unitPrice: 30, amountYuan: 15000, source: 'api', verifyStatus: null }, + ], + }, + ], + }, + ], + }, + { + date: '2026-08-06', + shortDate: '08-06', + unitPrice: 30, + quantityKg: 2250.0, + amountYuan: 67500.0, + momPct: 90.4, + stations: [ + { + stationId: 'st-jx', + stationName: '嘉兴中石化滨海加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 1250.0, + amountYuan: 37500.0, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 850.0, + amountYuan: 25500.0, + vehicles: [ + { id: 'v-601', time: '10:00:00', plateNo: '浙A55555F', fleetCategory: 'own', quantityKg: 450.0, unitPrice: 30, amountYuan: 13500, source: 'api', verifyStatus: 'verified' }, + { id: 'v-602', time: '16:00:00', plateNo: '浙A33333F', fleetCategory: 'own', quantityKg: 400.0, unitPrice: 30, amountYuan: 12000, source: 'lingniu_report', verifyStatus: 'unverified' }, + ], + }, + { + customerId: 'c-qd', + customerName: '广东氢动力科技服务有限公司', + quantityKg: 400.0, + amountYuan: 12000.0, + vehicles: [ + { id: 'v-603', time: '17:30:00', plateNo: '粤B33888D', fleetCategory: 'external', quantityKg: 400.0, unitPrice: 30, amountYuan: 12000, source: 'station_report', verifyStatus: null }, + ], + }, + ], + }, + { + stationId: 'st-dp', + stationName: '东鹏加氢站(对外)', + stationType: 'external_sale', + unitPrice: 38, + quantityKg: 1000.0, + amountYuan: 38000.0, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 300.0, + amountYuan: 11400.0, + vehicles: [ + { id: 'v-700', time: '09:10:00', plateNo: '浙A11222F', fleetCategory: 'own', quantityKg: 300.0, unitPrice: 38, amountYuan: 11400, source: 'api', verifyStatus: 'verified' }, + ], + }, + { + customerId: 'c-dp', + customerName: '东展供应链(广州)有限公司', + quantityKg: 700.0, + amountYuan: 26600.0, + vehicles: [ + { id: 'v-701', time: '11:30:00', plateNo: null, fleetCategory: 'external', quantityKg: 700.0, unitPrice: 38, amountYuan: 26600, source: 'station_report', verifyStatus: null }, + ], + }, + ], + }, + ], + }, + { + date: '2026-08-05', + shortDate: '08-05', + unitPrice: 30, + quantityKg: 2720.0, + amountYuan: 81600.0, + momPct: 20.9, + stations: [ + { + stationId: 'st-jj', + stationName: '嘉兴嘉锦加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 2720.0, + amountYuan: 81600.0, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 1720.0, + amountYuan: 51600.0, + vehicles: [ + { id: 'v-801', time: '08:00:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 860.0, unitPrice: 30, amountYuan: 25800, source: 'api', verifyStatus: 'verified' }, + { id: 'v-802', time: '15:20:00', plateNo: '浙A99999F', fleetCategory: 'own', quantityKg: 860.0, unitPrice: 30, amountYuan: 25800, source: 'lingniu_report', verifyStatus: 'verified' }, + ], + }, + { + customerId: 'c-qy', + customerName: '广东清运科技有限公司', + quantityKg: 1000.0, + amountYuan: 30000.0, + vehicles: [ + { id: 'v-803', time: '18:00:00', plateNo: '粤E99999D', fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'api', verifyStatus: null }, + ], + }, + ], + }, + ], + }, + { + date: '2026-08-04', + shortDate: '08-04', + unitPrice: 30, + quantityKg: 3150.0, + amountYuan: 94500.0, + momPct: 1.6, + stations: [ + { + stationId: 'st-jx', + stationName: '嘉兴中石化滨海加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 3150.0, + amountYuan: 94500.0, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 1000.0, + amountYuan: 30000.0, + vehicles: [ + { id: 'v-900', time: '07:30:00', plateNo: '浙A77777F', fleetCategory: 'own', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'api', verifyStatus: 'verified' }, + ], + }, + { + customerId: 'c-qd', + customerName: '广东氢动力科技服务有限公司', + quantityKg: 2150.0, + amountYuan: 64500.0, + vehicles: [ + { id: 'v-901', time: '09:10:00', plateNo: '粤B88888D', fleetCategory: 'external', quantityKg: 1075.0, unitPrice: 30, amountYuan: 32250, source: 'station_report', verifyStatus: null }, + { id: 'v-902', time: '16:40:00', plateNo: '粤B99999D', fleetCategory: 'external', quantityKg: 1075.0, unitPrice: 30, amountYuan: 32250, source: 'api', verifyStatus: null }, + ], + }, + ], + }, + ], + }, + { + date: '2026-08-03', + shortDate: '08-03', + unitPrice: 30, + quantityKg: 3100.0, + amountYuan: 93000.0, + momPct: 5.1, + stations: [ + { + stationId: 'st-sh', + stationName: '上海金山加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 3100.0, + amountYuan: 93000.0, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 2100.0, + amountYuan: 63000.0, + vehicles: [ + { id: 'v-1001', time: '10:30:00', plateNo: '沪A88111F', fleetCategory: 'own', quantityKg: 2100.0, unitPrice: 30, amountYuan: 63000, source: 'api', verifyStatus: 'verified' }, + ], + }, + { + customerId: 'c-dp', + customerName: '东展供应链(广州)有限公司', + quantityKg: 1000.0, + amountYuan: 30000.0, + vehicles: [ + { id: 'v-1002', time: '15:10:00', plateNo: null, fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'station_report', verifyStatus: null }, + ], + }, + ], + }, + ], + }, + { + date: '2026-08-02', + shortDate: '08-02', + unitPrice: 30, + quantityKg: 2950.0, + amountYuan: 88500.0, + momPct: -18.5, + stations: [ + { + stationId: 'st-jx', + stationName: '嘉兴中石化滨海加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 2950.0, + amountYuan: 88500.0, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 2000.0, + amountYuan: 60000.0, + vehicles: [ + { id: 'v-1101', time: '08:45:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'lingniu_report', verifyStatus: 'verified' }, + { id: 'v-1102', time: '14:15:00', plateNo: '浙A66666F', fleetCategory: 'own', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'api', verifyStatus: 'verified' }, + ], + }, + { + customerId: 'c-qy', + customerName: '广东清运科技有限公司', + quantityKg: 950.0, + amountYuan: 28500.0, + vehicles: [ + { id: 'v-1103', time: '17:00:00', plateNo: null, fleetCategory: 'external', quantityKg: 950.0, unitPrice: 30, amountYuan: 28500, source: 'station_report', verifyStatus: null }, + ], + }, + ], + }, + ], + }, + { + date: '2026-08-01', + shortDate: '08-01', + unitPrice: 30, + quantityKg: 3620.0, + amountYuan: 108600.0, + momPct: 1.1, + stations: [ + { + stationId: 'st-jx', + stationName: '嘉兴中石化滨海加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 2000.0, + amountYuan: 60000.0, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 2000.0, + amountYuan: 60000.0, + vehicles: [ + { id: 'v-1201', time: '09:00:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2000.0, unitPrice: 30, amountYuan: 60000, source: 'api', verifyStatus: 'verified' }, + ], + }, + ], + }, + { + stationId: 'st-jj', + stationName: '嘉兴嘉锦加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 1620.0, + amountYuan: 48600.0, + customers: [ + { + customerId: 'c-qd', + customerName: '广东氢动力科技服务有限公司', + quantityKg: 1620.0, + amountYuan: 48600.0, + vehicles: [ + { id: 'v-1202', time: '11:20:00', plateNo: '粤B33333D', fleetCategory: 'external', quantityKg: 1620.0, unitPrice: 30, amountYuan: 48600, source: 'station_report', verifyStatus: null }, + ], + }, + ], + }, + ], + }, + { + date: '2026-07-31', + shortDate: '07-31', + unitPrice: 30, + quantityKg: 3580.0, + amountYuan: 107400.0, + momPct: -4.5, + stations: [ + { + stationId: 'st-jj', + stationName: '嘉兴嘉锦加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 3580.0, + amountYuan: 107400.0, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 2580.0, + amountYuan: 77400.0, + vehicles: [ + { id: 'v-1301', time: '10:00:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2580.0, unitPrice: 30, amountYuan: 77400, source: 'api', verifyStatus: 'verified' }, + ], + }, + { + customerId: 'c-qd', + customerName: '广东氢动力科技服务有限公司', + quantityKg: 1000.0, + amountYuan: 30000.0, + vehicles: [ + { id: 'v-1302', time: '16:00:00', plateNo: '粤B55555D', fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'api', verifyStatus: null }, + ], + }, + ], + }, + ], + }, + { + date: '2026-07-30', + shortDate: '07-30', + unitPrice: 30, + quantityKg: 3750.0, + amountYuan: 112500.0, + momPct: 8.7, + stations: [ + { + stationId: 'st-jx', + stationName: '嘉兴中石化滨海加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 3750.0, + amountYuan: 112500.0, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 3750.0, + amountYuan: 112500.0, + vehicles: [ + { id: 'v-1401', time: '08:30:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 3750.0, unitPrice: 30, amountYuan: 112500, source: 'lingniu_report', verifyStatus: 'verified' }, + ], + }, + ], + }, + ], + }, + { + date: '2026-07-29', + shortDate: '07-29', + unitPrice: 30, + quantityKg: 3450.0, + amountYuan: 103500.0, + momPct: 15.8, + stations: [ + { + stationId: 'st-hz', + stationName: '杭州临安加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 3450.0, + amountYuan: 103500.0, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 2450.0, + amountYuan: 73500.0, + vehicles: [ + { id: 'v-1501', time: '13:00:00', plateNo: '浙A66666F', fleetCategory: 'own', quantityKg: 2450.0, unitPrice: 30, amountYuan: 73500, source: 'api', verifyStatus: 'verified' }, + ], + }, + { + customerId: 'c-qy', + customerName: '广东清运科技有限公司', + quantityKg: 1000.0, + amountYuan: 30000.0, + vehicles: [ + { id: 'v-1502', time: '17:40:00', plateNo: '浙A99111D', fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'station_report', verifyStatus: null }, + ], + }, + ], + }, + ], + }, + { + date: '2026-07-28', + shortDate: '07-28', + unitPrice: 30, + quantityKg: 2980.2, + amountYuan: 89406.0, + momPct: -4.5, + stations: [ + { + stationId: 'st-jx', + stationName: '嘉兴中石化滨海加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 2980.2, + amountYuan: 89406.0, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 2980.2, + amountYuan: 89406.0, + vehicles: [ + { id: 'v-1601', time: '15:10:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2980.2, unitPrice: 30, amountYuan: 89406, source: 'lingniu_report', verifyStatus: 'verified' }, + ], + }, + ], + }, + ], + }, + { + date: '2026-07-27', + shortDate: '07-27', + unitPrice: 30, + quantityKg: 3120.0, + amountYuan: 93600.0, + momPct: 33.3, + stations: [ + { + stationId: 'st-sh', + stationName: '上海金山加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 3120.0, + amountYuan: 93600.0, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 2120.0, + amountYuan: 63600.0, + vehicles: [ + { id: 'v-1701', time: '11:00:00', plateNo: '沪A88111F', fleetCategory: 'own', quantityKg: 2120.0, unitPrice: 30, amountYuan: 63600, source: 'api', verifyStatus: 'verified' }, + ], + }, + { + customerId: 'c-dp', + customerName: '东展供应链(广州)有限公司', + quantityKg: 1000.0, + amountYuan: 30000.0, + vehicles: [ + { id: 'v-1702', time: '16:20:00', plateNo: null, fleetCategory: 'external', quantityKg: 1000.0, unitPrice: 30, amountYuan: 30000, source: 'station_report', verifyStatus: null }, + ], + }, + ], + }, + ], + }, + { + date: '2026-07-26', + shortDate: '07-26', + unitPrice: 30, + quantityKg: 2340.5, + amountYuan: 70215.0, + momPct: -36.5, + stations: [ + { + stationId: 'st-jx', + stationName: '嘉兴中石化滨海加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 2340.5, + amountYuan: 70215.0, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 2340.5, + amountYuan: 70215.0, + vehicles: [ + { id: 'v-1801', time: '14:20:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2340.5, unitPrice: 30, amountYuan: 70215, source: 'api', verifyStatus: 'verified' }, + ], + }, + ], + }, + ], + }, + { + date: '2026-07-25', + shortDate: '07-25', + unitPrice: 30, + quantityKg: 3683.0, // 峰值 + amountYuan: 110490.0, + momPct: null, + stations: [ + { + stationId: 'st-jx', + stationName: '嘉兴中石化滨海加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 2183.0, + amountYuan: 65490.0, + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + quantityKg: 2183.0, + amountYuan: 65490.0, + vehicles: [ + { id: 'v-1901', time: '09:30:00', plateNo: '浙A88888F', fleetCategory: 'own', quantityKg: 2183.0, unitPrice: 30, amountYuan: 65490, source: 'api', verifyStatus: 'verified' }, + ], + }, + ], + }, + { + stationId: 'st-jj', + stationName: '嘉兴嘉锦加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: 1500.0, + amountYuan: 45000.0, + customers: [ + { + customerId: 'c-qd', + customerName: '广东氢动力科技服务有限公司', + quantityKg: 1500.0, + amountYuan: 45000.0, + vehicles: [ + { id: 'v-1902', time: '16:00:00', plateNo: '粤B12345D', fleetCategory: 'external', quantityKg: 1500.0, unitPrice: 30, amountYuan: 45000, source: 'station_report', verifyStatus: null }, + ], + }, + ], + }, + ], + }, +]; + +/** 根据任意规范化开始与结束日期 (YYYY-MM-DD) 动态生成完整区间内的 DailyItem 列表 */ +export function getDailyDataForRange(normStartStr: string, normEndStr: string): DailyItem[] { + if (!normStartStr || !normEndStr) return MOCK_DAILY_15DAYS; + + const startParts = normStartStr.split('-').map(Number); + const endParts = normEndStr.split('-').map(Number); + if (startParts.length !== 3 || endParts.length !== 3) { + return MOCK_DAILY_15DAYS; + } + + const start = new Date(startParts[0], startParts[1] - 1, startParts[2]); + const end = new Date(endParts[0], endParts[1] - 1, endParts[2]); + + if (isNaN(start.getTime()) || isNaN(end.getTime()) || start > end) { + return MOCK_DAILY_15DAYS; + } + + // 限制最大时间跨度 180 天,保证性能 + const diffTime = Math.abs(end.getTime() - start.getTime()); + const diffDays = Math.min(180, Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1); + + const existingMap = new Map(); + MOCK_DAILY_15DAYS.forEach((item) => existingMap.set(item.date, item)); + + const result: DailyItem[] = []; + + for (let i = 0; i < diffDays; i++) { + const d = new Date(end.getTime() - i * (1000 * 60 * 60 * 24)); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const dateStr = `${yyyy}-${mm}-${dd}`; + const shortDate = `${mm}-${dd}`; + + if (existingMap.has(dateStr)) { + result.push(existingMap.get(dateStr)!); + } else { + // 稳定拟真生成算法 + const seed = yyyy * 10000 + Number(mm) * 100 + Number(dd); + const baseVal = 2200 + (seed % 1400) + (seed % 9) * 60; + + const ownKg = Math.round(baseVal * 0.62 * 10) / 10; + const extKg = Math.round(baseVal * 0.38 * 10) / 10; + const totalKg = Math.round((ownKg + extKg) * 10) / 10; + + result.push({ + date: dateStr, + shortDate, + unitPrice: 30, + quantityKg: totalKg, + amountYuan: Math.round(totalKg * 30 * 10) / 10, + momPct: Math.round(((seed % 24) - 12) * 10) / 10, + stations: [ + { + stationId: `st-jx-${dateStr}`, + stationName: '嘉兴中石化滨海加氢站', + stationType: 'self_use', + unitPrice: 30, + quantityKg: ownKg, + amountYuan: Math.round(ownKg * 30), + prechargeBalance: 128500 + (seed % 4000), + customers: [ + { + customerId: 'c-ln', + customerName: '羚牛氢能科技(广东)有限公司', + customerCategory: 'internal', + quantityKg: ownKg, + amountYuan: Math.round(ownKg * 30), + vehicles: [ + { + id: `v-gen1-${dateStr}`, + time: '08:30:00', + plateNo: `浙A${(seed % 89999) + 10000}F`, + fleetCategory: 'own', + quantityKg: Math.round(ownKg * 0.65 * 10) / 10, + unitPrice: 30, + amountYuan: Math.round(ownKg * 0.65 * 30), + source: 'api', + verifyStatus: 'verified', + }, + { + id: `v-gen2-${dateStr}`, + time: '14:20:00', + plateNo: `浙A${(seed % 79999) + 10000}F`, + fleetCategory: 'own', + quantityKg: Math.round(ownKg * 0.35 * 10) / 10, + unitPrice: 30, + amountYuan: Math.round(ownKg * 0.35 * 30), + source: 'station_report', + verifyStatus: seed % 2 === 0 ? 'verified' : 'unverified', + }, + ], + }, + ], + }, + { + stationId: `st-fs-${dateStr}`, + stationName: '佛山南海加氢站', + stationType: 'external_sale', + unitPrice: 35, + quantityKg: extKg, + amountYuan: Math.round(extKg * 35), + prechargeBalance: 92000 + (seed % 3000), + customers: [ + { + customerId: 'c-qd', + customerName: '广东氢动力科技服务有限公司', + customerCategory: 'external', + quantityKg: extKg, + amountYuan: Math.round(extKg * 35), + vehicles: [ + { + id: `v-gen3-${dateStr}`, + time: '10:15:00', + plateNo: `粤B${(seed % 89999) + 10000}D`, + fleetCategory: 'external', + quantityKg: Math.round(extKg * 0.7 * 10) / 10, + unitPrice: 35, + amountYuan: Math.round(extKg * 0.7 * 35), + source: 'api', + verifyStatus: null, + }, + { + id: `v-gen4-${dateStr}`, + time: '16:40:00', + plateNo: null, + fleetCategory: 'external', + quantityKg: Math.round(extKg * 0.3 * 10) / 10, + unitPrice: 35, + amountYuan: Math.round(extKg * 0.3 * 35), + source: 'station_report', + verifyStatus: null, + }, + ], + }, + ], + }, + ], + }); + } + } + + return result; +} + +/** 根据车辆归属类型 ('all' | 'own' | 'external') 过滤并重新层层汇总 DailyItem 列表 */ +export function filterDailyDataByFleet( + items: DailyItem[], + fleetFilter: FleetCategoryFilter, +): DailyItem[] { + if (fleetFilter === 'all') { + return items; + } + + const result: DailyItem[] = []; + + for (const day of items) { + const newStations: DailyStationDetail[] = []; + + for (const st of day.stations) { + const newCustomers: CustomerDetail[] = []; + + for (const cust of st.customers) { + // 过滤出符合 fleetFilter 的车辆明细 + const filteredVehicles = (cust.vehicles || []).filter( + (v) => v.fleetCategory === fleetFilter, + ); + + if (filteredVehicles.length > 0) { + const custQty = filteredVehicles.reduce((sum, v) => sum + v.quantityKg, 0); + const custAmount = filteredVehicles.reduce((sum, v) => sum + v.amountYuan, 0); + + newCustomers.push({ + ...cust, + quantityKg: Math.round(custQty * 100) / 100, + amountYuan: Math.round(custAmount * 100) / 100, + vehicles: filteredVehicles, + }); + } + } + + if (newCustomers.length > 0) { + const stQty = newCustomers.reduce((sum, c) => sum + c.quantityKg, 0); + const stAmount = newCustomers.reduce((sum, c) => sum + c.amountYuan, 0); + + newStations.push({ + ...st, + quantityKg: Math.round(stQty * 100) / 100, + amountYuan: Math.round(stAmount * 100) / 100, + customers: newCustomers, + }); + } + } + + if (newStations.length > 0) { + const dayQty = newStations.reduce((sum, s) => sum + s.quantityKg, 0); + const dayAmount = newStations.reduce((sum, s) => sum + s.amountYuan, 0); + + result.push({ + ...day, + quantityKg: Math.round(dayQty * 100) / 100, + amountYuan: Math.round(dayAmount * 100) / 100, + stations: newStations, + }); + } + } + + // 重新计算动态环比 + for (let i = 0; i < result.length; i++) { + const current = result[i]; + const prev = result[i + 1]; // items 是按日期倒序 + if (prev && prev.quantityKg > 0) { + const pct = ((current.quantityKg - prev.quantityKg) / prev.quantityKg) * 100; + current.momPct = Math.round(pct * 10) / 10; + } else { + current.momPct = null; + } + } + + return result; +} + +/** 动态计算 Filter 后的 KPI 汇总数据 */ +export function calculateDailyKpis(filteredItems: DailyItem[], fleetFilter: FleetCategoryFilter) { + const totalQuantityKg = Math.round( + filteredItems.reduce((acc, item) => acc + item.quantityKg, 0) * 10, + ) / 10; + + // 统计内部车辆与外部车辆各自的加氢量 + let ownKg = 0; + let extKg = 0; + filteredItems.forEach((day) => { + day.stations.forEach((st) => { + st.customers.forEach((cust) => { + cust.vehicles.forEach((vh) => { + if (vh.fleetCategory === 'own') { + ownKg += vh.quantityKg; + } else { + extKg += vh.quantityKg; + } + }); + }); + }); + }); + ownKg = Math.round(ownKg * 10) / 10; + extKg = Math.round(extKg * 10) / 10; + + let fleetTypeLabel = '全部车辆'; + let fleetSubLabel = `内部 ${ownKg.toLocaleString('zh-CN')}Kg · 外部 ${extKg.toLocaleString('zh-CN')}Kg`; + + if (fleetFilter === 'own') { + fleetTypeLabel = '羚牛车辆'; + fleetSubLabel = '内部车辆归属口径'; + } else if (fleetFilter === 'external') { + fleetTypeLabel = '外部车辆'; + fleetSubLabel = '外部车辆归属口径'; + } + + const activeDaysCount = filteredItems.length; + const activeDaysStr = `${activeDaysCount} 天`; + const dailyAvgKgNum = activeDaysCount > 0 ? Math.round((totalQuantityKg / activeDaysCount) * 10) / 10 : 0; + const dailyAvgKgStr = `${dailyAvgKgNum.toLocaleString('zh-CN')} Kg`; + + // 站点去重统计 + const stationSet = new Set(); + filteredItems.forEach((d) => d.stations.forEach((s) => stationSet.add(s.stationId))); + const stationCount = stationSet.size; + + // 峰值日与低谷日 + let peakItem: DailyItem | null = null; + let troughItem: DailyItem | null = null; + + filteredItems.forEach((item) => { + if (!peakItem || item.quantityKg > peakItem.quantityKg) { + peakItem = item; + } + if (!troughItem || item.quantityKg < troughItem.quantityKg) { + troughItem = item; + } + }); + + const peakDayLabel = peakItem + ? `${(peakItem as DailyItem).shortDate} · ${Math.round((peakItem as DailyItem).quantityKg).toLocaleString('zh-CN')}` + : '-'; + const troughDayLabel = troughItem + ? `${(troughItem as DailyItem).shortDate} · ${Math.round((troughItem as DailyItem).quantityKg).toLocaleString('zh-CN')}` + : '-'; + + const dateRange = filteredItems.length > 0 + ? `${filteredItems[filteredItems.length - 1].date} 至 ${filteredItems[0].date}` + : '无数据'; + + return { + totalQuantityKg, + dateRange, + fleetTypeLabel, + fleetSubLabel, + ownKg, + extKg, + activeDays: activeDaysStr, + dailyAvgKg: dailyAvgKgStr, + dailyAvgKgNum, + stationCount, + peakDayLabel, + troughDayLabel, + zeroDaysCount: 0, + }; +} diff --git a/src/modules/energy/hydrogen/board/styles/energy-bi-board.css b/src/modules/energy/hydrogen/board/styles/energy-bi-board.css new file mode 100644 index 0000000..603e4bd --- /dev/null +++ b/src/modules/energy/hydrogen/board/styles/energy-bi-board.css @@ -0,0 +1,8045 @@ +/** + * 能源 BI 宿主设计令牌(来源:AI-羚牛氢能-能源BI-complete.zip · #hydrogen/overview) + * 本原型独立嵌入 bi-next,禁止引入 OneOS V2 组件; + * 字体例外:汉字/UI 与数字等宽对齐 V2 Token(DESIGN §2.2)。 + */ +@import '../../fonts/jetbrains-mono/jetbrains-mono.css'; + +/* —— 访问口令门(轻门禁 · 对齐宿主蓝系,非 V2 组件) —— */ +.ehb-gate { + --bi-text: #0f172a; + --bi-muted: #64748b; + --bi-blue: #2f6bff; + --bi-line: rgba(15, 23, 42, 0.08); + --bi-danger: #dc2626; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: + radial-gradient(1000px 380px at 15% -5%, rgba(37, 99, 235, 0.08), transparent 50%), + linear-gradient(160deg, #eff6ff 0%, #f8fafc 45%, #ffffff 100%); + color: var(--bi-text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', + 'Microsoft YaHei', 'Noto Sans SC', sans-serif; + box-sizing: border-box; +} + +/* P2 accessibility: one mobile navigation owns the interaction layer; key controls + remain at least 36px on desktop and 44px on touch-sized viewports. */ +@media (min-width: 768px) { + .ehb-rail { pointer-events: none; } + .ehb-chrome__tools .ehb-seg button, + .ehb-overview-filter .ehb-fleet-btn, + .ehb-overview-filter .ehb-pill-btn, + .ehb-mini-tab, + .ehb-mobile-detail-tabs button, + .ehb-overview-filter .ehb-year-select-btn { min-height: 36px !important; } +} + +@media (max-width: 767px) { + .ehb-rail { display: none !important; pointer-events: none; visibility: hidden; } + .ehb-mobile-bottom-nav button, + .ehb-mobile-view-mode [role="tab"], + .ehb-mobile-primary-filters > button, + .ehb-mobile-primary-filters .ehb-year-select-btn, + .ehb-mobile-filter-body .ehb-pill-btn, + .ehb-mobile-filter-body .ehb-fleet-btn, + .ehb-mobile-daily-presets .ehb-pill-btn { min-height: 44px !important; } +} + +/* Data detail switch: one surface and one interaction model on desktop and mobile. */ +.ehb-mobile-detail-tabs-card { + position: relative; + display: block; + grid-column: 1 / -1; + min-width: 0; + width: 100%; + overflow: hidden; + border: 1px solid var(--bi-hairline); + border-radius: 18px; + background: #fff; +} + +/* 全局报表数值降噪:表格值使用中性色,趋势仅由小箭头表达。 */ +.ehb-table tbody td, +.ehb-modal-table tbody td, +.ehb-station-detail-table tbody td, +.ehb-sum-table tbody td { + color: #334155 !important; +} + +.ehb-day-change, +.ehb-trend-value { + color: #334155 !important; + font-weight: 600; +} + +.ehb-day-change.is-up::after, +.ehb-trend-value.is-up::after { + content: ' ▲'; + color: #059669; + font-size: 10px; +} + +.ehb-day-change.is-down::after, +.ehb-trend-value.is-down::after { + content: ' ▼'; + color: #dc2626; + font-size: 10px; +} +.ehb-mobile-detail-tabs-head { + display: flex; + align-items: center; + gap: 18px; + padding: 16px 20px 0; +} +.ehb-mobile-detail-tabs-title { + color: #18263d; + font-size: 16px; + font-weight: 750; + white-space: nowrap; +} +.ehb-mobile-detail-tabs { + display: inline-flex; + gap: 3px; + padding: 3px; + border-radius: 10px; + background: #eef3f9; +} +.ehb-mobile-detail-tabs button { + min-width: 112px; + min-height: 34px; + padding: 0 16px; + border: 0; + border-radius: 8px; + background: transparent; + color: #60728d; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} +.ehb-mobile-detail-tabs button.is-active { + background: #fff; + color: #2f6bff; + box-shadow: 0 1px 4px rgba(32, 67, 116, .12); +} +.ehb-mobile-detail-panel { + margin: 0 !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; +} +.ehb-mobile-detail-panel:not(.is-active) { display: none; } +.ehb-mobile-detail-panel .ehb-sum-table-card__title { display: none; } +.ehb-mobile-detail-tabs-card .ehb-station-fullscreen-trigger { top: 14px; right: 18px; } + +.ehb-province-label--mobile { + display: none; +} + +@media (max-width: 767px) { + /* Fixed mobile sheets must use the viewport, not the blurred header as a containing block. */ + .ehb-chrome { + backdrop-filter: none !important; + } + + .ehb-date-popover, + .ehb-year-dropdown { + box-sizing: border-box !important; + } + + .ehb-mobile-detail-tabs-card { + border-radius: 16px; + } + .ehb-mobile-detail-tabs-head { + display: block; + padding: 14px 14px 0; + padding-inline-end: 14px !important; + } + .ehb-mobile-detail-tabs-title { + padding-right: 112px; + color: #18263d; + font-size: 16px; + font-weight: 750; + } + .ehb-mobile-detail-tabs { + display: grid; + width: 100%; + box-sizing: border-box; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px; + margin-top: 12px; + padding: 3px; + border-radius: 10px; + background: #eef3f9; + } + .ehb-mobile-detail-tabs button { + min-height: 44px; + border: 0; + border-radius: 8px; + background: transparent; + color: #60728d; + font-size: 12px; + font-weight: 700; + } + + .ehb-mini-tab { + min-width: 44px; + min-height: 44px; + } + + .ehb-province-label--desktop { + display: none; + } + + .ehb-province-label--mobile { + display: inline; + } + + .ehb-station-summary-card .ehb-sum-table-card__head > div { + width: 100%; + min-width: 0; + } + + .ehb-station-summary-card .ehb-mini-tabs { + width: 100%; + min-width: 0; + overflow-x: auto; + flex-wrap: nowrap; + box-sizing: border-box; + } + + .ehb-chart-box-head { + min-width: 0; + overflow: hidden; + } + + .ehb-chart-legend-inline { + width: 100%; + min-width: 0; + flex-wrap: wrap; + } + + .ehb-chart-box-meta { + max-width: 100%; + } + + /* Hidden chart tooltips must not enlarge the document on narrow screens. */ + .ehb-mbar-tooltip, + .ehb-rev-income-tooltip, + .ehb-top-bar-tooltip { + display: none !important; + } + + .ehb-mbar-col:hover .ehb-mbar-tooltip, + .ehb-rev-bar.is-income:hover .ehb-rev-income-tooltip, + .ehb-top-bar-bg:hover .ehb-top-bar-tooltip, + .ehb-top-station-item:hover .ehb-top-bar-tooltip { + display: block !important; + } + .ehb-mobile-detail-tabs-card .mobile-list-fullscreen-trigger { top: 10px; right: 10px; } + .ehb-mobile-detail-tabs-card .ehb-station-fullscreen-trigger { display: none; } +} + +.ehb-gate *, +.ehb-gate *::before, +.ehb-gate *::after { + box-sizing: border-box; +} + +.ehb-gate-card { + width: min(420px, 100%); + padding: 36px 32px 28px; + border: 1px solid var(--bi-line); + border-radius: 16px; + background: #ffffff; + box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08); +} + +.ehb-gate-kicker { + font-size: 11px; + letter-spacing: 0.12em; + color: var(--bi-blue); + margin: 0 0 16px; + font-weight: 600; +} + +.ehb-gate-title { + margin: 0 0 8px; + font-size: 24px; + font-weight: 800; + color: var(--bi-text); +} + +.ehb-gate-sub { + margin: 0 0 24px; + font-size: 13px; + line-height: 1.55; + color: var(--bi-muted); +} + +.ehb-gate-label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--bi-muted); + margin-bottom: 8px; +} + +.ehb-gate-input { + display: block; + width: 100%; + height: 44px; + min-height: 44px; + border-radius: 10px; + border: 1px solid var(--bi-line); + background: #f8fafc; + color: var(--bi-text); + padding: 0 14px; + font-size: 16px; + line-height: 44px; + appearance: none; + -webkit-appearance: none; +} + +.ehb-gate-input::placeholder { + color: #94a3b8; +} + +.ehb-gate-input:hover { + border-color: #cbd5e1; +} + +.ehb-gate-input:focus { + outline: none; + border-color: var(--bi-blue); + background: #fff; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); +} + +.ehb-gate-error { + min-height: 20px; + margin: 8px 0 12px; + font-size: 12px; + color: var(--bi-danger); +} + +.ehb-gate-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 44px; + min-height: 44px; + border: none; + border-radius: 10px; + background: var(--bi-blue); + color: #fff; + font-size: 15px; + font-weight: 700; + cursor: pointer; +} + +.ehb-gate-btn:hover { + background: #1d4ed8; +} + +.ehb-gate-btn:focus-visible { + outline: 2px solid #1e40af; + outline-offset: 2px; +} + +.ehb-gate-foot { + margin: 16px 0 0; + font-size: 11px; + color: #64748b; + text-align: center; +} + +.ehb-shell { + --bi-app-bg: #f8fafc; + --bi-panel: #ffffff; + --bi-hairline: rgba(15, 23, 42, 0.08); + --bi-hairline-subtle: rgba(15, 23, 42, 0.04); + --bi-text: #0f172a; + --bi-text-body: #1e293b; + --bi-text-sub: #334155; + --bi-muted: #64748b; + --bi-tertiary: #94a3b8; + --bi-blue: #2f6bff; + --bi-blue-soft: #eff6ff; + --bi-green: #059669; + --bi-amber: #d97706; + --bi-red: #dc2626; + --bi-purple: #7c3aed; + --bi-cyan: #0891b2; + --bi-rail: #0f172a; + --bi-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 16px rgba(15, 23, 42, 0.03); + --bi-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04); + --bi-radius: 14px; + --bi-radius-sm: 10px; + --bi-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', + 'Microsoft YaHei', 'Noto Sans SC', sans-serif; + --bi-font-mono: 'JetBrains Mono', 'Cascadia Mono', 'Cascadia Code', Consolas, 'SF Mono', + SFMono-Regular, Menlo, 'Courier New', monospace; + + display: flex; + min-height: 100vh; + background: + radial-gradient(1000px 380px at 15% -5%, rgba(37, 99, 235, 0.04), transparent 50%), + var(--bi-app-bg); + color: var(--bi-text-body); + font-family: var(--bi-font); + font-variant-numeric: tabular-nums; +} + +.ehb-rail { + width: 72px; + flex-shrink: 0; + background: var(--bi-rail); + color: #e2e8f0; + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 0; + gap: 8px; +} + +.ehb-rail__item { + width: 56px; + border: none; + background: transparent; + color: #94a3b8; + border-radius: 10px; + padding: 10px 4px; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 500; + transition: background 0.15s ease, color 0.15s ease; +} + +.ehb-rail__item.is-active { + background: var(--bi-blue); + color: #fff; + font-weight: 600; +} + +.ehb-rail__item:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.ehb-body { + flex: 1; + min-width: 0; + padding: 18px 22px 36px; + box-sizing: border-box; +} + +/* —— 页头 —— */ +.ehb-chrome { + position: sticky; + top: 0; + z-index: 20; + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 12px 16px; + margin: -6px -6px 16px; + padding: 10px 6px 12px; + background: rgba(248, 250, 252, 0.92); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--bi-hairline-subtle); +} + +.ehb-chrome__lead h1 { + margin: 2px 0 0; + font-size: 22px; + font-weight: 700; + color: var(--bi-text); + letter-spacing: -0.02em; + line-height: 1.2; +} + +.ehb-crumb { + font-size: 12px; + color: var(--bi-muted); + font-weight: 400; +} + +.ehb-chrome__tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.ehb-scope-switches, +.ehb-view-switches { + display: flex; + align-items: center; + gap: 7px; +} + +.ehb-switch-label { + color: var(--bi-muted); + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.ehb-filter-toggle { + display: none; +} + +.ehb-mobile-brand-icon, +.ehb-mobile-filter-panel { + display: none; +} + +.ehb-chrome__clock { + font-size: 12px; + color: var(--bi-tertiary); + font-family: var(--bi-font-mono); + font-variant-numeric: tabular-nums; +} + +.ehb-seg { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3px; + background: #f1f5f9; + border-radius: 10px; + padding: 3px; + min-width: 150px; +} + +.ehb-seg button { + border: none; + background: transparent; + border-radius: 7px; + height: 30px; + padding: 0 12px; + font-size: 12px; + font-weight: 500; + color: var(--bi-muted); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.ehb-seg button.is-active { + background: #ffffff; + color: var(--bi-blue); + font-weight: 600; + box-shadow: var(--bi-shadow-sm); +} + +.ehb-year-select-wrapper { + position: relative; + display: inline-block; +} + +.ehb-year-select-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 12px; + background: #ffffff; + border: 1px solid var(--bi-hairline); + border-radius: 999px; + font-size: 12px; + font-weight: 600; + color: #1e293b; + font-family: var(--bi-font-mono); + cursor: pointer; + box-shadow: var(--bi-shadow-sm); + transition: all 0.15s ease; +} + +.ehb-year-select-btn:hover, +.ehb-year-select-btn.is-active { + border-color: #2f6bff; + color: #2f6bff; + box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.12); +} + +.ehb-year-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 1000; + width: 140px; + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 10px; + padding: 6px; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.12), 0 8px 10px -6px rgba(0, 0, 0, 0.08); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +.ehb-year-dropdown__header { + padding: 4px 8px 6px; + font-size: 11px; + font-weight: 600; + color: #94a3b8; + border-bottom: 1px solid #f1f5f9; + margin-bottom: 4px; +} + +.ehb-year-dropdown__list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 200px; + overflow-y: auto; +} + +.ehb-year-dropdown__item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 6px 10px; + border: none; + background: transparent; + border-radius: 6px; + font-size: 12px; + font-family: var(--bi-font-mono); + font-weight: 500; + color: #334155; + cursor: pointer; + transition: all 0.12s ease; +} + +.ehb-year-dropdown__item:hover { + background: #f1f5f9; + color: #2f6bff; +} + +.ehb-year-dropdown__item.is-selected { + background: #e0f2fe; + color: #2f6bff; + font-weight: 700; +} + +.ehb-year-check { + font-size: 12px; + font-weight: 700; + color: #2f6bff; +} + +.ehb-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 11px; + border-radius: 8px; + border: 1px solid var(--bi-hairline); + background: #ffffff; + color: var(--bi-text-body); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease; +} + +.ehb-btn:hover { + border-color: rgba(37, 99, 235, 0.35); + color: var(--bi-blue); +} + +.ehb-btn:focus-visible, +.ehb-chip:focus-visible, +.ehb-seg button:focus-visible, +.ehb-year button:focus-visible, +.ehb-dim:focus-visible, +.ehb-dim__sub:focus-visible, +.ehb-stats__tabs button:focus-visible { + outline: 2px solid rgba(37, 99, 235, 0.4); + outline-offset: 1px; +} + +.ehb-btn--ghost { + background: transparent; + color: var(--bi-muted); +} + +.ehb-chip-group { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; + padding: 3px; + border-radius: 999px; + background: #f1f5f9; +} + +.ehb-chip { + height: 26px; + padding: 0 11px; + border-radius: 999px; + border: 1px solid transparent; + background: transparent; + font-size: 12px; + font-weight: 500; + color: var(--bi-muted); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.ehb-chip.is-active { + background: #ffffff; + color: var(--bi-blue); + font-weight: 600; + box-shadow: var(--bi-shadow-sm); +} + +.ehb-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.ehb-filters__rule { + width: 1px; + height: 18px; + background: var(--bi-hairline); +} + +/* —— 宿主总览区(V3 双层卡) —— */ +.ehb-host { + margin-bottom: 20px; + padding: 14px; + background: rgba(255, 255, 255, 0.6); + border: 1px dashed rgba(148, 163, 184, 0.3); + border-radius: var(--bi-radius); +} + +.ehb-metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.ehb-host-kpi, +.ehb-insight { + display: contents; +} + +.ehb-kpi-dual { + background: #ffffff; + border-radius: var(--bi-radius-sm); + border: 1px solid var(--bi-hairline); + padding: 10px; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.ehb-kpi-dual__head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +} + +.ehb-kpi-dual__label { + font-size: 12px; + font-weight: 500; + color: var(--bi-muted); +} + +.ehb-kpi-dual__badge { + width: 22px; + height: 22px; + border-radius: 6px; + display: grid; + place-items: center; + flex-shrink: 0; +} + +.ehb-kpi-dual__badge.is-blue { background: var(--bi-blue-soft); color: var(--bi-blue); } +.ehb-kpi-dual__badge.is-green { background: #ecfdf5; color: var(--bi-green); } +.ehb-kpi-dual__badge.is-amber { background: #fffbeb; color: var(--bi-amber); } +.ehb-kpi-dual__badge.is-purple { background: #f3e8ff; color: var(--bi-purple); } +.ehb-kpi-dual__badge.is-cyan { background: #ecfeff; color: var(--bi-cyan); } + +.ehb-kpi-dual__val { + display: flex; + align-items: baseline; + color: var(--bi-text-body); + font-family: var(--bi-font-mono); + font-variant-numeric: tabular-nums; + line-height: 1.2; + margin-bottom: 6px; +} + +.ehb-kpi-dual__symbol { + font-size: 13px; + font-weight: 600; + color: var(--bi-muted); + margin-right: 2px; +} + +.ehb-kpi-dual__num { + font-size: 20px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.ehb-kpi-dual__unit { + font-size: 12px; + font-weight: 500; + color: var(--bi-muted); + margin-left: 2px; +} + +.ehb-kpi-dual__deck { + background: #f8fafc; + border-radius: 6px; + padding: 4px 8px; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + color: var(--bi-muted); + font-family: var(--bi-font-mono); + font-variant-numeric: tabular-nums; +} + +.ehb-insight__card { + background: #ffffff; + border-radius: var(--bi-radius-sm); + border: 1px solid var(--bi-hairline); + padding: 10px 12px; + display: flex; + gap: 10px; + align-items: flex-start; +} + +.ehb-insight__icon { + width: 32px; + height: 32px; + border-radius: 8px; + background: var(--bi-blue-soft); + color: var(--bi-blue); + display: grid; + place-items: center; + flex-shrink: 0; +} + +.ehb-insight__icon.is-down { + background: #fef2f2; + color: var(--bi-red); +} + +.ehb-insight__icon.is-ok { + background: #ecfdf5; + color: var(--bi-green); +} + +.ehb-insight__title { + font-size: 11px; + color: var(--bi-muted); + font-weight: 500; +} + +.ehb-insight__value { + font-size: 18px; + font-weight: 700; + margin-top: 1px; + color: var(--bi-text-body); + font-family: var(--bi-font-mono); + font-variant-numeric: tabular-nums; +} + +.ehb-insight__value.is-neg { color: var(--bi-red); } +.ehb-insight__value.is-pos { color: var(--bi-green); } + +.ehb-insight__desc { + font-size: 11px; + color: var(--bi-tertiary); + margin-top: 2px; + line-height: 1.35; +} + +.ehb-insight__card--rank { + position: relative; + align-items: center; +} + +.ehb-insight__card--rank.is-open { + border-color: #7dd3fc; + box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.12); + z-index: 30; +} + +.ehb-insight__rank-body { + flex: 1; + min-width: 0; +} + +.ehb-insight__rank-chevron { + flex-shrink: 0; + color: var(--bi-muted); + transition: transform 0.2s ease; + margin-left: auto; +} + +.ehb-insight__rank-chevron.is-open { + transform: rotate(180deg); + color: #2f6bff; +} + +.ehb-station-rank-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + min-width: 360px; + max-width: min(520px, 92vw); + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 10px; + box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14); + z-index: 40; + overflow: hidden; +} + +.ehb-station-rank-dropdown__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid #e2e8f0; + font-size: 12px; + font-weight: 700; + color: #0f172a; +} + +.ehb-station-rank-dropdown__meta { + font-size: 11px; + font-weight: 500; + color: #64748b; +} + +.ehb-station-rank-dropdown__list { + max-height: 320px; + overflow-y: auto; + padding: 6px; + -webkit-overflow-scrolling: touch; +} + +.ehb-station-rank-item { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + width: 100%; + border: none; + background: transparent; + padding: 8px 8px; + border-radius: 8px; + cursor: pointer; + text-align: left; +} + +.ehb-station-rank-item:hover { + background: #f0f9ff; +} + +.ehb-station-rank-item__rank { + width: 22px; + height: 22px; + border-radius: 6px; + display: grid; + place-items: center; + font-size: 11px; + font-weight: 700; + font-family: var(--bi-font-mono); + color: #64748b; + background: #f1f5f9; +} + +.ehb-station-rank-item__rank.is-top { + color: #fff; + background: #2f6bff; +} + +.ehb-station-rank-item__main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.ehb-station-rank-item__name { + font-size: 12px; + color: #0f172a; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ehb-station-rank-item__bar { + height: 4px; + border-radius: 999px; + background: #e2e8f0; + overflow: hidden; +} + +.ehb-station-rank-item__bar > span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #73a2ff, #2f6bff); +} + +.ehb-station-rank-item__val { + font-size: 12px; + font-weight: 700; + font-family: var(--bi-font-mono); + font-variant-numeric: tabular-nums; + color: #0f172a; + white-space: nowrap; +} + +.ehb-station-rank-item__share { + font-size: 11px; + color: #64748b; + font-family: var(--bi-font-mono); + font-variant-numeric: tabular-nums; + min-width: 42px; + text-align: right; +} + +.ehb-station-rank-empty { + padding: 20px 12px; + text-align: center; + font-size: 12px; + color: #94a3b8; +} + +/* —— 核心区:我司成本 —— */ +.ehb-feature { + background: #ffffff; + border-radius: var(--bi-radius); + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04), 0 1px 2px rgba(15, 23, 42, 0.02); + border: 1px solid rgba(37, 99, 235, 0.18); + padding: 18px 20px 20px; +} + +.ehb-feature__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; + padding-bottom: 14px; + border-bottom: 1px solid var(--bi-hairline); +} + +.ehb-feature__bar h2 { + margin: 0; + font-size: 18px; + font-weight: 700; + color: var(--bi-text); + letter-spacing: -0.02em; + display: flex; + align-items: center; + gap: 8px; +} + +.ehb-feature__bar h2::before { + content: ''; + display: inline-block; + width: 4px; + height: 16px; + border-radius: 999px; + background: var(--bi-blue); +} + +.ehb-dim-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.ehb-dim { + position: relative; + text-align: left; + border: 1px solid var(--bi-hairline); + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + border-radius: var(--bi-radius-sm); + padding: 14px 16px 12px; + cursor: pointer; + overflow: hidden; + transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; +} + +.ehb-dim::before { + content: ''; + position: absolute; + inset: 0 0 auto; + height: 3px; + background: var(--bi-blue); +} + +.ehb-dim.is-lease::before { background: linear-gradient(90deg, #2f6bff, #8ab2ff); } +.ehb-dim.is-logistics::before { background: linear-gradient(90deg, #0891b2, #22d3ee); } +.ehb-dim.is-ops::before { background: linear-gradient(90deg, #7c3aed, #a78bfa); } + +.ehb-dim:hover { + border-color: rgba(37, 99, 235, 0.3); + transform: translateY(-1px); +} + +.ehb-dim.is-active { + background: var(--bi-blue-soft); + border-color: rgba(37, 99, 235, 0.45); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +@media (prefers-reduced-motion: reduce) { + .ehb-dim, + .ehb-rail__item, + .ehb-chip, + .ehb-seg button, + .ehb-btn { + transition: none; + } + .ehb-dim:hover { + transform: none; + } +} + +.ehb-dim__name { + font-size: 13px; + font-weight: 600; + color: var(--bi-muted); +} + +.ehb-dim__amt { + margin-top: 4px; + font-size: 22px; + font-weight: 800; + color: var(--bi-text); + font-family: var(--bi-font-mono); + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; +} + +.ehb-dim__subs { + margin-top: 10px; + display: grid; + gap: 5px; +} + +.ehb-dim__sub { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: var(--bi-muted); + padding: 5px 8px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.85); + border: 1px solid transparent; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; +} + +.ehb-dim__sub:hover { + background: #ffffff; + border-color: rgba(148, 163, 184, 0.25); +} + +.ehb-dim__sub.is-active { + border-color: rgba(37, 99, 235, 0.35); + color: var(--bi-blue); + font-weight: 600; + background: #ffffff; +} + +.ehb-dim__sub strong { + color: var(--bi-text-sub); + font-weight: 600; + font-family: var(--bi-font-mono); + font-variant-numeric: tabular-nums; +} + +.ehb-pending { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + padding: 8px 12px; + border-radius: 8px; + background: #fffbeb; + border: 1px solid rgba(217, 119, 6, 0.22); + font-size: 12px; + color: #92400e; + margin-bottom: 14px; +} + +.ehb-pending strong { + font-family: var(--bi-font-mono); + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.ehb-pending__sep { + width: 1px; + height: 12px; + background: rgba(217, 119, 6, 0.28); +} + +/* —— 面板与表格 —— */ +.ehb-panel { + margin-top: 14px; + border: 1px solid var(--bi-hairline); + border-radius: var(--bi-radius-sm); + background: #ffffff; + overflow: hidden; +} + +.ehb-panel__head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 12px; + padding: 8px 12px; + border-bottom: 1px solid var(--bi-hairline); + background: #f8fafc; +} + +.ehb-panel__meta { + font-size: 12px; + color: var(--bi-tertiary); +} + +.ehb-stats__tabs { + display: inline-flex; + gap: 3px; + background: #e2e8f0; + border-radius: 8px; + padding: 3px; +} + +.ehb-stats__tabs button { + border: none; + background: transparent; + height: 28px; + padding: 0 11px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + color: var(--bi-muted); + cursor: pointer; +} + +.ehb-stats__tabs button.is-active { + background: #ffffff; + color: var(--bi-blue); + font-weight: 600; + box-shadow: var(--bi-shadow-sm); +} + +.ehb-stats__path { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--bi-muted); +} + +.ehb-stats__path button { + border: none; + background: transparent; + color: var(--bi-blue); + font-weight: 600; + cursor: pointer; + padding: 0; +} + +.ehb-section-title { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--bi-text-body); +} + +.ehb-table-wrap { + overflow: auto; + max-height: min(40vh, 400px); + background: #ffffff; +} + +.ehb-panel .ehb-table-wrap { + border: none; + border-radius: 0; +} + +.ehb-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + min-width: max(100%, 720px); + font-size: 13px; +} + +.ehb-table th { + position: sticky; + top: 0; + z-index: 1; + text-align: left; + font-size: 12px; + font-weight: 500; + color: var(--bi-muted); + background-color: #f8fafc; + background-clip: padding-box; + transform: translateZ(0); + padding: 8px 12px; + border-bottom: 1px solid var(--bi-hairline); + white-space: nowrap; +} + +.ehb-table td { + padding: 8px 12px; + border-bottom: 1px solid var(--bi-hairline); + color: var(--bi-text-body); + font-weight: 400; +} + +.ehb-table tr:last-child td { + border-bottom: none; +} + +.ehb-table tr.is-clickable { + cursor: pointer; +} + +.ehb-table tr.is-clickable:hover td { + background: #f1f5f9; +} + +.ehb-table tr.is-active td { + background: rgba(37, 99, 235, 0.08); +} + +.ehb-mono { + font-variant-numeric: tabular-nums; + font-family: var(--bi-font-mono); + font-size: 12px; + color: var(--bi-text-sub); +} + +.ehb-mono.ehb-idx { + color: var(--bi-tertiary); + font-weight: 400; +} + +.ehb-badge { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 7px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; +} + +.ehb-badge.is-ok { + background: #ecfdf5; + color: #047857; +} + +.ehb-badge.is-warn { + background: #fffbeb; + color: #b45309; +} + +.ehb-empty { + padding: 36px 16px; + text-align: center; + color: var(--bi-muted); + font-size: 13px; +} + +.ehb-empty__icon { + color: var(--bi-blue); +} + +.ehb-empty__title { + margin-top: 8px; + font-weight: 600; + color: var(--bi-text-body); +} + +/* —— 宿主按日视图 (Daily View) 专用样式 —— */ + +.ehb-daily-filter-card { + background: #ffffff; + border-radius: var(--bi-radius); + border: 1px solid var(--bi-hairline); + padding: 12px 16px; + margin-bottom: 12px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.ehb-daily-filter-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.ehb-daily-range-summary { + display: flex; + align-items: center; + gap: 8px; + padding-top: 8px; + border-top: 1px solid #edf2f8; + color: var(--bi-muted); + font-size: 11px; + line-height: 1.4; +} + +.ehb-daily-range-summary strong { + color: var(--bi-text-body); + font-weight: 600; +} + +.ehb-daily-range-summary i { + color: var(--bi-tertiary); + font-style: normal; +} + +.ehb-daily-filter-group { + display: flex; + align-items: center; + gap: 8px; +} + +.ehb-pill-tabs { + display: flex; + background: #f1f5f9; + border-radius: 6px; + padding: 2px; + gap: 2px; +} + +.ehb-pill-btn { + border: none; + background: transparent; + padding: 4px 12px; + font-size: 12px; + font-weight: 500; + color: var(--bi-muted); + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; +} + +.ehb-pill-btn:hover { + color: var(--bi-text-body); +} + +.ehb-pill-btn.is-active { + background: #ffffff; + color: var(--bi-blue); + font-weight: 600; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.ehb-daily-date-picker-wrapper { + position: relative; + display: inline-block; +} + +.ehb-daily-date-picker { + display: flex; + align-items: center; + gap: 8px; + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 6px; + padding: 4px 10px; + font-size: 12px; + color: var(--bi-text-body); + font-family: var(--bi-font-mono); + cursor: pointer; + user-select: none; + transition: all 0.15s ease; +} + +.ehb-daily-date-picker:hover, +.ehb-daily-date-picker.is-active { + border-color: #2f6bff; + box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.12); +} + +.ehb-date-label { + color: #64748b; + font-weight: 500; +} + +.ehb-date-val { + color: #0f172a; + font-weight: 600; +} + +/* 自定义非原生日历 Popover 下拉卡片 */ +.ehb-date-popover { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 1000; + width: 238px; + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 10px; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.12), 0 8px 10px -6px rgba(0, 0, 0, 0.08); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +/* 模式切换条: 按日 | 按月 | 按年 */ +.ehb-dp-mode-bar { + display: flex; + background: #f1f5f9; + border-radius: 6px; + padding: 2px; + gap: 2px; + margin-bottom: 8px; +} + +.ehb-dp-mode-btn { + flex: 1; + border: none; + background: transparent; + padding: 3px 0; + font-size: 11px; + font-weight: 500; + color: #64748b; + border-radius: 4px; + cursor: pointer; + transition: all 0.12s ease; + text-align: center; +} + +.ehb-dp-mode-btn.is-active { + background: #ffffff; + color: #2f6bff; + font-weight: 600; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); +} + +.ehb-dp-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.ehb-dp-title-group { + display: flex; + align-items: center; + gap: 4px; +} + +.ehb-dp-title-btn { + border: none; + background: transparent; + padding: 2px 6px; + border-radius: 4px; + font-size: 13px; + font-weight: 700; + color: #0f172a; + cursor: pointer; + transition: all 0.12s ease; +} + +.ehb-dp-title-btn:hover { + background: #f1f5f9; + color: #2f6bff; +} + +.ehb-dp-title-btn.is-active { + color: #2f6bff; + background: #e0f2fe; +} + +.ehb-dp-title { + font-size: 13px; + font-weight: 700; + color: #0f172a; +} + +.ehb-dp-nav-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: #f1f5f9; + border-radius: 4px; + color: #475569; + cursor: pointer; + transition: all 0.15s ease; +} + +.ehb-dp-nav-btn:hover { + background: #e2e8f0; + color: #2f6bff; +} + +.ehb-dp-week-row { + display: grid; + grid-template-columns: repeat(7, 1fr); + text-align: center; + font-size: 11px; + font-weight: 600; + color: #94a3b8; + margin-bottom: 6px; +} + +.ehb-dp-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.ehb-dp-day { + display: flex; + align-items: center; + justify-content: center; + height: 26px; + border: none; + background: transparent; + border-radius: 4px; + font-size: 12px; + font-family: var(--bi-font-mono); + color: #334155; + cursor: pointer; + transition: all 0.12s ease; +} + +.ehb-dp-day:hover:not(.is-selected):not(.is-empty) { + background: #f1f5f9; + color: #2f6bff; +} + +.ehb-dp-day.is-selected { + background: #2f6bff; + color: #ffffff; + font-weight: 700; +} + +.ehb-dp-day.is-empty { + cursor: default; +} + +/* 月选择网格 */ +.ehb-dp-month-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + padding: 4px 0; +} + +.ehb-dp-month-item { + height: 34px; + border: 1px solid #e2e8f0; + background: #ffffff; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + color: #334155; + cursor: pointer; + transition: all 0.12s ease; +} + +.ehb-dp-month-item:hover:not(.is-selected) { + border-color: #73a2ff; + color: #2f6bff; + background: #f0f9ff; +} + +.ehb-dp-month-item.is-selected { + background: #2f6bff; + border-color: #2f6bff; + color: #ffffff; + font-weight: 700; +} + +/* 年选择网格 */ +.ehb-dp-year-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; + padding: 4px 0; +} + +.ehb-dp-year-item { + height: 34px; + border: 1px solid #e2e8f0; + background: #ffffff; + border-radius: 6px; + font-size: 12px; + font-family: var(--bi-font-mono); + font-weight: 500; + color: #334155; + cursor: pointer; + transition: all 0.12s ease; +} + +.ehb-dp-year-item:hover:not(.is-selected) { + border-color: #73a2ff; + color: #2f6bff; + background: #f0f9ff; +} + +.ehb-dp-year-item.is-selected { + background: #2f6bff; + border-color: #2f6bff; + color: #ffffff; + font-weight: 700; +} + +.ehb-fleet-segmented { + display: flex; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 2px; + gap: 4px; +} + +.ehb-fleet-btn { + display: flex; + align-items: center; + gap: 6px; + border: none; + background: transparent; + padding: 5px 14px; + font-size: 12px; + font-weight: 500; + color: var(--bi-muted); + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; +} + +.ehb-fleet-btn.is-active { + background: #ffffff; + color: var(--bi-blue); + font-weight: 600; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); +} + +.ehb-daily-kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.ehb-daily-kpi-card { + background: #ffffff; + border-radius: var(--bi-radius-sm); + border: 1px solid var(--bi-hairline); + padding: 12px 16px; + display: flex; + flex-direction: column; + position: relative; +} + +.ehb-daily-kpi-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.ehb-daily-kpi-title { + font-size: 12px; + font-weight: 500; + color: var(--bi-muted); +} + +.ehb-daily-kpi-val { + font-size: 24px; + font-weight: 800; + color: var(--bi-text-body); + font-family: var(--bi-font-mono); + font-variant-numeric: tabular-nums; + line-height: 1.2; + margin-bottom: 4px; + display: flex; + align-items: baseline; + gap: 3px; +} + +.ehb-daily-kpi-sub { + font-size: 11px; + color: var(--bi-tertiary); + font-family: var(--bi-font-mono); +} + +.ehb-value-up { color: #16866f; } +.ehb-value-down { color: #d45d52; } + +.ehb-daily-chart-section { + background: #ffffff; + border-radius: var(--bi-radius); + border: 1px solid var(--bi-hairline); + padding: 16px; + margin-bottom: 12px; +} + +.ehb-daily-chart-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.ehb-daily-chart-title { + font-size: 14px; + font-weight: 700; + color: var(--bi-text-body); +} + +.ehb-daily-chart-meta-group { + display: flex; + align-items: center; + gap: 16px; +} + +.ehb-daily-chart-legend { + display: flex; + align-items: center; + gap: 12px; +} + +.ehb-legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + color: #475569; +} + +.ehb-legend-dot { + width: 8px; + height: 8px; + border-radius: 2px; +} + +.ehb-legend-dot.is-own { + background: #2f6bff; +} + +.ehb-legend-dot.is-ext { + background: #f59e0b; +} + +.ehb-daily-chart-meta { + font-size: 11px; + color: var(--bi-tertiary); +} + +.ehb-daily-summary-pills { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 16px; + padding: 0; + border: 1px solid #e5ebf3; + border-radius: 9px; + background: #f8fafc; +} + +.ehb-daily-pill-item { + display: grid; + min-width: 0; + gap: 3px; + padding: 9px 12px; + color: var(--bi-muted); + font-size: 11px; + text-align: left; +} + +.ehb-daily-pill-item + .ehb-daily-pill-item { + border-left: 1px solid #e5ebf3; +} + +.ehb-daily-pill-item__label { + color: #71819b; + font-size: 10px; +} + +.ehb-daily-pill-item__date, +.ehb-daily-pill-item__value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ehb-daily-pill-item__date { + color: #3f526f; + font-family: var(--bi-font-mono); + font-size: 11px; + font-weight: 650; +} + +.ehb-daily-pill-item__value { + color: var(--bi-text-body); + font-family: var(--bi-font-mono); + font-size: 13px; + font-weight: 750; +} + +.ehb-daily-pill-item__value small { + color: #71819b; + font-family: var(--bi-font-sans); + font-size: 9px; + font-weight: 600; +} + +/* Desktop has enough horizontal room: keep each summary on one scanning line. */ +@media (min-width: 768px) { + .ehb-daily-pill-item { + display: flex; + box-sizing: border-box; + align-items: baseline; + gap: 10px; + min-height: 42px; + padding: 10px 14px; + } + + .ehb-daily-pill-item__label { + flex: 0 0 auto; + } + + .ehb-daily-pill-item__date { + min-width: 0; + } + + .ehb-daily-pill-item__value { + flex: 0 0 auto; + margin-left: auto; + } +} + +.ehb-daily-bar-container { + height: 200px; + display: flex; + align-items: flex-end; + gap: 8px; + padding-top: 24px; + padding-bottom: 24px; + position: relative; + border-bottom: 1px solid #e2e8f0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} + +.ehb-daily-avg-line { + position: absolute; + left: 0; + right: 0; + min-width: 100%; + border-top: 1.5px dashed #2f6bff; + opacity: 0.85; + pointer-events: none; + z-index: 5; +} + +.ehb-daily-avg-label { + position: sticky; + left: 8px; + top: -11px; + font-size: 11px; + font-weight: 600; + color: #1e40af; + background: #eff6ff; + border: 1px solid #93c5fd; + padding: 1px 8px; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + white-space: nowrap; +} + +.ehb-daily-bar-col { + flex: 1; + min-width: 18px; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + justify-content: flex-end; + position: relative; + cursor: pointer; +} + +.ehb-daily-bar-fill { + width: 100%; + max-width: 28px; + background: linear-gradient(180deg, #73a2ff 0%, #2f6bff 100%); + border-radius: 4px 4px 0 0; + transition: all 0.2s ease; + position: relative; +} + +.ehb-daily-bar-fill.is-stacked { + display: flex; + flex-direction: column; + overflow: hidden; + background: transparent; +} + +.ehb-daily-bar-fill.is-stacked.is-active { + box-shadow: 0 0 10px rgba(2, 132, 199, 0.5); +} + +.ehb-bar-segment { + width: 100%; + transition: all 0.2s ease; +} + +.ehb-bar-segment.is-ext { + background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%); + border-bottom: 1px solid rgba(255, 255, 255, 0.5); +} + +.ehb-bar-segment.is-own { + background: linear-gradient(180deg, #73a2ff 0%, #2f6bff 100%); +} + +.ehb-daily-bar-col:hover .ehb-bar-segment.is-ext { + filter: brightness(1.1); +} + +.ehb-daily-bar-col:hover .ehb-bar-segment.is-own { + filter: brightness(1.1); +} + +.ehb-daily-bar-val { + position: absolute; + top: -20px; + font-size: 10px; + color: var(--bi-muted); + font-family: var(--bi-font-mono); + white-space: nowrap; +} + +.ehb-daily-bar-label { + margin-top: 8px; + font-size: 10px; + color: var(--bi-tertiary); + font-family: var(--bi-font-mono); +} + +.ehb-daily-table-card { + background: #ffffff; + border-radius: var(--bi-radius); + border: 1px solid var(--bi-hairline); + padding: 16px; +} + +.ehb-daily-table-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.ehb-daily-table-title { + font-size: 14px; + font-weight: 700; + color: var(--bi-text-body); +} + +.ehb-title-sub { + font-size: 12px; + font-weight: 400; + color: var(--bi-tertiary); + margin-left: 4px; +} + +.ehb-show-h5 { + display: none !important; +} + +.ehb-hide-h5 { + display: inline !important; +} + +.ehb-export-btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + padding: 5px 12px; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; +} + +/* —— 钻取 Badge 标签 —— */ +.ehb-tag { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 1px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + line-height: 1.4; + cursor: help; +} + +.ehb-tag--self-use { + background: #eff6ff; + color: #2f6bff; + border: 1px solid #bfdbfe; +} + +.ehb-tag--ext-sale { + background: #f0fdf4; + color: #16a34a; + border: 1px solid #bbf7d0; +} + +/* 单站拆分内部/外部车辆加氢显示标签 */ +.ehb-station-cell { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; /* 加氢站名称与标签组垂直间距,舒适有呼吸感 */ + padding: 4px 0; +} + +.ehb-station-title-row { + display: inline-flex; + align-items: center; + font-weight: 600; + color: #0f172a; + line-height: 1.4; +} + +.ehb-arrow-icon { + margin-right: 6px; + color: #2f6bff; + display: inline-block; + width: 14px; +} + +.ehb-split-tag-group { + display: flex; + align-items: center; + gap: 6px 10px; /* 水平 10px,换行时垂直 6px */ + flex-wrap: wrap; +} + +.ehb-split-tag { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + padding: 3px 10px; + border-radius: 4px; + border: 1px solid transparent; + line-height: 1.4; + white-space: nowrap; +} + +.ehb-split-tag.is-own { + background: #f0f9ff; + border-color: #bae6fd; + color: #0369a1; +} + +.ehb-split-tag.is-ext { + background: #f8fafc; + border-color: #cbd5e1; + color: #475569; +} + +.ehb-split-tag__label { + font-weight: 600; + padding-right: 6px; + border-right: 1px solid rgba(0, 0, 0, 0.1); +} + +.ehb-split-tag__val { + font-family: var(--bi-font-mono); + font-weight: 700; +} + +.ehb-split-tag__price { + font-family: var(--bi-font-mono); + opacity: 0.88; +} + +.ehb-tag--own-fleet { + background: #f0fdf4; + color: #15803d; + border: 1px solid #bbf7d0; +} + +.ehb-tag--ext-fleet { + background: #f1f5f9; + color: #64748b; + border: 1px solid #e2e8f0; +} + +.ehb-tag--ext-cust { + background: #fff7ed; + color: #c2410c; + border: 1px solid #ffedd5; +} + +.ehb-tag--source-api { + background: #e0f2fe; + color: #0369a1; +} + +.ehb-tag--source-station { + background: #fff7ed; + color: #c2410c; +} + +.ehb-tag--source-lingniu { + background: #faf5ff; + color: #7e22ce; +} + +.ehb-tag--verify-ok { + background: #ecfdf5; + color: #047857; + border: 1px solid #a7f3d0; +} + +.ehb-tag--verify-partial { + background: #fff7ed; + color: #c2410c; + border: 1px solid #ffedd5; +} + +.ehb-tag--verify-warn { + background: #fffbeb; + color: #b45309; + border: 1px solid #fde68a; +} + +/* 穿透明细弹窗:蓝色只表示操作,普通数据与来源标签收敛为中性色。 */ +.ehb-drill-modal--quiet .ehb-modal-meta-val { + color: #1e293b !important; +} + +.ehb-drill-modal--quiet .ehb-modal-table td[style*="text-align: right"] { + color: #334155 !important; +} + +.ehb-drill-modal--quiet .ehb-tag--own-fleet, +.ehb-drill-modal--quiet .ehb-tag--ext-fleet, +.ehb-drill-modal--quiet .ehb-tag--ext-cust { + border: 1px solid #dce4ee; + background: #f3f6f9; + color: #52627a; +} + +.ehb-drill-modal--quiet .ehb-tag--source-api, +.ehb-drill-modal--quiet .ehb-tag--source-station, +.ehb-drill-modal--quiet .ehb-tag--source-lingniu { + border: 1px solid #d8e2ee; + background: #edf3f8; + color: #4f6580; +} + +.ehb-drill-modal--quiet .ehb-modal-table tbody tr[style*="background"] td { + background-color: inherit; +} + +.ehb-drill-modal--quiet .ehb-modal-table tbody tr:hover td { + background-color: #f4f7fb; +} + +/* 锚点闪烁高亮 */ +.is-highlight-target { + animation: ehbHighlightPulse 2s ease-out; +} + +@keyframes ehbHighlightPulse { + 0% { + background-color: rgba(59, 130, 246, 0.25); + box-shadow: inset 0 0 0 2px #3b82f6; + } + 100% { + background-color: transparent; + box-shadow: none; + } +} + +/* 总览视角:趋势图表大盘组件样式 */ +.ehb-overview-charts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 12px; + margin-bottom: 12px; +} + +.ehb-two-charts-row, +.ehb-sum-table-card { + grid-column: 1 / -1; + min-width: 0; +} + +.ehb-chart-box { + background: #ffffff; + border-radius: var(--bi-radius); + border: 1px solid var(--bi-hairline); + padding: 16px; + min-width: 0; +} + +.ehb-chart-box-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.ehb-chart-box-title { + font-size: 14px; + font-weight: 700; + color: var(--bi-text-body); +} + +.ehb-chart-box-meta { + font-size: 11px; + color: var(--bi-tertiary); +} + +.ehb-chart-legend-inline { + display: flex; + align-items: center; + gap: 14px; +} + +.ehb-chart-legend-tag { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + color: #475569; +} + +.ehb-legend-sq { + width: 10px; + height: 10px; + border-radius: 2px; +} + +.ehb-legend-sq.is-income { + background: #10b981; +} + +.ehb-legend-sq.is-cost { + background: #f59e0b; +} + +/* 月度加氢量柱状图 */ +.ehb-mbar-chart { + height: 160px; + display: flex; + align-items: flex-end; + gap: 12px; + padding-top: 20px; + padding-bottom: 20px; + border-bottom: 1px solid #f1f5f9; +} + +.ehb-mbar-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + justify-content: flex-end; + position: relative; + cursor: pointer; +} + +.ehb-mbar-val { + position: absolute; + top: -18px; + font-size: 11px; + font-weight: 600; + font-family: var(--bi-font-mono); + color: #475569; + white-space: nowrap; +} + +.ehb-mbar-fill { + width: 100%; + max-width: 42px; + background: linear-gradient(180deg, #73a2ff 0%, #2f6bff 100%); + border-radius: 4px 4px 0 0; + transition: all 0.2s ease; +} + +.ehb-mbar-col:hover .ehb-mbar-fill { + filter: brightness(1.1); + transform: scaleY(1.02); +} + +/* 柱状图 Hover 自定义浮动卡片 */ +.ehb-mbar-tooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%) translateY(4px); + background: rgba(15, 23, 42, 0.92); + backdrop-filter: blur(8px); + color: #ffffff; + padding: 8px 12px; + border-radius: 6px; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.2); + font-size: 11px; + white-space: nowrap; + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + z-index: 20; +} + +.ehb-mbar-col:hover .ehb-mbar-tooltip { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); +} + +.ehb-mbar-tooltip__head { + font-weight: 700; + font-size: 11px; + margin-bottom: 4px; + padding-bottom: 4px; + border-bottom: 1px solid rgba(255, 255, 255, 0.15); + color: #f8fafc; +} + +.ehb-mbar-tooltip__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + line-height: 1.6; +} + +.ehb-mbar-tooltip__left { + display: flex; + align-items: center; + gap: 6px; + color: #cbd5e1; +} + +.ehb-mbar-tooltip__dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.ehb-mbar-tooltip__dot.is-own { + background: #73a2ff; +} + +.ehb-mbar-tooltip__dot.is-ext { + background: #f59e0b; +} + +.ehb-mbar-tooltip__val { + font-weight: 700; + font-family: var(--bi-font-mono); + color: #ffffff; +} + +.ehb-mbar-label { + margin-top: 8px; + font-size: 11px; + color: #64748b; + font-family: var(--bi-font-mono); +} + +/* 月度收支对比图 */ +.ehb-rev-chart { + height: 160px; + display: flex; + align-items: flex-end; + gap: 16px; + padding-top: 20px; + padding-bottom: 20px; + border-bottom: 1px solid #f1f5f9; +} + +.ehb-rev-col-group { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + justify-content: flex-end; +} + +.ehb-rev-bars { + display: flex; + align-items: flex-end; + gap: 4px; + height: 100%; + width: 100%; + justify-content: center; +} + +.ehb-rev-bar { + width: 16px; + border-radius: 3px 3px 0 0; + transition: all 0.2s ease; + position: relative; + cursor: pointer; +} + +/* 客户收入 Hover 浮层,高保真显示 TOP9 客户 + 其他客户 */ +.ehb-rev-income-tooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%) translateY(4px); + background: rgba(15, 23, 42, 0.94); + backdrop-filter: blur(10px); + color: #ffffff; + padding: 10px 14px; + border-radius: 8px; + box-shadow: 0 12px 30px -5px rgba(0, 0, 0, 0.35), 0 8px 12px -6px rgba(0, 0, 0, 0.25); + font-size: 11px; + white-space: nowrap; + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + z-index: 30; + min-width: 270px; +} + +/* 成本支出 Hover 浮层,高保真显示包氢、物流、运维异动等成本项目 */ +.ehb-rev-cost-tooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%) translateY(4px); + background: rgba(15, 23, 42, 0.94); + backdrop-filter: blur(10px); + color: #ffffff; + padding: 10px 14px; + border-radius: 8px; + box-shadow: 0 12px 30px -5px rgba(0, 0, 0, 0.35), 0 8px 12px -6px rgba(0, 0, 0, 0.25); + font-size: 11px; + white-space: nowrap; + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + z-index: 30; + min-width: 220px; +} + +/* 避免最边缘的 1月 或 8月 弹窗被裁剪,靠近左侧靠左对齐,靠近右侧靠右对齐 */ +.ehb-rev-col-group:first-child .ehb-rev-income-tooltip, +.ehb-rev-col-group:first-child .ehb-rev-cost-tooltip { + left: 0; + transform: translateX(0) translateY(4px); +} +.ehb-rev-col-group:first-child .ehb-rev-bar.is-income:hover .ehb-rev-income-tooltip, +.ehb-rev-col-group:first-child .ehb-rev-bar.is-cost:hover .ehb-rev-cost-tooltip { + transform: translateX(0) translateY(0); +} + +.ehb-rev-col-group:nth-last-child(-n + 2) .ehb-rev-income-tooltip, +.ehb-rev-col-group:nth-last-child(-n + 2) .ehb-rev-cost-tooltip { + left: auto; + right: 0; + transform: translateX(0) translateY(4px); +} +.ehb-rev-col-group:nth-last-child(-n + 2) .ehb-rev-bar.is-income:hover .ehb-rev-income-tooltip, +.ehb-rev-col-group:nth-last-child(-n + 2) .ehb-rev-bar.is-cost:hover .ehb-rev-cost-tooltip { + transform: translateX(0) translateY(0); +} + +.ehb-rev-bar.is-income:hover .ehb-rev-income-tooltip, +.ehb-rev-bar.is-cost:hover .ehb-rev-cost-tooltip { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); +} + +.ehb-rev-income-tooltip__head { + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 700; + font-size: 11px; + margin-bottom: 6px; + padding-bottom: 5px; + border-bottom: 1px solid rgba(255, 255, 255, 0.15); + color: #34d399; +} + +.ehb-rev-cost-tooltip__head { + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 700; + font-size: 11px; + margin-bottom: 6px; + padding-bottom: 5px; + border-bottom: 1px solid rgba(255, 255, 255, 0.15); + color: #fbbf24; +} + +.ehb-rev-cost-tooltip__list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ehb-rev-cost-tooltip__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + line-height: 1.5; +} + +.ehb-rev-cost-tooltip__tag { + display: inline-flex; + align-items: center; + gap: 6px; + color: #cbd5e1; + font-weight: 500; +} + +.ehb-rev-cost-tooltip__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #f59e0b; +} + +.ehb-rev-cost-tooltip__val { + font-weight: 700; + font-family: var(--bi-font-mono); + color: #ffffff; +} + +.ehb-rev-cost-tooltip__foot { + margin-top: 6px; + padding-top: 5px; + border-top: 1px dashed rgba(255, 255, 255, 0.18); + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 700; +} + +.ehb-rev-income-tooltip__list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ehb-rev-income-tooltip__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + line-height: 1.5; +} + +.ehb-rev-income-tooltip__cust-name { + color: #cbd5e1; + font-weight: 500; + max-width: 175px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ehb-rev-income-tooltip__cust-name.is-other { + color: #94a3b8; + font-style: italic; +} + +.ehb-rev-income-tooltip__cust-val { + font-weight: 700; + font-family: var(--bi-font-mono); + color: #ffffff; +} + +.ehb-rev-income-tooltip__foot { + margin-top: 6px; + padding-top: 5px; + border-top: 1px dashed rgba(255, 255, 255, 0.18); + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 700; +} + +.ehb-rev-bar.is-cost { + background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%); +} + +.ehb-rev-bar.is-income { + background: linear-gradient(180deg, #34d399 0%, #10b981 100%); +} + +.ehb-rev-label { + margin-top: 8px; + font-size: 11px; + color: #64748b; + font-family: var(--bi-font-mono); +} + +/* 两图排布行 */ +.ehb-two-charts-row { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +/* 汇总大表卡片 (加氢站加氢汇总 & 客户账单汇总) */ +.ehb-sum-table-card { + background: #ffffff; + border-radius: var(--bi-radius); + border: 1px solid var(--bi-hairline); + padding: 18px; + margin-top: 0; +} + +.ehb-sum-table-card__head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.ehb-sum-table-card__title { + font-size: 14px; + font-weight: 700; + color: var(--bi-text-body); +} + +.ehb-sum-table-card__meta { + font-size: 11px; + font-weight: 600; + color: #64748b; + font-family: var(--bi-font-mono); +} + +.ehb-sum-table-wrap { + width: 100%; + max-width: 100%; + overflow-x: auto; +} + +.ehb-sum-table { + width: 100%; + min-width: 960px; + border-collapse: collapse; + text-align: left; +} + +.ehb-sum-table th { + font-size: 11px; + font-weight: 600; + color: #64748b; + padding: 10px 12px; + border-bottom: 1px solid #f1f5f9; + white-space: nowrap; +} + +.ehb-sum-table td { + font-size: 12px; + color: #334155; + padding: 10px 12px; + border-bottom: 1px solid #f8fafc; + white-space: nowrap; +} + +.ehb-sum-table tr:hover td { + background-color: #f8fafc; +} + +.ehb-sum-table .col-idx { + width: 40px; + text-align: center; + color: #94a3b8; + font-family: var(--bi-font-mono); + font-size: 11px; +} + +.ehb-sum-table .col-bold-kg { + font-weight: 700; + font-family: var(--bi-font-mono); + color: #0f172a; +} + +.ehb-sum-table .col-green-fee { + font-weight: 700; + color: #10b981; + font-family: var(--bi-font-mono); +} + +.ehb-sum-table .col-orange-cost { + font-weight: 700; + color: #f59e0b; + font-family: var(--bi-font-mono); +} + +.ehb-stay-tuned-tag { + display: inline-block; + font-size: 11px; + font-weight: 500; + color: #64748b; + background: #f1f5f9; + border: 1px dashed #cbd5e1; + border-radius: 4px; + padding: 1px 6px; + cursor: help; + transition: all 0.2s ease; +} + +.ehb-stay-tuned-tag:hover { + color: #533afd; + background: #f0f0ff; + border-color: #a5b4fc; +} + +/* KPI 数据来源穿透 Modal 弹窗 */ +.ehb-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(15, 23, 42, 0.7); + backdrop-filter: blur(8px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + animation: ehbFadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes ehbFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.ehb-modal-card { + background: #ffffff; + border-radius: 12px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35); + width: 100%; + max-width: 1100px; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(226, 232, 240, 0.8); + animation: ehbSlideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes ehbSlideUp { + from { opacity: 0; transform: translateY(16px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.ehb-modal-head { + padding: 16px 20px; + background: #0f172a; + color: #ffffff; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.ehb-modal-head__title-group { + display: flex; + align-items: center; + gap: 10px; +} + +.ehb-modal-head__title { + font-size: 16px; + font-weight: 700; + color: #f8fafc; + display: flex; + align-items: center; + gap: 8px; +} + +.ehb-modal-head__sub { + font-size: 12px; + color: #b7c4d8; + margin-top: 4px; + font-family: var(--bi-font-mono); + font-variant-numeric: tabular-nums; + line-height: 1.25; +} + +.ehb-modal-back-btn { + display: inline-flex; + align-items: center; + gap: 4px; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #f8fafc; + padding: 5px 10px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + margin-right: 8px; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.ehb-modal-back-btn:hover { + background: rgba(56, 189, 248, 0.2); + border-color: #73a2ff; + color: #73a2ff; +} + +.ehb-modal-head__actions { + display: flex; + align-items: center; + gap: 12px; +} + +.ehb-modal-close-btn { + background: rgba(255, 255, 255, 0.1); + border: none; + color: #cbd5e1; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; +} + +.ehb-modal-close-btn:hover { + background: rgba(239, 68, 68, 0.8); + color: #ffffff; +} + +.ehb-modal-body { + padding: 20px; + overflow-y: auto; + flex: 1; + background: #f8fafc; +} + +.ehb-modal-meta-bar { + background: #ffffff; + border-radius: 8px; + border: 1px solid #e2e8f0; + padding: 14px 18px; + margin-bottom: 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.ehb-mobile-drill-overview { display: none; } +.ehb-mobile-drill-overview__metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } + +.ehb-modal-meta-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ehb-modal-meta-label { + font-size: 11px; + color: #64748b; +} + +.ehb-modal-meta-val { + font-size: 16px; + font-weight: 800; + color: #0f172a; + font-family: var(--bi-font-mono); +} + +.ehb-modal-filter-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; + background: #ffffff; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid #e2e8f0; + flex-wrap: wrap; +} + +.ehb-modal-filter-group { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.ehb-drill-period-controls, +.ehb-drill-date-range { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} + +.ehb-drill-period-controls select, +.ehb-drill-period-controls input { + height: 34px; + box-sizing: border-box; + border: 1px solid #d5dfeb; + border-radius: 7px; + background: #fff; + color: #334155; + font: 600 12px/1 var(--bi-font); + padding: 0 9px; +} + +.ehb-drill-period-controls select { width: 122px; } +.ehb-drill-period-controls input[type='month'] { width: 122px; } +.ehb-drill-period-controls input[type='date'] { width: 126px; } +.ehb-drill-date-range > span { color: #7b8aa1; font-size: 11px; } + +.ehb-drill-period-controls select:focus, +.ehb-drill-period-controls input:focus { + border-color: #7fa5ef; + outline: 2px solid rgba(47, 107, 255, 0.12); +} + +.ehb-drill-period-select { + position: relative; + flex: 0 0 122px; + width: 122px; +} + +.ehb-drill-period-trigger { + display: flex; + width: 100%; + height: 34px; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 10px; + border: 1px solid #d5dfeb; + border-radius: 7px; + background: #fff; + color: #334155; + font: 600 12px/1 var(--bi-font); + white-space: nowrap; + cursor: pointer; +} + +.ehb-drill-period-trigger > span { + white-space: nowrap; +} + +.ehb-drill-period-menu { + position: absolute; + z-index: 30; + top: calc(100% + 4px); + left: 0; + width: max(100%, 132px); + overflow: hidden; + border: 1px solid #d5dfeb; + border-radius: 8px; + background: #fff; + box-shadow: 0 10px 24px rgba(28, 48, 78, .16); +} + +.ehb-drill-period-menu button { + display: block; + width: 100%; + min-height: 34px; + padding: 0 10px; + border: 0; + background: #fff; + color: #40516b; + font: 600 12px/1 var(--bi-font); + text-align: left; + cursor: pointer; +} + +.ehb-drill-period-menu button:hover, +.ehb-drill-period-menu button.is-selected { + background: #eef4ff; + color: #2f6bff; +} + +/* 全局钻取弹窗:统一布局密度与颜色角色。 */ +.ehb-drill-modal--unified .ehb-modal-body { + padding: 18px; + background: #f6f8fb; +} + +.ehb-drill-modal--unified .ehb-modal-meta-bar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + align-items: stretch; + gap: 0; + padding: 0; + overflow: hidden; + background: #fff; +} + +.ehb-drill-modal--unified .ehb-modal-meta-item { + min-width: 0; + padding: 12px 16px; + border-inline-end: 1px solid #edf1f6; +} + +.ehb-drill-modal--unified .ehb-modal-meta-item:last-child { + border-inline-end: 0; +} + +.ehb-drill-modal--unified .ehb-modal-meta-val { + color: #1e293b !important; +} + +.ehb-drill-modal--unified .ehb-station-core-metrics { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.ehb-station-core-metrics .ehb-metric-divider { + color: #a3afc0; + font-weight: 500; +} + +.ehb-drill-modal--unified .ehb-modal-filter-row { + gap: 8px; + padding: 8px 12px; + margin-bottom: 12px; +} + +.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: #dfe7f0; + border-radius: 8px; + background: #fff; +} + +.ehb-drill-modal--unified .ehb-modal-table th { + height: 38px; + padding-block: 8px; + background: #f1f4f8; + color: #52627a; +} + +.ehb-drill-modal--unified .ehb-modal-table > tbody > tr > td { + height: 40px; + padding-block: 8px; + border-bottom-color: #edf1f5; +} + +.ehb-drill-modal--unified .ehb-modal-table td[style*="text-align: right"] { + color: #334155 !important; +} + +.ehb-drill-modal--unified .ehb-day-change.is-up { color: #059669 !important; } +.ehb-drill-modal--unified .ehb-day-change.is-down { color: #dc2626 !important; } + +.ehb-drill-modal--unified .ehb-key-volume, +.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-volume, +.ehb-drill-modal--unified .ehb-station-detail-table td.ehb-key-volume { + color: #2f6bff !important; +} + +.ehb-drill-modal--unified .ehb-key-income, +.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-income, +.ehb-drill-modal--unified .ehb-station-detail-table td.ehb-key-income { + color: #2c8a78 !important; +} + +.ehb-drill-modal--unified .ehb-station-detail-row > td { + padding: 0 10px 10px !important; +} + +.ehb-drill-modal--unified .ehb-station-detail-table th, +.ehb-drill-modal--unified .ehb-station-detail-table td { + height: 34px; + padding: 7px 10px; +} + +.ehb-drill-modal--unified .ehb-station-detail-table th { + border-bottom: 1px solid #cbd7e6; + background: #e6edf6; + color: #344a67; + font-weight: 700; + box-shadow: inset 0 -1px 0 #cbd7e6; +} + +.ehb-drill-modal--unified .ehb-station-detail-table { + table-layout: fixed; +} + +.ehb-drill-modal--unified .ehb-station-detail-table td:nth-child(2) { + overflow: hidden; + text-overflow: ellipsis; +} + +.ehb-vehicle-time-stack { + display: inline-flex; + min-width: 0; + flex-direction: column; + align-items: flex-start; + gap: 2px; + line-height: 1.15; +} + +.ehb-vehicle-time-stack strong { + color: #1e293b; + font-size: 12px; + font-weight: 650; + white-space: nowrap; +} + +.ehb-vehicle-time-stack small { + color: #8796ac; + font-family: var(--bi-font-mono); + font-size: 10px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.ehb-tree-node-title--vehicle-time { align-items: flex-start; } + +/* 穿透筛弱提示:灰色小字 */ +.ehb-modal-hint-text { + font-size: 11px; + font-weight: 400; + color: var(--bi-tertiary, #94a3b8); + line-height: 1.4; + margin: 0; +} + +.ehb-modal-filter-group > .ehb-modal-hint-text { + flex: 1; + min-width: 180px; +} + +.ehb-modal-hint-text strong { + color: inherit; + font-weight: 400; +} + +.ehb-order-more-row { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.ehb-order-more-btn { + color: #2f6bff; + cursor: pointer; + border: 1px solid #bae6fd; + background: #f0f9ff; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + font-family: inherit; + flex-shrink: 0; +} + +.ehb-order-more-btn:hover { + background: #e0f2fe; + border-color: #7dd3fc; +} + +.ehb-order-more-hint { + font-size: 11px; + font-weight: 400; + color: var(--bi-tertiary, #94a3b8); + line-height: 1.4; +} + +.ehb-modal-search-input { + display: inline-flex; + align-items: center; + gap: 6px; + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 6px; + padding: 4px 10px; + height: 32px; + font-size: 12px; + width: 220px; + transition: all 0.2s; + box-sizing: border-box; +} + +.ehb-modal-search-input:focus-within { + border-color: #2f6bff; + box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.15); +} + +.ehb-modal-search-input input { + border: none !important; + outline: none !important; + background: transparent !important; + flex: 1; + min-width: 0; + padding: 0 !important; + margin: 0 !important; + font-size: 12px; + color: #0f172a; + font-family: inherit; + box-shadow: none !important; +} + +.ehb-modal-search-input button { + border: none; + background: transparent; + padding: 0; + margin: 0; + color: #94a3b8; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ehb-modal-search-input button:hover { + color: #ef4444; +} + +.ehb-modal-select { + border: 1px solid #cbd5e1; + border-radius: 6px; + padding: 0 8px; + height: 32px; + font-size: 12px; + color: #334155; + background: #ffffff; + min-width: 140px; + outline: none; + cursor: pointer; + transition: all 0.2s; + box-sizing: border-box; +} + +/* BI 可搜索选择器(穿透筛 · 非 V2) */ +.ehb-bi-search-select { + position: relative; + flex-shrink: 0; +} + +.ehb-bi-search-select.is-disabled { + opacity: 0.55; + pointer-events: none; +} + +.ehb-bi-search-select__trigger { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 6px; + width: 100%; + height: 32px; + padding: 0 10px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + font-size: 12px; + color: #64748b; + cursor: pointer; + box-sizing: border-box; +} + +.ehb-bi-search-select__trigger.has-value { + color: #0f172a; +} + +.ehb-bi-search-select__trigger.is-open, +.ehb-bi-search-select__trigger:hover { + border-color: #2f6bff; +} + +.ehb-bi-search-select__trigger.is-open { + box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.15); +} + +.ehb-bi-search-select__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + flex: 1; + min-width: 0; +} + +.ehb-bi-search-select__chevron { + flex-shrink: 0; + color: #64748b; +} + +.ehb-bi-search-select__dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + min-width: 100%; + z-index: 40; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); + overflow: hidden; +} + +.ehb-bi-search-select__search { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-bottom: 1px solid #e2e8f0; + color: #94a3b8; +} + +.ehb-bi-search-select__search input { + flex: 1; + min-width: 0; + border: none !important; + outline: none !important; + background: transparent !important; + box-shadow: none !important; + font-size: 12px; + color: #0f172a; + padding: 0 !important; + margin: 0 !important; + font-family: inherit; +} + +.ehb-bi-search-select__list { + max-height: 220px; + overflow-y: auto; + padding: 4px; +} + +.ehb-bi-search-select__item { + display: block; + width: 100%; + text-align: left; + border: none; + background: transparent; + padding: 8px 10px; + border-radius: 6px; + font-size: 12px; + color: #334155; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ehb-bi-search-select__item:hover { + background: #f1f5f9; +} + +.ehb-bi-search-select__item.is-selected { + background: #e0f2fe; + color: #0369a1; + font-weight: 600; +} + +.ehb-bi-search-select__empty { + padding: 12px 10px; + font-size: 12px; + color: #94a3b8; + text-align: center; +} + +.ehb-modal-select:focus { + border-color: #2f6bff; + box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.15); +} + +/* Modal 内可钻取 Tree Table */ +.ehb-modal-table-wrap { + background: #ffffff; + border-radius: 8px; + border: 1px solid #e2e8f0; + overflow: hidden; +} + +.ehb-modal-table-wrap.is-v-scroll { + max-height: min(52vh, 440px); + overflow-y: auto; +} + +.ehb-modal-table { + width: 100%; + border-collapse: collapse; + text-align: left; +} + +.ehb-modal-table th { + background: #f1f5f9; + font-size: 11px; + font-weight: 700; + color: #475569; + padding: 10px 12px; + border-bottom: 1px solid #e2e8f0; + white-space: nowrap; +} + +.ehb-modal-table td { + padding: 10px 12px; + font-size: 12px; + border-bottom: 1px solid #f1f5f9; + white-space: nowrap; +} + +/* Modal 树形表格层级与 H5 换行多行适配 */ +.ehb-modal-table th:first-child, +.ehb-modal-table td:first-child { + min-width: 240px; +} + +.ehb-tree-node-title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 6px; + line-height: 1.4; +} + +.ehb-tree-node-sub { + font-size: 11px; + color: #64748b; + font-weight: 400; + white-space: normal; +} + +.ehb-tree-ord-block { + display: flex; + flex-direction: column; + gap: 2px; + line-height: 1.3; +} + +.ehb-tree-ord-time { + font-size: 10px; + color: #64748b; + font-weight: 400; +} + +.ehb-tree-cell-l1 { padding-left: 12px; } +.ehb-tree-cell-l2 { padding-left: 28px; } +.ehb-tree-cell-l3 { padding-left: 44px; } +.ehb-tree-cell-l4 { padding-left: 60px; } + +.ehb-drill-filter-summary, +.ehb-order-mobile-summary { + display: none; +} + +.ehb-drill-filter-summary-row, +.ehb-recent-kpis { + display: contents; +} + +.ehb-modal-head__actions .mobile-list-fullscreen-trigger { + position: static; +} + +.ehb-modal-table tr:hover td { + background-color: #f8fafc; +} + +.ehb-station-day-row { + background: #f8fafc; + font-weight: 600; + cursor: pointer; +} + +.ehb-day-change { + font-family: var(--bi-font-mono); + font-weight: 700; + color: #94a3b8; +} + +.ehb-day-change.is-up { color: #059669; } +.ehb-day-change.is-down { color: #dc2626; } + +.ehb-station-detail-row > td { + padding: 0 12px 12px !important; + background: #f8fafc !important; +} + +.ehb-station-detail-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + border: 1px solid #e2e8f0; + border-radius: 8px; + overflow: hidden; + background: #fff; +} + +.ehb-station-detail-table th, +.ehb-station-detail-table td { + padding: 9px 12px; + border-bottom: 1px solid #edf2f7; + background: #fff; + color: #475569; + font-size: 11px; + text-align: left; + white-space: nowrap; +} + +.ehb-station-detail-table th { + border-bottom: 1px solid #cbd7e6; + background: #e6edf6; + color: #344a67; + font-weight: 700; +} + +.ehb-station-detail-table tbody tr:last-child td { border-bottom: 0; } + +/* KPI 点击下钻提示图标/按钮 */ +.ehb-kpi-drill-hint { + font-size: 10px; + color: var(--oneos-primary, #533afd); + background: rgba(83, 58, 253, 0.08); + padding: 2px 6px; + border-radius: 4px; + font-weight: 600; + margin-left: 6px; + display: inline-flex; + align-items: center; + gap: 2px; + transition: all 0.2s ease; +} + +.ehb-kpi-dual:hover .ehb-kpi-drill-hint { + background: #533afd; + color: #ffffff; +} + +.ehb-kpi-dual { + cursor: pointer; + transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.ehb-kpi-dual:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px -2px rgba(83, 58, 253, 0.15); +} + +/* 迷你比例进度条 */ +.ehb-ratio-flex { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.ehb-mini-bar-track { + width: 60px; + height: 5px; + background: #f1f5f9; + border-radius: 3px; + overflow: hidden; + position: relative; +} + +.ehb-mini-bar-fill { + height: 100%; + border-radius: 3px; +} + +.ehb-mini-bar-fill.is-blue { + background: #2f6bff; +} + +.ehb-mini-bar-fill.is-green { + background: #10b981; +} + +.ehb-ratio-text { + font-size: 11px; + font-family: var(--bi-font-mono); + color: #475569; + min-width: 42px; + text-align: right; +} + +/* 承担方 Badge */ +.ehb-bearer-tag { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 34px; + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + line-height: 1.4; + white-space: nowrap; +} + +.ehb-modal-table .ehb-bearer-col { + width: 56px; + min-width: 56px; + max-width: 56px; + padding-inline: 4px !important; +} + +.ehb-bearer-tag.is-cust { + color: #d97706; + background: #fffbe3; + border: 1px solid #fde68a; +} + +.ehb-bearer-tag.is-lingniu { + color: #2f6bff; + background: #eff6ff; + border: 1px solid #bfdbfe; +} + +.ehb-bearer-tag.is-company { + color: #2f6bff; + background: #eff6ff; + border: 1px solid #bfdbfe; +} + +.ehb-bearer-tag.is-customer { + color: #b45309; + background: #fffbeb; + border: 1px solid #fde68a; +} + +.ehb-bearer-tag.is-pending { + color: #64748b; + background: #f1f5f9; + border: 1px solid #cbd5e1; +} + +/* Top5 站条形图 */ +.ehb-top-stations-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* 新版补齐:三类承担方式、五项 KPI 及可切换下钻维度。 */ +.ehb-kpi-dual__deck.is-three { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.ehb-kpi-dual__deck.is-three > span { + display: flex; + min-width: 0; + flex-direction: column; +} + +.ehb-kpi-dual__deck.is-three small { + overflow: hidden; + font-size: 10px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ehb-kpi-dual__deck.is-three strong { + overflow: hidden; + color: var(--bi-text-body); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ehb-drill-axis { + width: fit-content; + margin: 0 0 10px auto; +} + +@media (min-width: 1181px) { + .ehb-metric-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } +} + +@media (min-width: 768px) { + .ehb-host-kpi > .ehb-kpi-dual:nth-child(5) { display: flex; } + .ehb-host-kpi .ehb-kpi-dual:nth-child(5) { + min-height: 146px; + padding: 14px 16px; + border: 1px solid #dce5f0; + border-radius: 14px; + background: #fff; + } +} + +@media (max-width: 767px) { + .ehb-host-kpi > .ehb-kpi-dual:nth-child(1), + .ehb-host-kpi > .ehb-kpi-dual:nth-child(2) { min-height: 150px; } + .ehb-host-kpi > .ehb-kpi-dual:nth-child(n+3) { + display: flex; + min-height: 92px; + padding: 10px 12px; + } + + .ehb-host-kpi > .ehb-kpi-dual:nth-child(5) { grid-column: 1 / -1; } + .ehb-host-kpi > .ehb-kpi-dual:nth-child(n+3) .ehb-kpi-dual__num { font-size: 20px; } + .ehb-drill-axis { width: 100%; margin: 0 0 10px; } + .ehb-drill-axis button { flex: 1; } +} + +.ehb-top-station-item { + display: flex; + align-items: center; + gap: 10px; +} + +.ehb-top-rank { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + background: #2f6bff; + color: #ffffff; + font-size: 11px; + font-weight: 700; + font-family: var(--bi-font-mono); + flex-shrink: 0; +} + +.ehb-top-rank.is-sub { + background: #94a3b8; +} + +.ehb-top-station-name { + font-size: 12px; + font-weight: 600; + color: #1e293b; + width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 0; +} + +.ehb-top-bar-bg { + flex: 1; + height: 12px; + background: #f1f5f9; + border-radius: 6px; + overflow: visible; + position: relative; +} + +.ehb-top-bar-fill { + height: 100%; + display: flex; + overflow: hidden; + border-radius: 6px; + transition: width 0.3s ease; + position: relative; +} + +.ehb-top-bar-seg { + height: 100%; + transition: width 0.2s ease; +} + +.ehb-top-bar-seg.is-own { + background: linear-gradient(90deg, #73a2ff 0%, #2f6bff 100%); +} + +.ehb-top-bar-seg.is-ext { + background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%); +} + +/* Top5 站加氢量横向 Hover 自定义悬浮卡 */ +.ehb-top-bar-tooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%) translateY(4px); + background: rgba(15, 23, 42, 0.94); + backdrop-filter: blur(10px); + color: #ffffff; + padding: 8px 12px; + border-radius: 6px; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.35); + font-size: 11px; + white-space: nowrap; + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + z-index: 30; + min-width: 210px; +} + +.ehb-top-bar-bg:hover .ehb-top-bar-tooltip, +.ehb-top-station-item:hover .ehb-top-bar-tooltip { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); +} + +.ehb-top-bar-tooltip__head { + font-weight: 700; + color: #73a2ff; + font-size: 11px; + margin-bottom: 4px; + padding-bottom: 4px; + border-bottom: 1px solid rgba(255, 255, 255, 0.15); +} + +.ehb-top-bar-tooltip__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + line-height: 1.6; +} + +.ehb-top-bar-tooltip__left { + display: flex; + align-items: center; + gap: 6px; + color: #cbd5e1; +} + +.ehb-top-bar-tooltip__dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.ehb-top-bar-tooltip__dot.is-own { + background: #73a2ff; +} + +.ehb-top-bar-tooltip__dot.is-ext { + background: #f59e0b; +} + +.ehb-top-bar-tooltip__val { + font-weight: 700; + font-family: var(--bi-font-mono); + color: #ffffff; +} + +.ehb-top-bar-tooltip__foot { + margin-top: 4px; + padding-top: 4px; + border-top: 1px dashed rgba(255, 255, 255, 0.15); + display: flex; + align-items: center; + justify-content: space-between; + color: #cbd5e1; + font-weight: 700; +} + +.ehb-top-station-val { + font-size: 12px; + font-weight: 700; + font-family: var(--bi-font-mono); + color: #0f172a; + width: 70px; + text-align: right; + flex-shrink: 0; +} + +/* 迷你切换分段页签 (如:按省 / 按市) */ +.ehb-mini-tabs { + display: inline-flex; + align-items: center; + background: #f1f5f9; + border-radius: 6px; + padding: 2px; + gap: 2px; +} + +.ehb-mini-tab { + min-height: 36px; + border: none; + background: transparent; + padding: 2px 10px; + font-size: 11px; + font-weight: 500; + color: #64748b; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; +} + +.ehb-mini-tab.is-active { + background: #ffffff; + color: #2f6bff; + font-weight: 700; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +/* 区域占比 Donut */ +.ehb-donut-section { + display: flex; + align-items: center; + justify-content: center; + gap: 32px; +} + +.ehb-donut-chart-wrap { + position: relative; + width: 130px; + height: 130px; + flex-shrink: 0; +} + +.ehb-donut-center-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; +} + +.ehb-donut-center-text .title { + font-size: 10px; + color: #64748b; +} + +.ehb-donut-center-text .val { + font-size: 13px; + font-weight: 800; + color: #0f172a; + font-family: var(--bi-font-mono); +} + +.ehb-region-legend-grid { + display: grid; + grid-template-columns: repeat(2, minmax(150px, 180px)); + justify-content: start; + gap: 10px 24px; +} + +.ehb-region-legend-item { + display: grid; + grid-template-columns: minmax(76px, auto) 44px; + align-items: center; + justify-content: start; + column-gap: 10px; + font-size: 11px; +} + +.ehb-region-legend-left { + display: flex; + align-items: center; + gap: 6px; + color: #334155; +} + +.ehb-region-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.ehb-region-legend-val { + min-width: 44px; + text-align: right; + font-weight: 700; + font-family: var(--bi-font-mono); + color: #0f172a; +} + +@media (max-width: 1100px) { + .ehb-overview-charts { + grid-template-columns: minmax(0, 1fr); + } + .ehb-two-charts-row { + grid-template-columns: minmax(0, 1fr); + } +} + +.ehb-h5-scroll-hint { + display: none; +} + +@media (max-width: 767px) { + .ehb-drill-filter-summary { + display: flex; + width: 100%; + min-height: 54px; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 0 0 10px; + padding: 9px 12px; + border: 1px solid #d7e1ef; + border-radius: 12px; + background: #fff; + color: #1e293b; + text-align: left; + } + + .ehb-drill-filter-summary__content { + display: grid; + min-width: 0; + gap: 2px; + } + + .ehb-drill-filter-summary__label { + color: #7183a0; + font-size: 10px; + font-weight: 600; + } + + .ehb-drill-filter-summary__content strong { + overflow: hidden; + color: #1e293b; + font-size: 12px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; + } + + .ehb-drill-filter-summary__action { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 4px; + color: #2f6bff; + font-size: 11px; + font-weight: 600; + } + + .ehb-drill-filter-summary__action svg { transition: transform .18s ease; } + .ehb-drill-filter-summary__action svg.is-open { transform: rotate(180deg); } + + .ehb-drill-filter-panel:not(.is-open) { + display: none !important; + } + + .ehb-drill-filter-panel.is-open { + display: flex; + margin-bottom: 10px; + } + + .ehb-order-desktop-primary { display: none; } + + .ehb-order-mobile-summary { + display: grid; + min-width: 270px; + gap: 7px; + white-space: normal; + } + + .ehb-order-mobile-main, + .ehb-order-mobile-secondary { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + .ehb-order-mobile-main { color: #1e293b; } + .ehb-order-mobile-secondary { + color: #7183a0; + font-size: 10px; + overflow-wrap: anywhere; + } + + .ehb-order-detail-cell { min-width: 300px; } + + .ehb-modal-head__actions { + display: inline-flex; + align-items: center; + gap: 6px; + } + + .ehb-modal-head__actions .mobile-list-fullscreen-trigger { + position: static; + min-width: 84px; + height: 32px; + } + + .ehb-rail { + display: none; + } + .ehb-body { + padding: 10px 10px 24px; + } + .ehb-chrome { + margin: -6px -6px 10px; + padding: 10px; + align-items: center; + gap: 10px; + } + .ehb-chrome__lead { + display: flex; + flex: 1; + min-width: 0; + flex-direction: row; + align-items: center !important; + gap: 10px !important; + } + .ehb-mobile-brand-icon { + display: inline-flex; + width: 38px; + height: 38px; + flex: 0 0 38px; + align-items: center; + justify-content: center; + border-radius: 10px; + color: #fff; + background: var(--bi-blue); + } + .ehb-chrome__identity { + min-width: 0; + } + .ehb-crumb { + display: none; + } + .ehb-chrome__lead h1 { + font-size: 18px; + line-height: 1.25; + } + .ehb-chrome__tools { + width: auto; + flex: 0 0 auto; + } + .ehb-scope-switches { + width: auto; + align-items: center; + flex-direction: row; + gap: 0; + } + .ehb-view-switches { + display: none; + } + .ehb-scope-switches .ehb-switch-label { + display: none; + } + .ehb-scope-switches .ehb-seg { + width: auto; + min-width: 132px; + border-radius: 9px; + } + .ehb-scope-switches .ehb-seg button { + min-height: 32px; + height: 32px; + padding: 0 11px; + font-size: 13px; + } + .ehb-time-range-pill { + max-width: 230px; + margin-top: 2px; + padding: 0 !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; + color: var(--bi-muted) !important; + font-size: 11px !important; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .ehb-mobile-filter-panel { + display: block; + width: 100%; + } + .ehb-mobile-view-mode { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 12px; + margin-bottom: 8px; + } + .ehb-mobile-view-mode > span { + color: #475569; + font-size: 12px; + font-weight: 700; + } + .ehb-mobile-view-mode > div { + display: grid; + min-height: 40px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px; + padding: 3px; + border-radius: 10px; + background: #eef3f9; + } + .ehb-mobile-view-mode button { + min-height: 34px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--bi-muted); + font-size: 13px; + font-weight: 700; + } + .ehb-mobile-view-mode button.is-active { + background: #fff; + color: var(--bi-blue); + box-shadow: var(--bi-shadow-sm); + } + .ehb-mobile-primary-filters { + margin-bottom: 8px; + } + .ehb-mobile-overview-quick { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + } + .ehb-mobile-overview-quick .ehb-year-select-btn, + .ehb-mobile-overview-quick .ehb-pill-tabs { + min-height: 40px; + } + .ehb-mobile-filter-bar { + display: flex; + box-sizing: border-box; + width: 100%; + min-height: 48px; + align-items: center; + gap: 7px; + padding: 5px 7px 5px 10px; + border: 1px solid #dbe4ee; + border-radius: 11px; + background: #fff; + } + .ehb-mobile-filter-trigger { + display: flex; + min-width: 0; + flex: 1; + min-height: 48px; + align-items: center; + gap: 9px; + padding: 0; + border: 0; + background: transparent; + color: var(--bi-text-body); + text-align: left; + } + .ehb-mobile-filter-icon { + display: inline-flex; + width: 28px; + height: 28px; + flex: 0 0 28px; + align-items: center; + justify-content: center; + border-radius: 8px; + background: var(--bi-blue-soft); + color: var(--bi-blue); + } + .ehb-mobile-filter-copy { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 1px; + } + .ehb-mobile-filter-copy strong { + font-size: 13px; + line-height: 1.2; + } + .ehb-mobile-filter-copy small { + overflow: hidden; + color: var(--bi-muted); + font-size: 11px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; + } + .ehb-mobile-filter-trigger > svg { + flex: 0 0 auto; + transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1); + } + .ehb-mobile-filter-trigger > svg.is-open { + transform: rotate(180deg); + } + .ehb-mobile-filter-body { + margin-top: 8px; + padding: 12px; + border: 1px solid var(--bi-hairline); + border-radius: 11px; + background: #fff; + } + .ehb-mobile-filter-field { + display: flex; + flex-direction: column; + gap: 7px; + margin-bottom: 12px; + } + .ehb-mobile-filter-field > span { + color: #475569; + font-size: 12px; + font-weight: 700; + } + .ehb-mobile-daily-presets { + display: grid !important; + grid-template-columns: repeat(4, minmax(0, 1fr)); + width: 100%; + } + .ehb-mobile-daily-presets .ehb-pill-btn { + min-width: 0; + padding: 0 4px !important; + } + .ehb-mobile-date-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 12px; + } + .ehb-mobile-date-fields .ehb-daily-date-picker-wrapper, + .ehb-mobile-date-fields .ehb-daily-date-picker { + width: 100%; + min-width: 0; + } + .ehb-mobile-refresh { + display: inline-flex; + min-height: 40px; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 12px; + border: 1px solid var(--bi-hairline); + border-radius: 9px; + background: #fff; + color: var(--bi-muted); + font-size: 13px; + font-weight: 700; + } + .ehb-mobile-refresh:disabled { + cursor: wait; + opacity: 0.72; + } + .ehb-mobile-view-filter { + display: grid; + height: 44px; + grid-template-columns: 1fr 1fr; + gap: 3px; + padding: 3px; + border-radius: 9px; + background: #f1f5f9; + } + .ehb-mobile-view-filter button { + border: 0; + border-radius: 7px; + background: transparent; + color: var(--bi-muted); + font-size: 13px; + font-weight: 700; + } + .ehb-mobile-view-filter button.is-active { + background: #fff; + color: var(--bi-blue); + box-shadow: var(--bi-shadow-sm); + } + .ehb-mobile-filter-apply { + width: 100%; + min-height: 44px; + border: 0; + border-radius: 9px; + background: var(--bi-blue); + color: #fff; + font-size: 14px; + font-weight: 700; + } + .ehb-daily-kpi-grid, + .ehb-metric-grid { + display: grid !important; + grid-template-columns: repeat(2, 1fr) !important; + gap: 8px !important; + margin-bottom: 12px !important; + } + + .ehb-daily-kpi-card, + .ehb-kpi-dual { + padding: 10px 10px !important; + box-sizing: border-box !important; + min-width: 0 !important; + } + + .ehb-daily-kpi-head, + .ehb-kpi-dual__head { + margin-bottom: 4px !important; + } + + .ehb-daily-kpi-title, + .ehb-kpi-dual__label { + font-size: 11px !important; + white-space: normal !important; + overflow: visible !important; + text-overflow: clip !important; + line-height: 1.3 !important; + } + + .ehb-kpi-drill-hint { + display: none !important; + } + + .ehb-kpi-dual__num { + font-size: 18px !important; + line-height: 1.2 !important; + font-weight: 700 !important; + } + + .ehb-kpi-dual__unit { + font-size: 11px !important; + margin-left: 2px !important; + } + + .ehb-kpi-dual__deck { + display: grid !important; + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + gap: 6px !important; + align-items: start !important; + font-size: 10px !important; + line-height: 1.35 !important; + } + + .ehb-kpi-dual__deck span { + min-width: 0 !important; + overflow-wrap: anywhere !important; + } + + .ehb-daily-kpi-sub, + .ehb-kpi-dual__footer { + font-size: 10px !important; + line-height: 1.3 !important; + color: #64748b !important; + word-break: break-all !important; + margin-top: 4px !important; + } + + .ehb-filter-toggle { + display: none; + } + .ehb-filter-toggle svg { + transition: transform 0.2s ease; + } + .ehb-filter-toggle svg.is-open { + transform: rotate(180deg); + } + .ehb-overview-filter { + display: none !important; + } + .ehb-overview-filter.is-open { + display: none !important; + } + .ehb-daily-container > .ehb-daily-filter-card { + display: none !important; + } + .ehb-filters__rule { + display: none; + } + + /* 彻底隐藏 H5 场景下所有导出/下载 Excel 按钮 */ + .ehb-export-btn, + .ehb-daily-export-btn, + .ehb-modal-head__actions .ehb-btn { + display: none !important; + } + + /* 1. H5 过滤卡片与按键整齐防错行 */ + .ehb-daily-filter-card { + padding: 10px !important; + } + .ehb-daily-filter-row { + flex-direction: column !important; + align-items: stretch !important; + gap: 10px !important; + } + .ehb-daily-filter-group { + flex-wrap: wrap !important; + gap: 8px !important; + width: 100% !important; + justify-content: space-between !important; + } + .ehb-fleet-segmented { + width: 100% !important; + display: flex !important; + box-sizing: border-box !important; + } + .ehb-fleet-btn { + flex: 1 !important; + justify-content: center !important; + text-align: center !important; + padding: 0 4px !important; + font-size: 12px !important; + height: 36px !important; + min-height: 36px !important; + white-space: nowrap !important; + } + .ehb-fleet-btn svg { + display: none !important; /* H5 隐藏车辆 Icon 腾出空间防字折断 */ + } + .ehb-pill-tabs { + width: 100% !important; + display: flex !important; + box-sizing: border-box !important; + } + .ehb-pill-btn { + flex: 1 !important; + justify-content: center !important; + text-align: center !important; + padding: 0 4px !important; + font-size: 12px !important; + height: 36px !important; + min-height: 36px !important; + white-space: nowrap !important; + } + .ehb-year-select-wrapper { + width: 100% !important; + } + .ehb-year-btn { + width: 100% !important; + height: 36px !important; + justify-content: space-between !important; + } + .ehb-dp-trigger { + height: 36px !important; + padding: 0 8px !important; + font-size: 12px !important; + flex: 1 !important; + } + + /* 2. H5 Modal 下钻全屏沉浸 */ + .ehb-modal-overlay { + padding: 0 !important; + align-items: flex-start !important; + justify-content: flex-start !important; + z-index: 9999 !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + overflow: hidden !important; + } + + .ehb-modal-card { + width: 100vw !important; + max-width: 100vw !important; + height: 100% !important; + height: 100dvh !important; + max-height: 100dvh !important; + border-radius: 0 !important; + border: none !important; + box-shadow: none !important; + display: flex !important; + flex-direction: column !important; + } + + .ehb-modal-head { + padding-top: max(10px, env(safe-area-inset-top, 10px)) !important; + padding-bottom: 10px !important; + padding-left: 12px !important; + padding-right: 12px !important; + min-height: 52px !important; + background: #0f172a !important; + box-sizing: border-box !important; + flex-shrink: 0 !important; + } + + .ehb-modal-head__title-group { + display: flex !important; + align-items: center !important; + gap: 8px !important; + flex: 1 !important; + min-width: 0 !important; + } + + .ehb-modal-head__title-group > div { + flex: 1 !important; + min-width: 0 !important; + } + + .ehb-modal-head__title { + font-size: 13px !important; + line-height: 1.3 !important; + font-weight: 700 !important; + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + } + + .ehb-modal-head__sub { + font-size: 10px !important; + color: #94a3b8 !important; + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + margin-top: 2px !important; + display: block !important; + -webkit-line-clamp: unset !important; + } + + .ehb-modal-back-btn { + padding: 4px 8px !important; + font-size: 12px !important; + min-height: 32px !important; + height: 32px !important; + flex-shrink: 0 !important; + } + + .ehb-modal-body { + padding: 10px 10px 20px !important; + } + + .ehb-modal-meta-bar { + grid-template-columns: repeat(2, 1fr) !important; + gap: 6px !important; + padding: 8px !important; + } + + .ehb-modal-meta-item { + padding: 4px 6px !important; + } + + .ehb-modal-meta-label { + font-size: 10px !important; + } + + .ehb-modal-meta-val { + font-size: 13px !important; + } + + .ehb-modal-filter-row { + flex-direction: column !important; + align-items: stretch !important; + gap: 8px !important; + padding: 8px !important; + } + + .ehb-modal-filter-group { + width: 100% !important; + gap: 8px !important; + } + + .ehb-modal-filter-group select { + flex: 1 !important; + min-width: 0 !important; + } + + .ehb-drill-period-controls { + width: 100%; + } + + .ehb-drill-period-controls select { + flex: 0 0 82px !important; + } + + .ehb-drill-period-controls input[type='month'], + .ehb-drill-date-range { + flex: 1; + } + + /* 3. 统一 H5 控件高度 36px,弱化提示卡占空间 */ + .ehb-modal-search-input, + .ehb-modal-select, + .ehb-bi-search-select, + .ehb-bi-search-select__trigger { + width: 100% !important; + height: 36px !important; + min-height: 36px !important; + font-size: 12px !important; + box-sizing: border-box !important; + } + + .ehb-modal-hint-text { + font-size: 11px !important; + font-weight: 400 !important; + color: var(--bi-tertiary, #94a3b8) !important; + line-height: 1.4 !important; + margin: 2px 0 !important; + } + + .ehb-modal-hint-text strong { + color: inherit !important; + font-weight: 400 !important; + } + + /* 钻取表 100% 支撑横滚与右侧财务/状态列可见,第一列支持多行(2-3行)无缝自适应 */ + .ehb-modal-table-wrap { + width: 100% !important; + overflow-x: auto !important; + -webkit-overflow-scrolling: touch !important; + display: block !important; + border-radius: 8px !important; + border: 1px solid #e2e8f0 !important; + box-shadow: inset -6px 0 8px -4px rgba(15, 23, 42, 0.1) !important; + } + + .ehb-modal-table { + min-width: 820px !important; + table-layout: auto !important; + } + + .ehb-modal-table th, + .ehb-modal-table td { + padding: 8px 8px !important; + font-size: 11px !important; + white-space: normal !important; + word-break: break-word !important; + } + + .ehb-modal-table th:first-child, + .ehb-modal-table td:first-child { + min-width: 250px !important; + } + + /* H5 移动端紧凑树结构缩进 */ + .ehb-tree-cell-l1 { padding-left: 6px !important; } + .ehb-tree-cell-l2 { padding-left: 16px !important; } + .ehb-tree-cell-l3 { padding-left: 26px !important; } + .ehb-tree-cell-l4 { padding-left: 36px !important; } + + .ehb-h5-scroll-hint { + display: block !important; + font-size: 11px !important; + color: #2f6bff !important; + background: rgba(2, 132, 199, 0.08) !important; + padding: 4px 8px !important; + border-radius: 4px !important; + margin-bottom: 6px !important; + text-align: center !important; + font-weight: 500 !important; + } + + /* H5 下日期 Popover 与年份 Select 转 Bottom Sheet */ + .ehb-date-popover, + .ehb-year-dropdown { + position: fixed !important; + bottom: 74px !important; + left: 12px !important; + right: 12px !important; + top: auto !important; + width: auto !important; + max-width: none !important; + max-height: calc(100dvh - 90px) !important; + overflow-y: auto !important; + overscroll-behavior: contain; + border-radius: 16px !important; + box-shadow: 0 -10px 30px rgba(15, 23, 42, 0.3) !important; + z-index: 10000 !important; + animation: ehbSlideUpSheet 0.25s cubic-bezier(0.16, 1, 0.3, 1) !important; + } + + @keyframes ehbSlideUpSheet { + from { transform: translateY(100%); } + to { transform: translateY(0); } + } + + /* 图表头部与图例 H5 上下分行两端对齐,彻底消除图例重叠挤压 */ + .ehb-daily-chart-head, + .ehb-chart-box-head { + flex-direction: column !important; + align-items: flex-start !important; + gap: 8px !important; + margin-bottom: 10px !important; + } + + .ehb-daily-chart-title, + .ehb-chart-box-title { + width: 100% !important; + } + + .ehb-daily-chart-meta-group { + width: 100% !important; + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + gap: 8px !important; + } + + .ehb-daily-chart-legend, + .ehb-chart-legend-inline { + display: flex !important; + align-items: center !important; + gap: 10px !important; + } + + .ehb-legend-item, + .ehb-chart-legend-tag { + font-size: 11px !important; + } + + .ehb-daily-chart-meta, + .ehb-chart-box-meta { + font-size: 10px !important; + color: #64748b !important; + white-space: nowrap !important; + } + + /* 图表与表格在 H5 下的自适应 */ + .ehb-daily-bar-container { + overflow-x: auto !important; + overflow-y: hidden !important; + -webkit-overflow-scrolling: touch !important; + padding-top: 28px !important; + padding-bottom: 24px !important; + gap: 10px !important; + } + + .ehb-daily-bar-col { + flex: 0 0 46px !important; + min-width: 46px !important; + max-width: 46px !important; + } + + .ehb-daily-bar-fill { + width: 24px !important; + max-width: 24px !important; + } + + .ehb-daily-bar-val { + font-size: 11px !important; + font-weight: 600 !important; + top: -22px !important; + } + + .ehb-daily-bar-label { + font-size: 11px !important; + margin-top: 6px !important; + white-space: nowrap !important; + } + + .ehb-mbar-chart, + .ehb-rev-chart { + overflow-x: auto !important; + -webkit-overflow-scrolling: touch !important; + padding-bottom: 6px !important; + } + + .ehb-mbar-col { + min-width: 42px !important; + } + + .ehb-rev-col-group { + min-width: 52px !important; + } + + .ehb-donut-section { + flex-direction: column !important; + align-items: center !important; + } + + .ehb-region-legend-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + width: 100% !important; + } + + .ehb-region-legend-item { + grid-template-columns: minmax(0, auto) 44px !important; + justify-content: center !important; + } + + .ehb-sum-table-card__head { + flex-direction: column !important; + align-items: flex-start !important; + gap: 8px !important; + } + + .ehb-mini-tabs { + overflow-x: auto !important; + max-width: 100% !important; + padding-bottom: 2px; + } + + /* 提示文案 H5 适配:单行显示不跨行 */ + .ehb-show-h5 { + display: inline !important; + } + + .ehb-hide-h5 { + display: none !important; + } + + .ehb-daily-table-title, + .ehb-daily-chart-title { + display: flex !important; + align-items: center !important; + flex-wrap: nowrap !important; + white-space: nowrap !important; + overflow: hidden !important; + max-width: 100% !important; + } + + .ehb-title-sub { + font-size: 11px !important; + color: var(--bi-tertiary) !important; + margin-left: 4px !important; + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + } + + /* 4. H5 按住/悬浮图标 Tooltip 沉浸居中/屏内安全弹出,100% 绝对不超出屏外 */ + .ehb-mbar-col:hover .ehb-mbar-tooltip, + .ehb-rev-bar.is-income:hover .ehb-rev-income-tooltip, + .ehb-rev-bar.is-cost:hover .ehb-rev-cost-tooltip, + .ehb-top-bar-bg:hover .ehb-top-bar-tooltip, + .ehb-top-station-item:hover .ehb-top-bar-tooltip { + position: fixed !important; + top: 50% !important; + left: 50% !important; + right: auto !important; + bottom: auto !important; + transform: translate(-50%, -50%) !important; + width: calc(100vw - 32px) !important; + max-width: 320px !important; + z-index: 10002 !important; + box-shadow: 0 16px 40px rgba(15, 23, 42, 0.5) !important; + pointer-events: none; + animation: ehbTooltipCenterFade 0.2s ease-out !important; + } + + @keyframes ehbTooltipCenterFade { + from { + opacity: 0; + transform: translate(-50%, -46%); + } + to { + opacity: 1; + transform: translate(-50%, -50%); + } + } +} + +@keyframes ehbRefreshSpin { + to { transform: rotate(360deg); } +} + +.is-spinning { + animation: ehbRefreshSpin 0.8s linear infinite; +} + +/* ===== 站日报 + 现结进账(体系A)· 增量,不覆盖既有 .ehb-table ===== */ +.ehb-seg--wrap { + display: flex; + flex-wrap: wrap; + grid-template-columns: none; + min-width: 0; + gap: 3px; +} +.ehb-seg--wrap button { + flex: 0 0 auto; + min-width: 64px; +} +.ehb-cash-banner { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 14px; + border-radius: 10px; + background: #f0f9ff; + border: 1px solid #bae6fd; + color: #0c4a6e; + font-size: 12px; + line-height: 1.5; + margin-bottom: 12px; +} +.ehb-cash-banner strong { + font-size: 13px; + color: #0369a1; +} +.ehb-field-label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: #64748b; + font-weight: 600; +} +.ehb-native-select, +.ehb-native-input { + min-height: 36px; + height: 36px; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 0 10px; + font-size: 13px; + color: #0f172a; + background: #fff; + min-width: 160px; +} +.ehb-native-input.is-num { + text-align: right; + font-variant-numeric: tabular-nums; +} +.ehb-btn--primary { + background: #2f6bff; + color: #fff; + border-color: #2f6bff; +} +.ehb-btn--primary:hover { + background: #0369a1; + color: #fff; + border-color: #0369a1; +} +.ehb-sd-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + overflow: hidden; +} +.ehb-table-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + overflow: hidden; +} +.ehb-sd-card__head, +.ehb-table-card__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid #f1f5f9; +} +.ehb-sd-card__title, +.ehb-table-card__title { + font-size: 14px; + font-weight: 700; + color: #0f172a; +} +.ehb-sd-card__hint, +.ehb-table-card__hint { + font-size: 11px; + color: #94a3b8; +} +.ehb-sd-scroll, +.ehb-table-scroll { + overflow: auto; +} +.ehb-empty-cell { + text-align: center !important; + color: #94a3b8; + padding: 28px 12px !important; +} +.ehb-row-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.ehb-link-btn { + display: inline-flex; + align-items: center; + gap: 4px; + background: none; + border: none; + color: #2f6bff; + font-size: 12px; + font-weight: 600; + cursor: pointer; + padding: 0; +} +.ehb-link-btn.is-danger { + color: #dc2626; +} +.ehb-muted-hint { + font-size: 12px; + color: #94a3b8; + align-self: flex-end; + padding-bottom: 6px; +} +.ehb-kpi-grid--4 { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} +.ehb-kpi-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 14px 16px; +} +.ehb-kpi-card__label { + font-size: 12px; + color: #64748b; + font-weight: 600; + margin-bottom: 6px; +} +.ehb-kpi-card__value { + font-size: 22px; + font-weight: 800; + color: #0f172a; + font-variant-numeric: tabular-nums; + line-height: 1.2; +} +.ehb-kpi-card__sub { + margin-top: 6px; + font-size: 12px; + color: #94a3b8; +} +.ehb-kpi-unit { + font-size: 13px; + font-weight: 600; + margin-left: 4px; + color: #64748b; +} +.ehb-station-trend { + display: flex; + gap: 8px; + overflow-x: auto; + padding: 8px 4px 4px; + min-height: 160px; + align-items: flex-end; +} +.ehb-station-trend__col { + flex: 0 0 48px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} +.ehb-station-trend__val { + font-size: 11px; + color: #64748b; + font-variant-numeric: tabular-nums; +} +.ehb-station-trend__bar-wrap { + width: 100%; + height: 110px; + display: flex; + align-items: flex-end; + justify-content: center; +} +.ehb-station-trend__bar { + width: 22px; + border-radius: 4px 4px 2px 2px; + background: linear-gradient(180deg, #8ab2ff, #2f6bff); +} +.ehb-station-trend__date { + font-size: 11px; + color: #94a3b8; +} +.ehb-dual-tables { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +.ehb-tag--own { + background: #e0f2fe; + color: #0369a1; +} +.ehb-tag--ext { + background: #fff7ed; + color: #c2410c; +} +.ehb-cash-modal { + max-width: 720px; + width: calc(100% - 24px); +} +.ehb-cash-modal-note { + font-size: 12px; + color: #0369a1; + background: #f0f9ff; + border-radius: 8px; + padding: 8px 10px; + margin: 0 0 12px; +} +.ehb-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 14px; +} +.ehb-form-grid label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: #64748b; +} +.ehb-form-span2 { + grid-column: 1 / -1; +} +.ehb-cash-lines-head { + display: flex; + align-items: center; + justify-content: space-between; + margin: 8px 0; + font-size: 13px; + font-weight: 700; + color: #0f172a; +} +.ehb-manual-total { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: #64748b; +} +.ehb-cash-modal-foot { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid #f1f5f9; + background: #fff; +} +.ehb-cash-modal .ehb-modal-body { + overflow: auto; + max-height: min(70vh, 560px); + padding: 16px; +} +.ehb-toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: #0f172a; + color: #fff; + padding: 10px 16px; + border-radius: 999px; + font-size: 13px; + z-index: 10050; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.25); +} +.ehb-table .is-num, +.ehb-sum-table .is-num { + text-align: right; + font-variant-numeric: tabular-nums; +} +.ehb-table .is-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} +.ehb-table tr.is-total td { + font-weight: 700; + background: #f8fafc; +} +@media (max-width: 767px) { + .ehb-kpi-grid--4 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .ehb-dual-tables { + grid-template-columns: 1fr; + } + .ehb-form-grid { + grid-template-columns: 1fr; + } + .ehb-station-trend__col { + flex-basis: 46px; + } +} + +/* ========================================================================== */ +/* 2026-08 经营驾驶舱视觉重构 */ +/* ========================================================================== */ +.ehb-shell { + --bi-app-bg: #f3f6fb; + --bi-hairline: #e6ecf4; + --bi-hairline-subtle: #edf1f6; + --bi-blue: #2f6bff; + --bi-blue-soft: #eef4ff; + --bi-green: #0ca678; + --bi-radius: 18px; + --bi-radius-sm: 14px; + --bi-shadow: 0 10px 30px rgba(27, 54, 93, .07); + background: var(--bi-app-bg); +} +.ehb-rail { width: 68px; padding-top: 20px; background: #101c31; } +.ehb-rail__item { width: 48px; border-radius: 12px; } +.ehb-rail__item.is-active { background: linear-gradient(145deg,#2f6bff,#4c86ff); box-shadow: 0 8px 18px rgba(47,107,255,.32); } +.ehb-body { max-width: 1680px; margin: 0 auto; padding: 22px 28px 44px; } +.ehb-chrome { margin: -8px -8px 18px; padding: 12px 8px 16px; align-items: center; background: rgba(243,246,251,.94); } +.ehb-chrome__lead h1 { font-size: 24px; font-weight: 800; } +.ehb-time-range-pill { border-color: #d9e5ff !important; background: #eef4ff !important; color: #2f6bff !important; } +.ehb-seg { min-width: 168px; padding: 4px; border-radius: 12px; background: #e9eef6; } +.ehb-seg button { height: 34px; border-radius: 9px; font-weight: 650; } +.ehb-seg button.is-active { color: #2f6bff; box-shadow: 0 3px 10px rgba(27,54,93,.08); } +.ehb-daily-filter-card,.ehb-overview-filter { border: 1px solid var(--bi-hairline) !important; border-radius: 16px !important; background: #fff !important; box-shadow: 0 4px 18px rgba(27,54,93,.045); } +.ehb-host { margin-bottom: 18px; padding: 0; border: 0; background: transparent; } +.ehb-metric-grid { grid-template-columns: repeat(4,minmax(0,1fr)); gap: 14px; } +.ehb-kpi-dual,.ehb-insight__card,.ehb-chart-box,.ehb-sum-table-card { border-color: var(--bi-hairline); box-shadow: 0 5px 20px rgba(27,54,93,.045); } +.ehb-kpi-dual { min-height: 134px; padding: 16px; border-radius: 16px; } +.ehb-host-kpi .ehb-kpi-dual:nth-child(1),.ehb-host-kpi .ehb-kpi-dual:nth-child(2) { min-height: 154px; padding: 18px; } +.ehb-host-kpi .ehb-kpi-dual:nth-child(1) { color: #fff; border-color: transparent; background: linear-gradient(145deg,#2f6bff,#4e88ff); box-shadow: 0 14px 28px rgba(47,107,255,.22); } +.ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__label,.ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__symbol,.ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__unit,.ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__footer { color: rgba(255,255,255,.78) !important; } +.ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__num { color: #fff; } +.ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__badge { color: #fff; background: rgba(255,255,255,.16); } +.ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__deck { color: rgba(255,255,255,.9); background: rgba(255,255,255,.12); } +.ehb-kpi-dual__label { font-size: 13px; font-weight: 650; } +.ehb-kpi-dual__num { font-size: 28px; font-weight: 800; } +.ehb-kpi-dual__deck { margin-top: auto; padding: 7px 9px; border-radius: 8px; } +.ehb-insight__card { min-height: 100px; padding: 16px; border-radius: 16px; align-items: center; } +.ehb-insight__value { font-size: 21px; } +.ehb-overview-charts { gap: 16px; } +.ehb-chart-box,.ehb-sum-table-card { padding: 20px; border-radius: 18px; } +.ehb-chart-box-title,.ehb-sum-table-card__title { font-size: 15px; font-weight: 750; } +.ehb-sum-table th { background: #f8fafd; } +.ehb-sum-table tr:hover td { background: #f4f8ff; } + +@media (max-width: 1180px) { .ehb-metric-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } } + +@media (max-width: 767px) { + .ehb-shell { background: #f4f7fb; } + .ehb-body { padding: 10px 12px 28px; } + .ehb-chrome { position: relative; margin: 0 0 12px; padding: 14px; border: 1px solid var(--bi-hairline); border-radius: 18px; background: #fff; box-shadow: 0 6px 20px rgba(27,54,93,.05); } + .ehb-mobile-brand-icon { width: 42px; height: 42px; flex-basis: 42px; border-radius: 13px; background: linear-gradient(145deg,#2f6bff,#4e88ff); } + .ehb-chrome__lead h1 { font-size: 19px; } + .ehb-time-range-pill { max-width: 210px; } + .ehb-chrome__tools { position: absolute; top: 12px; right: 12px; } + .ehb-scope-switches .ehb-seg { min-width: 116px; } + .ehb-scope-switches .ehb-seg button { padding: 0 8px; font-size: 12px; } + .ehb-mobile-filter-panel { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--bi-hairline-subtle); } + .ehb-mobile-view-mode { grid-template-columns: 1fr; margin-bottom: 10px; } + .ehb-mobile-view-mode > span { display: none; } + .ehb-mobile-overview-quick { grid-template-columns: 112px minmax(0,1fr); } + .ehb-mobile-filter-bar { border-radius: 13px; background: #f8fafd; } + .ehb-mobile-filter-body { border-radius: 14px; box-shadow: 0 8px 20px rgba(27,54,93,.06); } + .ehb-host { margin-top: 2px; } + .ehb-metric-grid { grid-template-columns: repeat(2,minmax(0,1fr)) !important; gap: 10px !important; } + .ehb-kpi-dual { min-height: 132px !important; padding: 13px !important; border-radius: 15px !important; } + .ehb-host-kpi .ehb-kpi-dual:nth-child(1),.ehb-host-kpi .ehb-kpi-dual:nth-child(2) { min-height: 158px !important; } + .ehb-kpi-dual__num { font-size: 24px !important; } + .ehb-kpi-dual__label { font-size: 12px !important; } + .ehb-kpi-dual__deck { display: flex !important; flex-direction: column; gap: 2px !important; padding: 7px !important; } + .ehb-insight__card { grid-column: 1/-1; min-height: 82px; padding: 13px; } + .ehb-overview-charts,.ehb-two-charts-row { display: grid; grid-template-columns: 1fr !important; gap: 12px; } + .ehb-chart-box,.ehb-sum-table-card { padding: 14px; border-radius: 16px; } + .ehb-chart-box-head,.ehb-sum-table-card__head { align-items: flex-start; gap: 8px; } + .ehb-sum-table-wrap { margin: 0 -14px -14px; padding: 0 14px 14px; scroll-snap-type: x proximity; } + .ehb-modal-overlay { padding: 0; } + .ehb-modal-card { max-height: 94vh; align-self: flex-end; border-radius: 18px 18px 0 0; } +} + +/* 2026-08 低饱和经营看板:让数据成为视觉主角,颜色只承担状态与操作提示。 */ +.ehb-shell { + --bi-blue: #5b78ad; + --bi-blue-soft: #eaf0f8; + --bi-purple: #2f6bff; + --bi-app-bg: #f5f7fa; +} + +.ehb-rail__item.is-active { + background: #5f7dad; + box-shadow: 0 6px 14px rgba(45, 69, 105, 0.2); +} + +.ehb-time-range-pill { + border-color: #dce5f1 !important; + background: #f1f5fa !important; + color: #4f6f9f !important; +} + +.ehb-seg button.is-active, +.ehb-mini-tab.is-active { + color: var(--bi-blue); +} + +.ehb-host-kpi .ehb-kpi-dual:nth-child(1) { + color: var(--bi-text-body); + border-color: #d7e1ee; + background: #eaf0f8; + box-shadow: 0 8px 18px rgba(45, 69, 105, 0.1); +} + +.ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__label, +.ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__symbol, +.ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__unit, +.ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__footer { + color: #5d6f89 !important; +} + +.ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__num { + color: #21324d; +} + +.ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__badge { + color: #5271a3; + background: rgba(255, 255, 255, 0.66); +} + +.ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__deck { + color: #4f6380; + background: rgba(255, 255, 255, 0.58); +} + +.ehb-daily-bar-fill, +.ehb-bar-segment.is-own, +.ehb-mbar-fill { + background: #2f6bff; +} + +.ehb-bar-segment.is-ext { + background: #8fb4ff; +} + +.ehb-rev-bar.is-cost { + background: #7c83e6; +} + +.ehb-rev-bar.is-income { + background: #2f9fb3; +} + +.ehb-kpi-dual__badge.is-purple { background: #eef4ff; color: #2f6bff; } + +.ehb-kpi-drill-hint { + color: #2f6bff; + background: #eef4ff; +} + +.ehb-kpi-dual:hover .ehb-kpi-drill-hint { + color: #ffffff; + background: #2f6bff; +} + +.ehb-tag--source-lingniu { + color: #2f6bff; + background: #eef4ff; +} + +.ehb-station-rank-item__bar > span, +.ehb-mini-bar-fill.is-blue, +.ehb-station-trend__bar, +.ehb-dim.is-lease::before, +.ehb-dim.is-ops::before { + background: #2f6bff; +} + +.ehb-mini-bar-fill.is-orange, +.ehb-dim.is-logistics::before { + background: #f09a61; +} + +.ehb-mobile-brand-icon { + background: #2f6bff; +} + +.ehb-legend-sq.is-income { + background: #2f9fb3; +} + +.ehb-legend-sq.is-cost { + background: #7c83e6; +} + +.ehb-top-bar-seg.is-own { + background: #2f6bff; +} + +.ehb-top-bar-seg.is-ext { + background: #8fb4ff; +} + +/* 保留各图表原色,仅降低数据色块的视觉强度;按钮、文字和状态色不受影响。 */ +.ehb-mbar-fill > div, +.ehb-bar-segment, +.ehb-top-bar-seg, +.ehb-rev-bar, +.ehb-chart-box .ehb-legend-sq, +.ehb-daily-chart-section .ehb-legend-dot, +.ehb-donut-chart-wrap circle:not(:first-child), +.ehb-region-legend-item [style*="background"], +.ehb-mini-bar-fill, +.ehb-station-rank-item__bar > span, +.ehb-daily-bar-fill, +.ehb-mobile-profit-track > span { + opacity: 0.78; +} + +.ehb-station-name { + color: var(--bi-text-body); + font-weight: 600; +} + +.ehb-entity-name, +.ehb-entity-cell { + color: var(--bi-text-body); + font-weight: 600; +} + +.ehb-entity-action { + margin-left: 4px; + color: var(--bi-blue); + font-size: 11px; + font-weight: 400; +} + +.ehb-station-view { + margin-left: 4px; + color: var(--bi-blue); + font-size: 11px; + font-weight: 400; +} + +.ehb-station-province { + display: inline-flex; + padding: 1px 6px; + border-radius: 4px; + background: #eef3f8; + color: #5b7090; + font-size: 11px; + font-weight: 500; +} + +.ehb-sum-table tr:hover td { + background: #f3f6fa; +} + +/* Mobile dashboard: compact, number-first, and consistent with the desktop blue system. */ +.ehb-mobile-updated, +.ehb-mobile-scope-chip, +.ehb-mobile-context-switch, +.ehb-mobile-section-title, +.ehb-mobile-month-summary, +.ehb-mobile-profit-summary, +.ehb-mobile-bottom-nav, +.ehb-mobile-view-chip, +.ehb-mobile-order-chip, +.ehb-mobile-fleet-chip, +.ehb-mobile-current-range { + display: none; +} + +@media (max-width: 767px) { + .ehb-shell { min-width: 0; background: #f4f7fb; } + .ehb-rail { display: none; } + .ehb-body { + width: 100%; + min-width: 0; + padding: 14px; + padding-bottom: calc(84px + env(safe-area-inset-bottom)); + } + + .ehb-chrome { + margin: 0 0 8px; + padding: 2px 2px 8px; + border: 0; + border-bottom: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + } + .ehb-chrome__lead { width: 100%; align-items: center; justify-content: space-between; gap: 10px; } + .ehb-chrome__identity { min-width: 0; } + .ehb-mobile-brand-icon, + .ehb-chrome__crumb, + .ehb-time-range-pill, + .ehb-chrome__tools { display: none !important; } + .ehb-chrome__lead h1 { margin: 0; font-size: 20px; line-height: 1.2; letter-spacing: -.4px; } + .ehb-mobile-updated { display: block; margin-top: 2px; color: #71819b; font-size: 11px; line-height: 1.25; } + .ehb-mobile-context-switch, + .ehb-mobile-scope-chip { + display: inline-flex; + min-width: 72px; + min-height: 44px; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 12px; + border: 0; + border-radius: 22px; + background: #eaf1ff; + color: #21324d; + font-size: 12px; + font-weight: 700; + } + .ehb-mobile-context-switch span, + .ehb-mobile-scope-chip span { color: #2f6bff; font-size: 9px; } + .ehb-mobile-context-switch svg { color: #6a7d99; } + .ehb-mobile-context-switch.is-scope { min-width: 84px; padding: 0 10px; font-size: 13px; } + + .ehb-mobile-filter-panel { + margin-top: 10px; + padding: 10px 10px 11px; + border: 1px solid #dfe7f1; + border-radius: 14px; + background: #fff; + } + .ehb-mobile-view-mode { display: none !important; } + .ehb-mobile-primary-filters { + display: grid; + grid-template-columns: 68px 56px minmax(58px,1fr) minmax(58px,1fr) 55px; + gap: 5px; + overflow: visible; + padding: 0; + } + .ehb-mobile-overview-quick { display: flex !important; min-width: 0; flex: 1; gap: 6px; } + .ehb-mobile-view-chip { display: inline-flex; } + .ehb-mobile-view-chip, + .ehb-mobile-overview-quick button, + .ehb-mobile-overview-quick .ehb-year-picker, + .ehb-mobile-primary-filters button { + min-width: 0; + min-height: 36px; + align-items: center; + justify-content: center; + border-radius: 18px !important; + } + .ehb-mobile-view-chip { + padding: 0 8px; + border: 0; + background: #eaf1ff; + color: #21324d; + font-size: 11px; + font-weight: 650; + } + .ehb-mobile-overview-quick .ehb-year-select-btn { padding: 0 10px; font-size: 11px; } + .ehb-mobile-primary-filters .ehb-year-select-btn { + width: 68px; + padding: 0 6px; + font-size: 10px; + } + .ehb-mobile-primary-filters .ehb-year-text { white-space: nowrap; } + .ehb-mobile-primary-filters .ehb-mobile-context-switch { + min-width: 0; + padding: 0 5px; + gap: 3px; + } + .ehb-mobile-primary-filters .ehb-mobile-context-switch svg { + display: block; + flex: 0 0 auto; + } + .ehb-mobile-overview-quick .ehb-pill-tabs { min-width: 0; } + .ehb-mobile-overview-quick .ehb-pill-btn { padding: 0 7px; font-size: 11px; white-space: nowrap; } + .ehb-mobile-filter-bar { display: none; } + .ehb-mobile-order-chip, + .ehb-mobile-fleet-chip, + .ehb-mobile-filter-disclosure { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 7px; + border: 0; + background: #f1f5fb; + color: #20314c; + font-size: 11px; + font-weight: 650; + white-space: nowrap; + } + .ehb-mobile-filter-disclosure { + padding: 0 10px; + background: #2f6bff; + color: #fff; + } + .ehb-mobile-filter-disclosure[aria-expanded="true"] { + border-color: #b8ccff; + background: #eef4ff; + color: #2f6bff; + } + .ehb-mobile-current-range { + display: block; + margin-top: 9px; + overflow: hidden; + color: #71819b; + font-size: 11px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; + } + .ehb-mobile-verify-filter { margin-bottom: 12px; } + .ehb-mobile-verify-filter .ehb-pill-tabs { width: 100%; } + .ehb-mobile-verify-filter .ehb-pill-btn { min-height: 40px; } + + .ehb-mobile-section-title { display: block; margin: 20px 2px 10px; color: #18263d; font-size: 18px; } + .ehb-metric-grid { display: block !important; } + .ehb-host-kpi { display: grid !important; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px !important; } + .ehb-host-kpi > .ehb-kpi-dual:nth-child(n+3) { display: none; } + .ehb-host-kpi .ehb-kpi-dual:nth-child(1), + .ehb-host-kpi .ehb-kpi-dual:nth-child(2) { + min-height: 150px !important; + padding: 14px !important; + border-radius: 18px !important; + } + .ehb-host-kpi .ehb-kpi-dual:nth-child(1) { + color: #fff; + border-color: #2f6bff; + background: #2f6bff; + box-shadow: 0 10px 22px rgba(47,107,255,.18); + } + .ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__label, + .ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__symbol, + .ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__unit, + .ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__footer { color: rgba(255,255,255,.82) !important; } + .ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__num { color: #fff; } + .ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__deck { color: #fff; background: rgba(255,255,255,.14); } + .ehb-host-kpi .ehb-kpi-dual:nth-child(2) { background: #fff; box-shadow: 0 6px 18px rgba(27,54,93,.06); } + .ehb-kpi-dual__num { font-size: clamp(25px,8vw,34px) !important; } + .ehb-kpi-drill-hint, + .ehb-kpi-dual__badge { display: none !important; } + + .ehb-mobile-month-summary, + .ehb-mobile-profit-summary { + display: block; + margin-top: 14px; + padding: 16px; + border: 1px solid var(--bi-hairline); + border-radius: 18px; + background: #fff; + box-shadow: 0 5px 18px rgba(27,54,93,.045); + } + .ehb-mobile-summary-head { display: flex; align-items: center; justify-content: space-between; padding-bottom: 12px; border-bottom: 1px solid var(--bi-hairline); } + .ehb-mobile-summary-head strong { color: #1c2a42; font-size: 17px; } + .ehb-mobile-summary-head span { color: #56729e; font-size: 12px; } + details.ehb-mobile-month-summary { + margin: 0 0 12px; + padding: 0 16px; + border-radius: 14px; + box-shadow: none; + } + details.ehb-mobile-month-summary > .ehb-mobile-summary-head { + min-height: 52px; + padding: 0; + border: 0; + cursor: pointer; + list-style: none; + } + details.ehb-mobile-month-summary > .ehb-mobile-summary-head::-webkit-details-marker { display: none; } + details.ehb-mobile-month-summary > .ehb-mobile-summary-head span { + display: inline-flex; + align-items: center; + gap: 4px; + } + details.ehb-mobile-month-summary > .ehb-mobile-summary-head i { + font-style: normal; + } + .ehb-mobile-summary-state--open { display: none; } + details.ehb-mobile-month-summary[open] .ehb-mobile-summary-state--closed { display: none; } + details.ehb-mobile-month-summary[open] .ehb-mobile-summary-state--open { display: inline; } + details.ehb-mobile-month-summary > .ehb-mobile-summary-head svg { transition: transform .18s ease-out; } + details.ehb-mobile-month-summary[open] > .ehb-mobile-summary-head { + border-bottom: 1px solid var(--bi-hairline); + } + details.ehb-mobile-month-summary[open] > .ehb-mobile-summary-head svg { transform: rotate(180deg); } + @media (prefers-reduced-motion: reduce) { + details.ehb-mobile-month-summary > .ehb-mobile-summary-head svg { transition: none; } + } + .ehb-mobile-summary-grid { display: grid; grid-template-columns: repeat(3,1fr); padding-top: 14px; } + .ehb-mobile-summary-grid > div { min-width: 0; padding: 0 10px; border-left: 1px solid var(--bi-hairline); } + .ehb-mobile-summary-grid > div:first-child { padding-left: 0; border-left: 0; } + .ehb-mobile-summary-grid span { display: block; margin-bottom: 6px; color: #70819c; font-size: 11px; white-space: nowrap; } + .ehb-mobile-summary-grid strong { color: #18263d; font-size: 19px; white-space: nowrap; } + .ehb-mobile-diagnosis-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0; + padding-top: 4px; + } + .ehb-mobile-diagnosis-grid > div, + .ehb-mobile-diagnosis-grid > button { + display: grid; + min-width: 0; + min-height: 72px; + align-content: center; + gap: 3px; + padding: 10px 8px; + border: 0; + border-top: 1px solid var(--bi-hairline); + background: transparent; + text-align: left; + } + .ehb-mobile-diagnosis-grid > :nth-child(odd) { border-right: 1px solid var(--bi-hairline); } + .ehb-mobile-diagnosis-grid > :nth-child(-n+2) { border-top: 0; } + .ehb-mobile-diagnosis-grid span, + .ehb-mobile-diagnosis-grid small { overflow: hidden; color: #70819c; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } + .ehb-mobile-diagnosis-grid strong { color: #18263d; font-family: var(--bi-font-mono); font-size: 17px; white-space: nowrap; } + .ehb-mobile-diagnosis-grid strong.is-positive { color: #11866d; } + .ehb-mobile-diagnosis-grid strong.is-warning { color: #c96c12; } + .ehb-mobile-diagnosis-action { cursor: pointer; } + .ehb-mobile-diagnosis-action:active { background: #f4f7fb; } + .ehb-mobile-summary-grid small, + .ehb-mobile-profit-value small { margin-left: 2px; color: #71819b; font-size: 11px; font-weight: 600; } + .ehb-mobile-profit-summary { margin-bottom: 18px; } + .ehb-mobile-profit-summary .ehb-mobile-summary-head span { padding: 7px 10px; border-radius: 12px; background: #edf8f6; color: #187c70; } + .ehb-mobile-profit-value { margin: 14px 0 12px; color: #18263d; font-size: 30px; font-weight: 800; } + .ehb-mobile-profit-row { display: flex; justify-content: space-between; color: #70819c; font-size: 12px; } + .ehb-mobile-profit-row strong { margin-left: 5px; color: #283750; } + .ehb-mobile-profit-track { height: 6px; margin-top: 12px; overflow: hidden; border-radius: 3px; background: #edf1f6; } + .ehb-mobile-profit-track span { display: block; height: 100%; border-radius: inherit; background: #2f6bff; } + .ehb-insight { display: none !important; } + + .ehb-overview-charts, + .ehb-two-charts-row { display: grid; grid-template-columns: minmax(0,1fr) !important; gap: 12px; } + .ehb-chart-box, + .ehb-sum-table-card { min-width: 0; padding: 14px; border-radius: 18px; } + .ehb-chart-box-title, + .ehb-sum-table-card__title { font-size: 15px; } + + .ehb-mobile-bottom-nav { + position: fixed; + z-index: 80; + right: 0; + bottom: 0; + left: 0; + display: grid; + grid-template-columns: repeat(3,1fr); + padding: 7px 18px calc(7px + env(safe-area-inset-bottom)); + border-top: 1px solid #e3e9f2; + background: rgba(255,255,255,.96); + box-shadow: 0 -8px 24px rgba(27,54,93,.07); + backdrop-filter: blur(14px); + } + .ehb-mobile-bottom-nav button { + min-height: 50px; + border: 0; + background: transparent; + color: #7a899f; + font-size: 15px; + } + .ehb-mobile-bottom-nav button > svg { + display: block; + width: 22px; + height: 22px; + margin: 0 auto; + } + .ehb-mobile-bottom-nav button span { display: block; margin-top: 2px; font-size: 11px; } + .ehb-mobile-bottom-nav button.is-active { border-radius: 14px; background: #eef4ff; color: #2f6bff; font-weight: 750; } + .ehb-mobile-bottom-nav button.is-active::first-line { color: #2f6bff; } +} + +@media (max-width: 380px) { + .ehb-body { padding-right: 12px; padding-left: 12px; } + .ehb-host-kpi { gap: 10px !important; } + .ehb-host-kpi .ehb-kpi-dual:nth-child(1), + .ehb-host-kpi .ehb-kpi-dual:nth-child(2) { min-height: 150px !important; padding: 12px !important; } + .ehb-kpi-dual__num { font-size: 27px !important; } +} + +.ehb-mobile-legend-label { display: none; } + +.ehb-station-summary-card { position: relative; } +.ehb-station-summary-card .ehb-sum-table-card__title { padding-right: 42px; } + +.ehb-station-fullscreen-trigger { + position: absolute; + top: 14px; + right: 14px; + display: inline-flex; + width: auto; + min-width: 92px; + height: 32px; + margin: 0; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 10px; + border: 1px solid #dbe4f1; + border-radius: 10px; + background: #fff; + color: #4f6f9f; + cursor: pointer; +} +.ehb-station-fullscreen-trigger span { font-size: 12px; font-weight: 600; white-space: nowrap; } +.ehb-station-fullscreen-trigger:hover { border-color: #b9ccf7; color: #2f6bff; } + +.ehb-station-fullscreen { + position: fixed; + z-index: 120; + inset: 0; + overflow: hidden; + background: #f4f7fb; +} +.ehb-station-fullscreen__panel { + box-sizing: border-box; + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + padding: 16px 18px; + background: #f4f7fb; +} +.ehb-station-fullscreen__head { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.ehb-station-fullscreen__head > div { display: flex; min-width: 0; align-items: baseline; gap: 14px; } +.ehb-station-fullscreen__head strong { color: #18263d; font-size: 19px; } +.ehb-station-fullscreen__head span { color: #71819b; font-size: 11px; } +.ehb-station-fullscreen__head button { + display: inline-flex; + width: 40px; + height: 40px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 10px; + background: #e8eef8; + color: #23344f; +} +.ehb-station-fullscreen__filters { display: flex; gap: 6px; overflow-x: auto; margin: 12px 0; padding-bottom: 2px; } +.ehb-station-fullscreen__filters button { + min-height: 34px; + flex: none; + padding: 0 12px; + border: 0; + border-radius: 8px; + background: #e9eef6; + color: #60718b; + font-size: 12px; + font-weight: 650; +} +.ehb-station-fullscreen__filters button.is-active { background: #fff; color: #2f6bff; box-shadow: 0 2px 6px rgba(27,54,93,.1); } +.ehb-station-fullscreen__table-wrap { min-height: 0; flex: 1; overflow: auto; border-radius: 12px; background: #fff; } +.ehb-station-fullscreen__table-wrap table { width: 100%; border-collapse: collapse; table-layout: fixed; } +.ehb-station-fullscreen__table-wrap th, +.ehb-station-fullscreen__table-wrap td { padding: 9px 12px; border-bottom: 1px solid #edf1f6; color: #24334c; font-size: 12px; text-align: left; } +.ehb-station-fullscreen__table-wrap th { position: sticky; top: 0; z-index: 1; background: #f7f9fc; color: #65758e; font-weight: 700; } +.ehb-station-fullscreen__table-wrap th:first-child, +.ehb-station-fullscreen__table-wrap td:first-child { width: 34px; } +.ehb-station-fullscreen__table-wrap td:nth-child(n+4):not(:last-child) { font-family: var(--bi-font-mono); } +.ehb-station-fullscreen__table-wrap td button { border: 0; background: transparent; color: #2f6bff; font-weight: 650; } + +@media (max-width: 767px) { + .ehb-desktop-legend-label { display: none; } + .ehb-mobile-legend-label { display: inline; } + .ehb-station-fullscreen-trigger { top: 12px; right: 12px; width: auto; height: 32px; } +} + +/* Phone portrait drill-down: compact header and filters keep data above fold. */ +@media (max-width: 767px) and (orientation: portrait) { + .ehb-drill-modal--unified .ehb-station-core-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + gap: 0 !important; + margin-bottom: 8px; + padding: 0 !important; + } + + .ehb-drill-modal--unified .ehb-station-core-metrics .ehb-modal-meta-item { + min-height: 62px; + justify-content: center; + padding: 9px 12px !important; + border-bottom: 1px solid #edf1f6; + } + + .ehb-drill-modal--unified .ehb-station-core-metrics .ehb-modal-meta-item:nth-child(2n) { + border-inline-end: 0; + } + + .ehb-drill-modal--unified .ehb-station-core-metrics .ehb-modal-meta-item:nth-last-child(-n + 2) { + border-bottom: 0; + } + + .ehb-drill-modal--unified .ehb-station-core-metrics .ehb-modal-meta-label { + font-size: 10px !important; + } + + .ehb-drill-modal--unified .ehb-station-core-metrics .ehb-modal-meta-val { + font-size: 15px !important; + } + + .ehb-drill-modal--unified .ehb-modal-head { + display: grid !important; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 64px !important; + padding: max(8px, env(safe-area-inset-top, 8px)) 10px 8px !important; + } + + .ehb-drill-modal--unified .ehb-modal-head__title-group { + grid-column: 1; + width: 100%; + gap: 6px !important; + } + + .ehb-drill-modal--unified .ehb-modal-head__actions { + display: flex !important; + grid-column: 2; + align-items: center; + gap: 4px; + } + + .ehb-drill-modal--unified .ehb-modal-head__actions .mobile-list-fullscreen-trigger { + position: static; + width: 44px; + min-width: 44px; + min-height: 44px; + padding: 0; + border-color: rgba(255, 255, 255, .14); + background: rgba(255, 255, 255, .08); + color: #e8eef8; + } + + .ehb-drill-modal--unified .ehb-modal-head__actions .mobile-list-fullscreen-trigger__label { + display: none; + } + + .ehb-drill-modal--unified .ehb-modal-close-btn { + width: 44px; + height: 44px; + } + + .ehb-drill-modal--unified .ehb-modal-back-btn { + width: 44px; + min-width: 44px; + padding: 0 !important; + justify-content: center; + } + + .ehb-drill-modal--unified .ehb-modal-back-btn span { + display: none; + } + + .ehb-drill-modal--unified .ehb-modal-body { + width: 100%; + min-width: 0; + box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; + } + + .ehb-drill-modal--unified .ehb-modal-filter-row, + .ehb-drill-modal--unified .ehb-modal-filter-group { + min-width: 0; + box-sizing: border-box; + } + + .ehb-drill-modal--unified .ehb-modal-filter-group { + display: grid !important; + grid-template-columns: 120px minmax(0, 1fr); + align-items: center; + gap: 8px !important; + } + + .ehb-drill-modal--unified .ehb-drill-period-controls, + .ehb-drill-modal--unified .ehb-drill-period-select, + .ehb-drill-modal--unified .ehb-drill-period-trigger { + width: 100%; + min-width: 0; + box-sizing: border-box; + } + + .ehb-drill-modal--unified .ehb-drill-period-select { + flex: 0 0 120px; + } + + .ehb-drill-modal--unified .ehb-drill-period-trigger { + min-height: 44px; + white-space: nowrap; + } + + .ehb-drill-modal--unified .ehb-modal-search-input { + width: 100% !important; + min-width: 0 !important; + min-height: 44px !important; + } + + .ehb-drill-modal--unified .ehb-modal-hint-text { + grid-column: 1 / -1; + margin: 0 !important; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .ehb-drill-modal--unified .ehb-modal-filter-row { + margin-bottom: 6px; + padding: 8px !important; + } + + .ehb-drill-modal--unified .ehb-modal-table-wrap { + width: 100% !important; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + overflow-x: auto; + overflow-y: visible; + } +} + +/* Phone landscape: keep drill-down pages horizontal and prioritize table rows. */ +@media (max-width: 767px) and (orientation: landscape) { + .ehb-drill-filter-summary-row { display: none !important; } + .ehb-drill-filter-panel { display: flex !important; } + .ehb-order-desktop-primary { display: inline !important; } + .ehb-order-mobile-summary { display: none !important; } + .ehb-modal-card { + width: 100vw !important; + max-width: 100vw !important; + height: 100dvh !important; + max-height: 100dvh !important; + border-radius: 0 !important; + } + + .ehb-modal-head { + min-height: 44px !important; + padding: 5px max(10px, env(safe-area-inset-right)) 5px max(10px, env(safe-area-inset-left)) !important; + } + + .ehb-modal-head__title-group { gap: 8px !important; } + .ehb-modal-head__title { font-size: 13px !important; line-height: 1.2 !important; } + .ehb-modal-head__sub { margin-top: 1px !important; font-size: 9px !important; } + .ehb-modal-back-btn { min-height: 32px !important; height: 32px !important; } + + .ehb-drill-modal--unified .ehb-modal-body { + padding: 8px !important; + overflow-x: hidden !important; + overflow-y: auto !important; + } + + .ehb-drill-modal--unified .ehb-modal-meta-bar { + grid-template-columns: repeat(6, minmax(0, 1fr)) !important; + margin-bottom: 8px !important; + } + + .ehb-drill-modal--unified .ehb-modal-meta-item { + min-height: 44px; + justify-content: center; + padding: 5px 9px !important; + } + + .ehb-drill-modal--unified .ehb-modal-meta-label { font-size: 9px !important; } + .ehb-drill-modal--unified .ehb-modal-meta-val { font-size: 12px !important; } + + .ehb-drill-modal--unified .ehb-modal-filter-row { + min-height: 40px; + flex-direction: row !important; + align-items: center !important; + margin-bottom: 8px !important; + padding: 5px 8px !important; + } + + .ehb-drill-modal--unified .ehb-modal-filter-group { + width: auto !important; + flex: 1; + flex-wrap: nowrap !important; + } + + .ehb-drill-modal--unified .ehb-modal-search-input { + width: min(220px, 30vw) !important; + height: 32px !important; + min-height: 32px !important; + } + + .ehb-drill-modal--unified .ehb-drill-period-controls { width: auto; } + .ehb-drill-modal--unified .ehb-drill-period-controls select { flex: 0 0 108px !important; width: 108px; } + .ehb-drill-modal--unified .ehb-drill-period-controls input { height: 32px; padding-inline: 6px; font-size: 10px; } + .ehb-drill-modal--unified .ehb-drill-period-controls input[type='month'] { width: 108px; } + .ehb-drill-modal--unified .ehb-drill-period-controls input[type='date'] { width: 110px; } + + .ehb-drill-modal--unified .ehb-modal-hint-text { + min-width: 0; + font-size: 9px !important; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .ehb-drill-modal--unified .ehb-modal-table-wrap { + min-height: 0; + height: auto; + max-height: none; + overflow-x: auto !important; + overflow-y: visible !important; + } + + .ehb-drill-modal--unified .ehb-modal-table th, + .ehb-drill-modal--unified .ehb-modal-table > tbody > tr > td { + height: 34px; + padding-block: 6px; + } +} + +/* Browsers that leave native fullscreen before opening a drill dialog retain + the same compact landscape composition inside the rotated session. */ +@media (max-width: 767px) and (orientation: portrait) { + html.ehb-landscape-session .ehb-drill-filter-summary-row { display: none !important; } + html.ehb-landscape-session .ehb-drill-filter-panel { display: flex !important; } + html.ehb-landscape-session .ehb-order-desktop-primary { display: inline !important; } + html.ehb-landscape-session .ehb-order-mobile-summary { display: none !important; } + html.ehb-landscape-session .ehb-modal-card { + width: 100% !important; + max-width: 100% !important; + height: 100% !important; + max-height: 100% !important; + } + html.ehb-landscape-session .ehb-modal-head { + min-height: 44px !important; + padding: 5px 10px !important; + } + html.ehb-landscape-session .ehb-modal-head__title { font-size: 13px !important; line-height: 1.2 !important; } + html.ehb-landscape-session .ehb-modal-head__sub { margin-top: 1px !important; font-size: 9px !important; } + html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-body { + padding: 8px !important; + overflow-x: hidden !important; + overflow-y: auto !important; + } + html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-meta-bar { + grid-template-columns: repeat(6, minmax(0, 1fr)) !important; + gap: 0 !important; + margin-bottom: 8px !important; + padding: 0 !important; + } + html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-meta-item { + min-height: 44px; + justify-content: center; + padding: 5px 9px !important; + } + html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-filter-row { + min-height: 40px; + flex-direction: row !important; + align-items: center !important; + gap: 8px !important; + margin-bottom: 8px !important; + padding: 5px 8px !important; + } + html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-filter-group { + width: auto !important; + flex: 1; + flex-wrap: nowrap !important; + } + html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-search-input { + width: min(220px, 30vw) !important; + height: 32px !important; + min-height: 32px !important; + } + html.ehb-landscape-session .ehb-drill-modal--unified .ehb-drill-period-controls { width: auto; } + html.ehb-landscape-session .ehb-drill-modal--unified .ehb-drill-period-controls select { flex: 0 0 108px !important; width: 108px; } + html.ehb-landscape-session .ehb-drill-modal--unified .ehb-drill-period-controls input { height: 32px; padding-inline: 6px; font-size: 10px; } + html.ehb-landscape-session .ehb-drill-modal--unified .ehb-drill-period-controls input[type='month'] { width: 108px; } + html.ehb-landscape-session .ehb-drill-modal--unified .ehb-drill-period-controls input[type='date'] { width: 110px; } + html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-hint-text { + min-width: 0; + font-size: 9px !important; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-table-wrap { + min-height: 0; + height: auto; + max-height: none; + overflow-x: auto !important; + overflow-y: visible !important; + } +} + +/* 横屏会话可能将视口放大到767px以上,因此不能只依赖手机断点解除列表限高。 */ +html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-body { + overflow-x: hidden !important; + overflow-y: auto !important; +} + +html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-table-wrap.is-v-scroll { + height: auto !important; + max-height: none !important; + overflow-x: auto !important; + overflow-y: visible !important; +} + +html.ehb-landscape-session .ehb-drill-modal--unified .ehb-station-core-metrics { + grid-template-columns: repeat(4, minmax(0, 1fr)) !important; +} + +.ehb-desktop-live-badge, +.ehb-desktop-time-range, +.ehb-desktop-year-label, +.ehb-desktop-year-suffix, +.ehb-year-calendar, +.ehb-filter-dot { display: none; } + +.ehb-diagnosis { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 116px repeat(4, minmax(0, 1fr)); + min-height: 68px; + overflow: visible; + border: 1px solid #dce5f0; + border-radius: 12px; + background: #fff; +} +.ehb-diagnosis__heading { + display: flex; + align-items: center; + padding: 0 16px; + color: #263750; + font-size: 14px; +} +.ehb-diagnosis__item { + position: relative; + display: grid; + align-content: center; + gap: 2px; + min-width: 0; + padding: 8px 16px; + border-left: 1px solid #e7ecf3; +} +.ehb-diagnosis__item > span, +.ehb-diagnosis__item > small { overflow: hidden; color: #71819b; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.ehb-diagnosis__item > strong { color: #24334c; font-family: var(--bi-font-mono); font-size: 15px; line-height: 1.2; white-space: nowrap; } +.ehb-diagnosis__item > strong svg { margin-left: 3px; vertical-align: -2px; transition: transform .18s ease; } +.ehb-diagnosis__item > strong svg.is-open { transform: rotate(180deg); } +.ehb-diagnosis__item .is-positive { color: #11866d; } +.ehb-diagnosis__item .is-warning { color: #c96c12; } +.ehb-diagnosis__item.is-action { + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.ehb-diagnosis__item.is-action:hover { background: #f7f9fc; } +.ehb-diagnosis__item.is-open { z-index: 31; background: #f7f9fc; } + +@media (max-width: 767px) { + .ehb-diagnosis { display: none !important; } +} + +@media (min-width: 768px) { + .ehb-shell { display: block; background: #f3f6fa; } + .ehb-rail { display: none; } + .ehb-body { max-width: none; padding: 8px 16px 28px; } + + .ehb-chrome { + display: grid; + grid-template-columns: 1fr; + margin: 0 0 8px; + padding: 6px 14px 0; + border: 1px solid #e2e8f1; + border-radius: 0; + background: #fff; + box-shadow: none; + } + .ehb-chrome__lead { display: flex; width: 100%; min-height: 34px; align-items: center; gap: 9px; } + .ehb-mobile-brand-icon { + display: inline-flex; + width: 34px; + height: 34px; + flex: 0 0 34px; + align-items: center; + justify-content: center; + border-radius: 10px; + background: #2f6bff; + color: #fff; + } + .ehb-crumb, + .ehb-time-range-pill { display: none !important; } + .ehb-chrome__lead h1 { font-size: 18px; line-height: 1.15; } + .ehb-desktop-live-badge { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 9px; + border: 1px solid #9ee8c7; + border-radius: 12px; + background: #ecfdf5; + color: #12845d; + font-size: 12px; + font-weight: 700; + } + .ehb-desktop-time-range { display: block; margin-top: 1px; color: #71819b; font-size: 10px; line-height: 1.2; } + + .ehb-chrome__tools { + display: flex; + width: 100%; + min-height: 34px; + align-items: center; + justify-content: space-between; + margin-top: 4px; + padding: 3px 0 4px; + border-top: 1px solid #e7ecf3; + } + .ehb-scope-switches, + .ehb-view-switches { gap: 0; } + .ehb-switch-label { display: none; } + .ehb-chrome__tools .ehb-seg { min-width: 156px; gap: 3px; padding: 0; border: 0; border-radius: 8px; background: transparent; } + .ehb-chrome__tools .ehb-seg button { min-height: 26px; padding: 0 11px; border: 1px solid transparent; font-size: 11px; } + .ehb-chrome__tools .ehb-seg button.is-active { border-color: #d7e3f5; background: #eaf1fb; color: #2f6bff; box-shadow: none; } + + .ehb-overview-filter { + margin: 0 0 12px !important; + padding: 6px 14px !important; + border-color: #e1e8f1 !important; + border-radius: 0 !important; + background: #f8fafc !important; + box-shadow: none !important; + } + .ehb-overview-filter .ehb-daily-filter-row { width: 100%; justify-content: space-between; } + .ehb-overview-filter .ehb-daily-filter-group { gap: 6px; } + .ehb-overview-filter .ehb-daily-filter-group:last-child { margin-left: auto; } + .ehb-overview-filter .ehb-chrome__clock, + .ehb-overview-filter .ehb-btn--ghost { display: none; } + .ehb-desktop-year-label { display: inline; color: #3e4e66; font-size: 13px; font-weight: 700; white-space: nowrap; } + .ehb-overview-filter .ehb-year-select-btn { width: auto; min-width: 112px; height: 32px; padding: 0 10px; } + .ehb-overview-filter .ehb-year-calendar, + .ehb-overview-filter .ehb-desktop-year-suffix { display: inline-flex; } + .ehb-overview-filter .ehb-year-calendar { color: #71819b; } + .ehb-overview-filter .ehb-filter-dot { + display: inline-block; + width: 6px; + height: 6px; + margin-right: 5px; + border-radius: 50%; + } + .ehb-overview-filter .ehb-filter-dot.is-own { background: #2f6bff; } + .ehb-overview-filter .ehb-filter-dot.is-external { background: #f59e0b; } + .ehb-overview-filter .ehb-fleet-segmented, + .ehb-overview-filter .ehb-pill-tabs { min-height: 34px; padding: 2px; } + .ehb-overview-filter .ehb-fleet-btn, + .ehb-overview-filter .ehb-pill-btn { min-height: 28px; padding: 0 12px; } + + /* PC 端菜单统一采用紧凑工具栏规格,移动端仍保留 36px+ 热区。 */ + .ehb-mobile-detail-tabs-head { gap: 12px; padding: 10px 16px 0; } + .ehb-mobile-detail-tabs { gap: 2px; padding: 2px; border-radius: 8px; } + .ehb-mobile-detail-tabs button { + min-width: 92px; + min-height: 28px; + padding: 0 12px; + border-radius: 7px; + font-size: 12px; + } + .ehb-mini-tabs { gap: 2px; padding: 2px; border-radius: 8px; } + .ehb-mini-tab { min-height: 28px; padding: 0 9px; border-radius: 7px; } + .ehb-mobile-detail-panel { padding-top: 12px; } + .ehb-mobile-detail-panel .ehb-sum-table-card__head { gap: 8px !important; } + + .ehb-host { margin-bottom: 20px; padding: 0; border: 0; background: transparent; } + .ehb-metric-grid { grid-template-columns: repeat(4,minmax(0,1fr)); gap: 16px; } + .ehb-host-kpi > .ehb-kpi-dual:nth-child(5) { display: none; } + .ehb-host-kpi .ehb-kpi-dual:nth-child(-n+4) { + min-height: 146px; + padding: 14px 16px; + border: 1px solid #dce5f0; + border-radius: 14px; + background: #fff; + box-shadow: none; + } + .ehb-host-kpi .ehb-kpi-dual:nth-child(1) { color: #21324d; border-color: #c9d8ed; background: #eaf1fb; box-shadow: none; } + .ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__label, + .ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__symbol, + .ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__unit, + .ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__footer { color: #687891 !important; } + .ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__num { color: #18263d; } + .ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__deck { color: #43546d; background: #f5f8fd; } + .ehb-host-kpi .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__badge { color: #2f6bff; background: #eef4ff; } + .ehb-kpi-dual__num { font-size: 30px; } + .ehb-kpi-dual__deck { background: #f6f8fb; } + .ehb-insight__card { min-height: 104px; padding: 12px 14px; border: 1px solid #dce5f0; border-radius: 14px; box-shadow: none; } +} + +/* 最终响应式覆盖:保证 PC 五项全显示,移动端五项均可操作。 */ +@media (min-width: 1181px) { + .ehb-metric-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } +} + +@media (min-width: 768px) { + .ehb-host-kpi > .ehb-kpi-dual:nth-child(5) { display: flex; } +} + +@media (max-width: 767px) { + .ehb-drill-modal--cumulative .ehb-modal-meta-bar { display: none !important; } + .ehb-drill-modal--cumulative .ehb-modal-head__actions { display: none !important; } + .ehb-drill-modal--cumulative .ehb-drill-filter-summary-row { grid-template-columns: minmax(0, 1fr); } + .ehb-drill-modal--cumulative .ehb-drill-filter-summary-row .mobile-list-fullscreen-trigger { display: none !important; } + .ehb-mobile-drill-overview { + display: block; + margin-bottom: 10px; + padding: 14px; + border: 1px solid #dfe7f0; + border-radius: 12px; + background: #fff; + } + .ehb-mobile-drill-overview__metrics { gap: 0; } + .ehb-mobile-drill-overview__metrics > div { display: grid; gap: 5px; min-width: 0; padding: 2px 12px; } + .ehb-mobile-drill-overview__metrics > div:first-child { padding-left: 0; border-inline-end: 1px solid #e7edf5; } + .ehb-mobile-drill-overview__metrics span { color: #71819b; font-size: 10px; } + .ehb-mobile-drill-overview__metrics strong { color: #245fd4; font: 750 19px/1.25 var(--bi-font-mono); } + .ehb-mobile-drill-overview__metrics > div:nth-child(2) strong { color: #2c8a78; } + .ehb-mobile-drill-overview__metrics small { font: 600 9px/1 var(--bi-font); } + .ehb-mobile-drill-overview > p { display: flex; justify-content: space-between; gap: 8px; margin: 12px 0 0; padding-top: 10px; border-top: 1px solid #edf1f5; color: #71819b; font-size: 10px; } + .ehb-drill-modal--cumulative .ehb-h5-scroll-hint { display: none !important; } + .ehb-mobile-section-title { display: none; } + .ehb-drill-filter-summary-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 44px; + align-items: stretch; + gap: 8px; + margin-bottom: 10px; + } + .ehb-drill-filter-summary-row .ehb-drill-filter-summary { min-width: 0; margin: 0; } + .ehb-drill-filter-summary-row .mobile-list-fullscreen-trigger { + position: static; + width: 44px; + min-width: 44px; + height: auto; + min-height: 44px; + padding: 0; + justify-content: center; + } + .ehb-drill-filter-summary-row .mobile-list-fullscreen-trigger__label { display: none; } + .ehb-drill-modal--unified .ehb-modal-head__sub { + overflow: visible !important; + white-space: normal !important; + text-overflow: clip !important; + } + .ehb-host-kpi > .ehb-kpi-dual:nth-child(1) { + color: #21324d; + border-color: #c9d8ed; + background: #eaf1fb; + box-shadow: none; + } + .ehb-host-kpi > .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__label, + .ehb-host-kpi > .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__symbol, + .ehb-host-kpi > .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__unit, + .ehb-host-kpi > .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__footer { + color: #526987 !important; + } + .ehb-host-kpi > .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__num { color: #245fd4; } + .ehb-host-kpi > .ehb-kpi-dual:nth-child(1) .ehb-kpi-dual__deck { + color: #43546d; + background: rgba(255, 255, 255, .62); + } + .ehb-host-kpi .ehb-kpi-dual__deck.is-three small { color: #62748e; } + .ehb-host-kpi .ehb-kpi-dual__deck.is-three strong { color: #18263d; } + .ehb-host-kpi > .ehb-kpi-dual:nth-child(n+3) { + display: flex; + min-height: 92px; + padding: 10px 12px; + } + .ehb-host-kpi > .ehb-kpi-dual:nth-child(3) { + grid-column: 1; + grid-row: 2; + justify-content: center; + } + .ehb-host-kpi > .ehb-kpi-dual:nth-child(3) .ehb-kpi-dual__deck { margin-top: 12px; } + .ehb-recent-kpis { + display: grid; + grid-column: 2; + grid-row: 2; + grid-template-rows: repeat(2, minmax(0, 1fr)); + gap: 0; + overflow: hidden; + border: 1px solid #dce5f0; + border-radius: 15px; + background: #fff; + } + .ehb-host-kpi > .ehb-recent-kpis > .ehb-kpi-dual { + min-height: 0 !important; + padding: 10px 12px !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + color: #21324d !important; + box-shadow: none !important; + } + .ehb-host-kpi > .ehb-recent-kpis > .ehb-kpi-dual .ehb-kpi-dual__label, + .ehb-host-kpi > .ehb-recent-kpis > .ehb-kpi-dual .ehb-kpi-dual__symbol, + .ehb-host-kpi > .ehb-recent-kpis > .ehb-kpi-dual .ehb-kpi-dual__unit, + .ehb-host-kpi > .ehb-recent-kpis > .ehb-kpi-dual .ehb-kpi-dual__footer { color: #687891 !important; } + .ehb-host-kpi > .ehb-recent-kpis > .ehb-kpi-dual .ehb-kpi-dual__num { color: #18263d !important; } + .ehb-host-kpi > .ehb-recent-kpis > .ehb-kpi-dual + .ehb-kpi-dual { border-top: 1px solid #e7edf5 !important; } + .ehb-recent-kpis .ehb-kpi-dual__deck { margin-top: 6px; padding: 5px 7px; } + .ehb-recent-kpis .ehb-kpi-dual__num { font-size: clamp(20px, 6.5vw, 28px) !important; } +} + +/* 2026-08-29 移动端经营总览:结果先行,减少双列大卡与嵌套卡片。 */ +.ehb-mobile-operating-overview { display: none; } +.ehb-mobile-profit-card, +.ehb-mobile-period-cards { display: none; } + +@media (max-width: 767px) { + .ehb-body { + padding: 10px 12px calc(76px + env(safe-area-inset-bottom)); + } + + .ehb-chrome { + margin-bottom: 4px; + padding-bottom: 4px; + } + + .ehb-mobile-filter-panel { + margin-top: 6px; + padding: 8px; + border-radius: 12px; + } + + .ehb-mobile-current-range { + margin-top: 6px; + } + + .ehb-mobile-operating-overview { + display: block; + overflow: hidden; + margin-bottom: 12px; + border: 1px solid #dce5f0; + border-radius: 14px; + background: #fff; + } + + .ehb-mobile-operating-overview__head { + display: flex; + min-height: 40px; + align-items: center; + justify-content: space-between; + padding: 0 14px; + border-bottom: 1px solid #e7edf5; + } + + .ehb-mobile-operating-overview__head strong { + color: #18263d; + font-size: 15px; + } + + .ehb-mobile-operating-overview__head span { + color: #71819b; + font-size: 11px; + } + + .ehb-mobile-operating-overview__metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .ehb-mobile-operating-overview__metrics button { + display: grid; + min-width: 0; + min-height: 86px; + align-content: center; + gap: 6px; + padding: 12px 14px; + border: 0; + background: transparent; + text-align: left; + } + + .ehb-mobile-operating-overview__metrics button + button { + border-left: 1px solid #e7edf5; + } + + .ehb-mobile-operating-overview__metrics button > span { + color: #64748b; + font-size: 12px; + font-weight: 650; + } + + .ehb-mobile-operating-overview__metrics button > strong { + overflow: hidden; + color: #18263d; + font-family: var(--bi-font-mono); + font-size: clamp(22px, 6.8vw, 28px); + font-variant-numeric: tabular-nums; + line-height: 1.1; + letter-spacing: -.03em; + white-space: nowrap; + } + + .ehb-mobile-operating-overview__metrics button:first-child > strong { + color: #245fd4; + } + + .ehb-mobile-operating-overview__metrics small { + margin: 0 2px; + color: #64748b; + font-size: 11px; + font-weight: 650; + letter-spacing: 0; + } + + .ehb-mobile-operating-overview__bearers { + padding: 6px 12px 10px; + border-top: 1px solid #e7edf5; + background: #f8fafc; + } + + .ehb-mobile-operating-overview__bearers > div { + display: grid; + min-height: 30px; + grid-template-columns: minmax(66px, .8fr) minmax(76px, .9fr) minmax(98px, 1.2fr); + align-items: center; + gap: 6px; + color: #64748b; + font-size: 11px; + } + + .ehb-mobile-operating-overview__bearers > div + div { + border-top: 1px solid #edf1f6; + } + + .ehb-mobile-operating-overview__bearers .is-heading { + min-height: 26px; + color: #7b8aa2; + font-size: 10px; + } + + .ehb-mobile-operating-overview__bearers span:nth-child(n+2), + .ehb-mobile-operating-overview__bearers strong { + text-align: right; + } + + .ehb-mobile-operating-overview__bearers strong { + color: #26364f; + font-family: var(--bi-font-mono); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .ehb-host-kpi { + grid-template-columns: 1fr !important; + gap: 10px !important; + } + + .ehb-host-kpi > .ehb-kpi-dual:nth-child(-n+2) { + display: none !important; + } + + .ehb-host-kpi > .ehb-kpi-dual:nth-child(3) { + grid-column: 1 / -1; + grid-row: auto; + min-height: 88px; + padding: 12px 14px; + } + + .ehb-host-kpi > .ehb-kpi-dual:nth-child(3) .ehb-kpi-dual__deck { + display: flex !important; + margin-top: 8px; + padding: 6px 8px; + } + + .ehb-recent-kpis { + display: grid; + grid-column: 1 / -1; + grid-row: auto; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: none; + border-radius: 14px; + } + + .ehb-host-kpi > .ehb-recent-kpis > .ehb-kpi-dual { + min-height: 104px !important; + } + + .ehb-host-kpi > .ehb-recent-kpis > .ehb-kpi-dual + .ehb-kpi-dual { + border-top: 0 !important; + border-left: 1px solid #e7edf5 !important; + } + + .ehb-recent-kpis .ehb-kpi-dual__deck { + display: grid !important; + grid-template-columns: 1fr !important; + gap: 2px !important; + } +} + +/* 2026-08-29 参考稿细化:累计构成大卡、横向利润卡与四项筛选。 */ +@media (max-width: 767px) { + .ehb-mobile-primary-filters { + grid-template-columns: 72px 72px minmax(0, 1fr) 72px; + gap: 6px; + } + + .ehb-mobile-order-chip { + display: none !important; + } + + .ehb-mobile-primary-filters .ehb-year-select-btn, + .ehb-mobile-primary-filters .ehb-mobile-context-switch, + .ehb-mobile-primary-filters .ehb-mobile-fleet-chip, + .ehb-mobile-primary-filters .ehb-mobile-filter-disclosure { + min-height: 44px; + border-radius: 9px !important; + } + + .ehb-mobile-operating-overview__head { + min-height: 46px; + } + + .ehb-mobile-operating-overview__metrics { + margin: 0 14px; + border-bottom: 0; + } + + .ehb-mobile-operating-overview__metrics button { + min-height: 100px; + padding: 14px 4px; + } + + .ehb-mobile-operating-overview__metrics button:first-child { + padding-right: 12px; + } + + .ehb-mobile-operating-overview__metrics button + button { + padding-left: 18px; + } + + .ehb-mobile-operating-overview__metrics button > strong { + font-size: clamp(26px, 8vw, 34px); + } + + .ehb-mobile-operating-overview__bar { + display: flex; + height: 9px; + margin: 2px 14px 14px; + overflow: hidden; + border-radius: 999px; + background: #d7dee9; + } + + .ehb-mobile-operating-overview__bar > span { + min-width: 2px; + } + + .ehb-mobile-operating-overview__bar .is-company, + .ehb-mobile-operating-overview__legend i.is-company { background: #2f6bff; } + .ehb-mobile-operating-overview__bar .is-customer, + .ehb-mobile-operating-overview__legend i.is-customer { background: #35b8ac; } + .ehb-mobile-operating-overview__bar .is-pending, + .ehb-mobile-operating-overview__legend i.is-pending { background: #f28a35; } + + .ehb-mobile-operating-overview__legend { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + padding: 0 14px 14px; + } + + .ehb-mobile-operating-overview__legend > div { + display: grid; + min-width: 0; + gap: 4px; + color: #64748b; + font-size: 10px; + } + + .ehb-mobile-operating-overview__legend span { + display: flex; + align-items: center; + gap: 5px; + color: #40516b; + font-size: 11px; + font-weight: 700; + } + + .ehb-mobile-operating-overview__legend i { + width: 7px; + height: 7px; + flex: 0 0 7px; + border-radius: 50%; + } + + .ehb-mobile-operating-overview__legend strong { + color: #293b56; + font-family: var(--bi-font-mono); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .ehb-mobile-operating-overview__legend small { + color: #7b8aa2; + font-size: 10px; + } + + .ehb-mobile-operating-overview__action { + display: flex; + width: calc(100% - 28px); + min-height: 44px; + align-items: center; + justify-content: center; + gap: 4px; + margin: 0 14px; + border: 0; + border-top: 1px solid #e7edf5; + background: transparent; + color: #2f6bff; + font-size: 13px; + font-weight: 700; + } + + .ehb-host-kpi > .ehb-kpi-dual:nth-child(3) { + position: relative; + display: block; + min-height: 116px; + padding: 18px 52% 18px 18px; + } + + .ehb-host-kpi > .ehb-kpi-dual:nth-child(3) .ehb-kpi-dual__head { + flex-direction: row-reverse; + justify-content: flex-end; + gap: 10px; + } + + .ehb-host-kpi > .ehb-kpi-dual:nth-child(3) .ehb-kpi-dual__badge { + display: grid !important; + width: 38px; + height: 38px; + border-radius: 50%; + } + + .ehb-host-kpi > .ehb-kpi-dual:nth-child(3) .ehb-kpi-dual__val { + margin: 4px 0 0 48px; + } + + .ehb-host-kpi > .ehb-kpi-dual:nth-child(3) .ehb-kpi-dual__num { + color: #16a34a; + } + + .ehb-host-kpi > .ehb-kpi-dual:nth-child(3) .ehb-kpi-dual__deck { + position: absolute; + top: 16px; + right: 14px; + bottom: 16px; + display: grid !important; + width: 44%; + align-content: center; + gap: 10px !important; + margin: 0; + padding: 0 0 0 16px; + border-left: 1px solid #e7edf5; + border-radius: 0; + background: transparent; + } + + .ehb-host-kpi > .ehb-recent-kpis > .ehb-kpi-dual .ehb-kpi-dual__badge { + display: grid !important; + width: 32px; + height: 32px; + border-radius: 50%; + } + + .ehb-mobile-month-summary { + margin-bottom: 12px; + } +} + +/* 竖屏明细页头统一:仅保留返回,长业务名称可换行,不让操作按钮覆盖标题。 */ +@media (max-width: 767px) and (orientation: portrait) { + .ehb-drill-modal--unified .ehb-modal-head { + align-items: flex-start !important; + min-height: 68px !important; + height: auto !important; + padding: max(10px, env(safe-area-inset-top, 10px)) 12px 10px !important; + } + + .ehb-drill-modal--unified .ehb-modal-head__title-group { + width: 100%; + align-items: flex-start !important; + gap: 10px !important; + } + + .ehb-drill-modal--unified .ehb-modal-head__title-group > div { + min-width: 0 !important; + padding-top: 1px; + } + + .ehb-drill-modal--unified .ehb-modal-head__title { + display: block !important; + overflow: visible !important; + color: #f8fafc !important; + font-size: 14px !important; + line-height: 1.35 !important; + white-space: normal !important; + text-overflow: clip !important; + text-wrap: pretty; + -webkit-line-clamp: unset !important; + -webkit-box-orient: initial !important; + } + + .ehb-drill-modal--unified .ehb-modal-head__sub { + overflow: visible !important; + margin-top: 3px !important; + font-size: 10px !important; + line-height: 1.35 !important; + white-space: normal !important; + text-overflow: clip !important; + } + + .ehb-drill-modal--unified .ehb-modal-head__actions { + display: none !important; + } + + .ehb-drill-modal--unified .ehb-modal-back-btn { + flex: 0 0 44px; + align-self: flex-start; + } + + .ehb-drill-modal--supports-landscape .ehb-modal-head__title-group { + box-sizing: border-box; + padding-inline-end: 54px; + } + + .ehb-drill-modal--supports-landscape .ehb-modal-head__actions { + position: absolute; + top: max(10px, env(safe-area-inset-top, 10px)); + right: 12px; + display: flex !important; + width: 44px; + height: 44px; + } + + .ehb-drill-modal--supports-landscape .ehb-modal-head__actions .mobile-list-fullscreen-trigger { + position: static; + width: 44px; + min-width: 44px; + height: 44px; + padding: 0; + } + + .ehb-drill-modal--supports-landscape .ehb-modal-close-btn { + display: none !important; + } +} + +/* 利润与近期指标使用独立移动端结构,避免桌面通用 KPI 内部顺序错位。 */ +@media (max-width: 767px) { + .ehb-mobile-profit-card { + display: grid; + width: 100%; + min-height: 124px; + margin-bottom: 12px; + grid-template-columns: 44px minmax(0, 1fr) minmax(132px, .9fr); + align-items: center; + gap: 10px; + padding: 16px; + border: 1px solid #dce5f0; + border-radius: 14px; + background: #fff; + color: #26364f; + text-align: left; + } + + .ehb-mobile-profit-card__icon { + display: grid; + width: 44px; + height: 44px; + place-items: center; + border-radius: 50%; + background: #e7f8ef; + color: #16a34a; + } + + .ehb-mobile-profit-card__result { + display: grid; + min-width: 0; + gap: 8px; + } + + .ehb-mobile-profit-card__result > span { + color: #536782; + font-size: 13px; + font-weight: 700; + } + + .ehb-mobile-profit-card__result > strong { + color: #16a34a; + font-family: var(--bi-font-mono); + font-size: clamp(24px, 7vw, 31px); + font-variant-numeric: tabular-nums; + line-height: 1; + white-space: nowrap; + } + + .ehb-mobile-profit-card__result small { + margin: 0 2px; + color: #58708f; + font-size: 11px; + } + + .ehb-mobile-profit-card__breakdown { + display: grid; + min-width: 0; + align-content: center; + gap: 12px; + align-self: stretch; + padding-left: 14px; + border-left: 1px solid #e3e9f1; + } + + .ehb-mobile-profit-card__breakdown > span { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + align-items: center; + gap: 6px; + color: #2f6bff; + } + + .ehb-mobile-profit-card__breakdown > span + span { + color: #f28a35; + } + + .ehb-mobile-profit-card__breakdown > span > span { + display: grid; + gap: 2px; + color: #64748b; + font-size: 11px; + } + + .ehb-mobile-profit-card__breakdown strong { + color: #445975; + font-family: var(--bi-font-mono); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .ehb-mobile-period-cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .ehb-mobile-period-card { + display: grid; + min-width: 0; + min-height: 144px; + align-content: start; + gap: 10px; + padding: 14px; + border: 1px solid #dce5f0; + border-radius: 14px; + background: #fff; + color: #26364f; + text-align: left; + } + + .ehb-mobile-period-card__head { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 6px; + color: #536782; + font-size: 13px; + font-weight: 700; + } + + .ehb-mobile-period-card__head > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .ehb-mobile-period-card__head i { + display: grid; + width: 34px; + height: 34px; + flex: 0 0 34px; + place-items: center; + border-radius: 50%; + background: #eef4ff; + color: #2f6bff; + } + + .ehb-mobile-period-card > strong { + color: #18263d; + font-family: var(--bi-font-mono); + font-size: clamp(27px, 8vw, 34px); + font-variant-numeric: tabular-nums; + line-height: 1; + white-space: nowrap; + } + + .ehb-mobile-period-card > strong small { + margin-left: 3px; + color: #64748b; + font-size: 11px; + } + + .ehb-mobile-period-card__footer { + margin-top: auto; + padding-top: 10px; + border-top: 1px solid #e7edf5; + color: #657995; + font-size: 11px; + white-space: nowrap; + } + + .ehb-host-kpi > .ehb-kpi-dual:nth-child(3), + .ehb-host-kpi > .ehb-recent-kpis { + display: none !important; + } +} + +/* 移动端紧凑密度:在不缩小触控热区的前提下,让一屏承载更多经营信息。 */ +@media (max-width: 767px) { + .ehb-body { + --ehb-mobile-section-gap: 8px; + --ehb-mobile-card-padding: 12px; + padding-inline: 8px; + } + + .ehb-chrome, + .ehb-mobile-operating-overview, + .ehb-mobile-profit-card, + .ehb-mobile-month-summary, + .ehb-host, + .ehb-chart-box, + .ehb-sum-table-card, + .ehb-mobile-detail-tabs-card { + margin-bottom: var(--ehb-mobile-section-gap) !important; + } + + .ehb-chart-box, + .ehb-sum-table-card, + .ehb-mobile-profit-card, + .ehb-mobile-period-card { + padding: var(--ehb-mobile-card-padding) !important; + } + + .ehb-mobile-period-cards, + .ehb-host-kpi, + .ehb-overview-charts, + .ehb-two-charts-row { + gap: var(--ehb-mobile-section-gap) !important; + } + + .ehb-mobile-operating-overview__head { + min-height: 40px; + padding-inline: 12px; + } + + .ehb-mobile-operating-overview__metrics { + margin-inline: 12px; + } + + .ehb-mobile-operating-overview__metrics button { + min-height: 84px; + padding-block: 10px; + } + + .ehb-mobile-profit-card { + min-height: 104px; + } + + .ehb-mobile-period-card { + min-height: 120px; + gap: 8px; + } + + .ehb-drill-modal--unified .ehb-modal-body { + padding: 8px !important; + } + + .ehb-drill-modal--unified .ehb-modal-filter-row { + margin-bottom: 6px !important; + padding: 6px !important; + } + + .ehb-drill-modal--unified .ehb-modal-table { + min-width: 720px !important; + table-layout: fixed !important; + } + + .ehb-drill-modal--unified .ehb-modal-table th, + .ehb-drill-modal--unified .ehb-modal-table > tbody > tr > td { + box-sizing: border-box; + min-height: 44px; + height: 44px; + padding: 6px 8px !important; + line-height: 1.3; + } + + .ehb-drill-modal--unified .ehb-modal-table th:first-child, + .ehb-drill-modal--unified .ehb-modal-table td:first-child { + width: 132px !important; + min-width: 132px !important; + max-width: 132px !important; + } + + .ehb-drill-modal--unified .ehb-modal-table th:nth-child(2), + .ehb-drill-modal--unified .ehb-modal-table td:nth-child(2) { + width: 132px; + } + + .ehb-drill-modal--unified .ehb-tree-cell-l1 { + padding-left: 6px !important; + } + + .ehb-drill-modal--unified .ehb-tree-cell-l2 { + padding-left: 10px !important; + } + + .ehb-drill-modal--unified .ehb-tree-node-title { + gap: 4px; + } +} + +/* 放在全部历史规则之后,确保趋势数字本身保持中性。 */ +.ehb-day-change, +.ehb-day-change.is-up, +.ehb-day-change.is-down, +.ehb-trend-value, +.ehb-trend-value.is-up, +.ehb-trend-value.is-down { + color: #334155 !important; +} + +/* 数据明细工具区:标题、页签、地域筛选、统计范围保持同一紧凑节奏。 */ +.ehb-mobile-detail-tabs-head { + gap: 10px; + padding: 12px 16px 8px; +} + +.ehb-mobile-detail-panel { + padding: 8px 16px 16px !important; +} + +.ehb-station-summary-card .ehb-sum-table-card__head { + min-height: 34px; + margin-bottom: 8px; + gap: 8px !important; +} + +.ehb-station-summary-card .ehb-sum-table-card__head > div:first-child { + min-width: 0; + flex: 1 1 auto; + gap: 8px !important; +} + +.ehb-station-summary-card .ehb-sum-table-card__meta { + flex: 0 0 auto; + margin-left: auto; + margin-right: 102px; + white-space: nowrap; +} + +.ehb-station-fullscreen__table-wrap table { + width: max(100%, 980px); + min-width: 980px; + table-layout: auto; +} + +.ehb-station-fullscreen__table-wrap th:nth-child(2), +.ehb-station-fullscreen__table-wrap td:nth-child(2) { + min-width: 230px; +} + +@media (max-width: 767px) { + .ehb-mobile-detail-tabs-head { + padding: 12px 12px 6px; + } + + .ehb-mobile-detail-tabs { + margin-top: 8px; + } + + .ehb-mobile-detail-panel { + padding: 6px 12px 12px !important; + } + + .ehb-station-summary-card .ehb-sum-table-card__head { + margin-bottom: 6px; + } + + .ehb-station-summary-card .ehb-sum-table-card__meta { + margin: 0; + white-space: normal; + } + + [data-mobile-fullscreen-active="true"] .ehb-mobile-detail-tabs-head { + padding: 4px 106px 6px 4px !important; + } + + [data-mobile-fullscreen-active="true"] .ehb-mobile-detail-tabs-title { + padding-right: 0; + } + + [data-mobile-fullscreen-active="true"] .ehb-mobile-detail-tabs { + width: min(360px, 100%); + margin-top: 4px; + } + + [data-mobile-fullscreen-active="true"] .ehb-mobile-detail-panel { + padding: 4px !important; + } + + [data-mobile-fullscreen-active="true"] .ehb-sum-table { + width: 1120px !important; + min-width: 1120px !important; + } + + [data-mobile-fullscreen-active="true"] .ehb-sum-table-wrap { + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; + } + + [data-mobile-fullscreen-active="true"] .ehb-station-fullscreen-trigger { + display: none !important; + } +} + +/* 真实数据下钻沿用 8113 原型的信息顺序;窄屏只滚动,不替换信息架构。 */ +.ehb-drill-root-tabs { + display: flex; + justify-content: flex-end; + gap: 4px; + margin: 0 0 10px; +} + +.ehb-drill-root-tabs .ehb-pill-btn { + min-height: 30px; + padding: 5px 13px; + border: 1px solid #dbe4ef; + border-radius: 8px; + background: #fff; + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.ehb-drill-root-tabs .ehb-pill-btn.is-active { + border-color: #c6d9ff; + background: #eaf2ff; + color: #2f6bff; +} + +@media (max-width: 767px) { + .ehb-drill-modal--unified .ehb-modal-body { + display: block !important; + overflow: auto !important; + padding: 12px !important; + } + + .ehb-drill-modal--unified .ehb-drill-root-tabs { + display: flex !important; + justify-content: flex-end; + margin-bottom: 10px; + } + + .ehb-drill-modal--unified .ehb-modal-meta-bar { + display: grid !important; + grid-template-columns: repeat(4, minmax(126px, 1fr)) !important; + min-width: 600px; + overflow: visible !important; + } + + .ehb-drill-modal--unified .ehb-modal-filter-row, + .ehb-drill-modal--unified .ehb-modal-filter-group { + display: flex !important; + width: auto !important; + flex-flow: row wrap !important; + align-items: center !important; + } + + .ehb-drill-modal--unified .ehb-modal-filter-row { + gap: 8px !important; + padding: 10px !important; + } + + .ehb-drill-modal--unified .ehb-modal-filter-group { + gap: 8px !important; + } + + .ehb-drill-modal--unified .ehb-bi-search-select { + display: block !important; + width: 180px !important; + } + + .ehb-drill-modal--unified .ehb-fleet-segmented, + .ehb-drill-modal--unified .ehb-export-btn, + .ehb-drill-modal--unified .ehb-modal-hint-text, + .ehb-drill-modal--unified .ehb-modal-search-input { + display: flex !important; + } + + .ehb-drill-modal--unified .ehb-modal-table-wrap { + overflow: auto !important; + } + + .ehb-drill-modal--unified .ehb-modal-table { + min-width: 1120px !important; + table-layout: auto !important; + } +} +.ehb-live-data-state.is-loading { + position: fixed; + z-index: 500; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 8px; + background: rgb(244 247 251 / 96%); + color: #64748b; + font-size: 12px; + backdrop-filter: blur(3px); +} + +.ehb-live-data-state.is-loading strong { + color: #1e293b; + font-size: 15px; +} + +.ehb-live-data-spinner { + width: 28px; + height: 28px; + border: 3px solid #dbeafe; + border-top-color: #2f6bff; + border-radius: 50%; + animation: ehb-live-spin .75s linear infinite; +} + +.ehb-api-error-overlay { + position: fixed; + z-index: 560; + inset: 0; + display: grid; + place-items: center; + padding: 24px; + background: rgb(15 23 42 / 54%); + backdrop-filter: blur(5px); +} + +.ehb-api-error-dialog { + width: min(520px, 100%); + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 16px; + padding: 24px; + border: 1px solid #fecaca; + border-radius: 20px; + background: #fff; + box-shadow: 0 24px 70px rgb(15 23 42 / 28%); +} + +.ehb-api-error-icon { + width: 52px; + height: 52px; + display: grid; + place-items: center; + color: #dc2626; + background: #fef2f2; + border-radius: 16px; +} + +.ehb-api-error-copy { min-width: 0; } + +.ehb-api-error-copy strong { + display: block; + color: #0f172a; + font-size: 20px; + line-height: 1.4; +} + +.ehb-api-error-copy p { + margin: 8px 0 12px; + color: #475569; + font-size: 14px; + line-height: 1.65; +} + +.ehb-api-error-copy code { + display: block; + overflow-wrap: anywhere; + padding: 9px 11px; + color: #b42318; + background: #fff7ed; + border: 1px solid #fed7aa; + border-radius: 10px; + font: 600 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.ehb-api-error-retry { + grid-column: 2; + justify-self: start; + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 42px; + padding: 0 18px; + border: 0; + border-radius: 11px; + color: #fff; + background: #2563eb; + font-size: 14px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 8px 20px rgb(37 99 235 / 24%); +} + +.ehb-api-error-retry:hover { background: #1d4ed8; } +.ehb-api-error-retry:focus-visible { outline: 3px solid rgb(147 197 253 / 75%); outline-offset: 3px; } + +@media (max-width: 560px) { + .ehb-api-error-overlay { padding: 18px; } + .ehb-api-error-dialog { + grid-template-columns: 1fr; + gap: 12px; + padding: 22px 20px; + border-radius: 18px; + } + .ehb-api-error-icon { width: 46px; height: 46px; border-radius: 14px; } + .ehb-api-error-copy strong { font-size: 19px; } + .ehb-api-error-retry { grid-column: 1; width: 100%; justify-content: center; } +} + +@keyframes ehb-live-spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .ehb-live-data-spinner { animation: none; } +} + +/* 触摸设备不保留 sticky hover 浮层,避免遮挡图表;完整数值仍由柱顶标签与下钻提供。 */ +@media (hover: none), (pointer: coarse) { + .ehb-mbar-tooltip, + .ehb-rev-income-tooltip, + .ehb-rev-cost-tooltip, + .ehb-top-bar-tooltip, + .ehb-mbar-col:hover .ehb-mbar-tooltip, + .ehb-rev-bar.is-income:hover .ehb-rev-income-tooltip, + .ehb-rev-bar.is-cost:hover .ehb-rev-cost-tooltip, + .ehb-top-bar-bg:hover .ehb-top-bar-tooltip, + .ehb-top-station-item:hover .ehb-top-bar-tooltip { + display: none !important; + } +} + +/* Mobile drill workspace v2: one authoritative rule-set after legacy overrides. */ +@media (max-width: 767px) { + .ehb-modal-overlay { + align-items: stretch !important; + padding: 0 !important; + background: #eaf0f8 !important; + } + + .ehb-drill-modal--unified { + width: 100vw !important; + max-width: 100vw !important; + height: 100dvh !important; + max-height: 100dvh !important; + margin: 0 !important; + border: 0 !important; + border-radius: 0 !important; + box-shadow: none !important; + } + + .ehb-drill-modal--unified .ehb-modal-head { + position: sticky !important; + top: 0; + z-index: 30; + min-height: 72px !important; + padding: max(12px, env(safe-area-inset-top)) 14px 11px !important; + background: #0f1b32 !important; + box-shadow: 0 1px 0 rgb(255 255 255 / 12%); + } + + .ehb-drill-modal--unified .ehb-modal-head__title { font-size: 15px !important; } + .ehb-drill-modal--unified .ehb-modal-head__sub { color: #9fb0ca !important; font-size: 10px !important; } + .ehb-drill-modal--unified .ehb-modal-back-btn { + border-color: rgb(255 255 255 / 18%) !important; + background: rgb(255 255 255 / 8%) !important; + } + + .ehb-drill-modal--unified .ehb-modal-body { + display: flex !important; + min-height: 0 !important; + padding: 12px !important; + gap: 10px; + background: #f4f7fb !important; + } + + .ehb-drill-modal--unified .ehb-drill-root-tabs, + .ehb-drill-modal--unified .ehb-mobile-drill-overview, + .ehb-drill-modal--unified .ehb-modal-meta-bar, + .ehb-drill-modal--unified .ehb-drill-filter-summary-row { + flex: 0 0 auto; + margin: 0 !important; + border: 1px solid #dce5f1 !important; + border-radius: 13px !important; + background: #fff !important; + box-shadow: 0 5px 18px rgb(33 53 85 / 5%); + } + + .ehb-drill-modal--unified .ehb-mobile-drill-overview { padding: 14px !important; } + .ehb-drill-modal--unified .ehb-modal-meta-bar { + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + overflow: hidden; + } + .ehb-drill-modal--unified .ehb-modal-meta-item { + min-height: 62px !important; + padding: 10px 12px !important; + border-bottom: 1px solid #edf1f6; + } + + .ehb-drill-modal--cumulative .ehb-drill-filter-summary-row { + display: grid !important; + grid-template-columns: minmax(0, 1fr) 48px !important; + padding: 5px !important; + } + .ehb-drill-modal--cumulative .ehb-drill-filter-summary-row .mobile-list-fullscreen-trigger { + display: inline-flex !important; + position: static !important; + width: 44px !important; + min-width: 44px !important; + height: 44px !important; + padding: 0 !important; + } + .ehb-drill-filter-summary-row .mobile-list-fullscreen-trigger__label { display: none !important; } + + .ehb-drill-modal--unified .ehb-h5-scroll-hint { + display: flex !important; + flex: 0 0 auto; + align-items: center; + justify-content: center; + min-height: 30px; + margin: 0 !important; + border-radius: 9px; + color: #2563eb; + background: #e8f1ff; + font-size: 10px; + font-weight: 700; + } + + .ehb-drill-modal--unified .ehb-modal-table-wrap { + flex: 1 1 auto !important; + min-height: 260px !important; + height: auto !important; + max-height: none !important; + margin: 0 !important; + overflow: auto !important; + overscroll-behavior: contain; + border: 1px solid #dce5f1 !important; + border-radius: 13px !important; + background: #fff !important; + box-shadow: 0 8px 24px rgb(33 53 85 / 6%); + -webkit-overflow-scrolling: touch; + } + + .ehb-drill-modal--unified .ehb-modal-table { + width: max-content !important; + min-width: 1080px !important; + table-layout: fixed !important; + } + .ehb-drill-modal--unified .ehb-modal-table th, + .ehb-drill-modal--unified .ehb-modal-table > tbody > tr > td { + min-width: 126px; + height: auto !important; + min-height: 52px !important; + padding: 10px 12px !important; + font-size: 11px !important; + line-height: 1.45 !important; + vertical-align: middle; + } + .ehb-drill-modal--unified .ehb-modal-table th { + position: sticky !important; + top: 0; + z-index: 8; + color: #53647d !important; + background: #f7f9fc !important; + } + .ehb-drill-modal--unified .ehb-modal-table th:first-child, + .ehb-drill-modal--unified .ehb-modal-table td:first-child { + position: sticky !important; + left: 0; + z-index: 7; + width: 190px !important; + min-width: 190px !important; + max-width: 190px !important; + background: #fff !important; + box-shadow: 8px 0 14px -13px #334155; + } + .ehb-drill-modal--unified .ehb-modal-table th:first-child { + z-index: 12; + background: #f7f9fc !important; + } + .ehb-drill-modal--unified .ehb-modal-table th:nth-child(2), + .ehb-drill-modal--unified .ehb-modal-table td:nth-child(2) { width: 144px !important; min-width: 144px !important; } +} + +html.ehb-landscape-session .ehb-modal-overlay { + position: fixed !important; + inset: 0 !important; + z-index: 10000 !important; + padding: 0 !important; +} +html.ehb-landscape-session .ehb-drill-modal--unified { + position: fixed !important; + inset: 0 !important; + width: 100vw !important; + max-width: 100vw !important; + height: 100dvh !important; + max-height: 100dvh !important; + border-radius: 0 !important; +} +html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-body { + display: flex !important; + height: calc(100dvh - 54px) !important; + min-height: 0 !important; + padding: 8px 10px 10px !important; + gap: 8px; + overflow: hidden !important; +} +html.ehb-landscape-session .ehb-drill-modal--unified .ehb-mobile-drill-overview, +html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-meta-bar { + flex: 0 0 auto; +} +html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-meta-bar { + grid-template-columns: repeat(4, minmax(150px, 1fr)) !important; +} +html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-table-wrap { + flex: 1 1 auto !important; + min-height: 0 !important; + height: auto !important; + max-height: none !important; + overflow: auto !important; +} +html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-table { + min-width: 1180px !important; +} +html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-table th:first-child, +html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-table td:first-child { + width: 220px !important; + min-width: 220px !important; + max-width: 220px !important; +} + +/* 桌面端累计卡片三项构成:金额必须完整展示,不允许用省略号隐藏业务数据。 */ +@media (min-width: 768px) { + .ehb-host-kpi .ehb-kpi-dual__deck.is-three { + display: grid !important; + grid-template-columns: repeat(3, minmax(0, 1fr)) !important; + align-items: start !important; + gap: 0 !important; + padding: 8px 10px !important; + } + + .ehb-host-kpi .ehb-kpi-dual__deck.is-three > span { + display: flex !important; + min-width: 0 !important; + flex-direction: column !important; + align-items: flex-start !important; + gap: 3px !important; + padding: 0 7px !important; + overflow: visible !important; + } + + .ehb-host-kpi .ehb-kpi-dual__deck.is-three > span:first-child { padding-left: 0 !important; } + .ehb-host-kpi .ehb-kpi-dual__deck.is-three > span:last-child { padding-right: 0 !important; } + .ehb-host-kpi .ehb-kpi-dual__deck.is-three > span + span { border-left: 1px solid #e4eaf2; } + + .ehb-host-kpi .ehb-kpi-dual__deck.is-three small, + .ehb-host-kpi .ehb-kpi-dual__deck.is-three strong { + display: block !important; + max-width: none !important; + overflow: visible !important; + text-overflow: clip !important; + white-space: nowrap !important; + } + + .ehb-host-kpi .ehb-kpi-dual__deck.is-three small { font: 600 10px/1.2 var(--bi-font) !important; } + .ehb-host-kpi .ehb-kpi-dual__deck.is-three strong { + font: 700 10px/1.25 var(--bi-font-mono) !important; + letter-spacing: -0.04em !important; + } + + .ehb-host-kpi .ehb-kpi-dual__deck.is-triple { + display: grid !important; + grid-template-columns: repeat(3, minmax(0, 1fr)) !important; + align-items: start !important; + gap: 0 !important; + padding: 8px 10px !important; + } + + .ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail { + display: flex !important; + min-width: 0 !important; + flex-direction: column !important; + align-items: flex-start !important; + gap: 3px !important; + padding: 0 8px !important; + overflow: visible !important; + } + + .ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail:first-child { + padding-left: 0 !important; + } + + .ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail:last-child { + padding-right: 0 !important; + } + + .ehb-host-kpi .ehb-kpi-dual__deck.is-triple .ehb-kpi-dual__detail + .ehb-kpi-dual__detail { + border-left: 1px solid #e4eaf2; + } + + .ehb-host-kpi .ehb-kpi-dual__detail-label, + .ehb-host-kpi .ehb-kpi-dual__detail-value { + display: block !important; + max-width: none !important; + overflow: visible !important; + text-overflow: clip !important; + white-space: nowrap !important; + } + + .ehb-host-kpi .ehb-kpi-dual__detail-label { + color: #71819a; + font: 600 10px/1.2 var(--bi-font); + } + + .ehb-host-kpi .ehb-kpi-dual__detail-value { + color: #26364f; + font: 700 10px/1.25 var(--bi-font-mono); + font-variant-numeric: tabular-nums; + letter-spacing: -0.03em; + } +} diff --git a/src/modules/energy/hydrogen/board/types.ts b/src/modules/energy/hydrogen/board/types.ts new file mode 100644 index 0000000..3f7ccc6 --- /dev/null +++ b/src/modules/energy/hydrogen/board/types.ts @@ -0,0 +1,73 @@ +/** 能源 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 = { + lease: '租赁成本', + logistics: '物流成本', + ops: '运维成本', + pending: '待归属', +}; + +export const LEASE_KIND_LABEL: Record = { + company_borne: '我司承担', + package_h2: '包氢项目', +}; + +export const OPS_KIND_LABEL: Record = { + abnormal: '异动', + transfer: '调拨', +}; + +export const BORNE_BY_LABEL: Record = { + company: '我司承担', + customer: '客户承担', + pending: '待核准', +}; + +export const BORNE_BY_ORDER: BorneBy[] = ['company', 'customer', 'pending']; diff --git a/src/modules/energy/hydrogen/common/MobileListFullscreenButton.tsx b/src/modules/energy/hydrogen/common/MobileListFullscreenButton.tsx new file mode 100644 index 0000000..ee77562 --- /dev/null +++ b/src/modules/energy/hydrogen/common/MobileListFullscreenButton.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { isPhoneUserAgent, phoneRotation } from './phone-viewport'; +import { Maximize2 } from 'lucide-react'; +import './mobile-list-fullscreen.css'; + +export function MobileListFullscreenButton({ + label = '横屏查看', + placement = 'overlay', +}: { + label?: string; + placement?: 'overlay' | 'inline'; +}) { + const [isActive, setIsActive] = useState(false); + const targetRef = useRef(null); + const savedStyles = useRef(new Map()); + 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 = { + 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) => { + event.stopPropagation(); + const target = event.currentTarget.closest('[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 ( + + ); +} diff --git a/src/modules/energy/hydrogen/common/energy-spot-cash-intake/index.ts b/src/modules/energy/hydrogen/common/energy-spot-cash-intake/index.ts new file mode 100644 index 0000000..bf0d5c0 --- /dev/null +++ b/src/modules/energy/hydrogen/common/energy-spot-cash-intake/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './store'; diff --git a/src/modules/energy/hydrogen/common/energy-spot-cash-intake/store.ts b/src/modules/energy/hydrogen/common/energy-spot-cash-intake/store.ts new file mode 100644 index 0000000..440e778 --- /dev/null +++ b/src/modules/energy/hydrogen/common/energy-spot-cash-intake/store.ts @@ -0,0 +1,569 @@ +/** + * 体系 A · 站日现结进账 Mock + localStorage + * 仅本尊提供 Excel 实站;禁造站。 + * - 南海:截止8.11 收支汇总 → 现结 + * - 东鹏大道:加氢记录-20260802 汇总进账明细 → 现结 + */ +import type { + CashIntakeChangeLog, + StationCashIntakeDay, + StationCashIntakeLine, +} from './types'; + +export const CASH_INTAKE_STORAGE_KEY = 'oneos-energy-spot-cash-intake-v4'; +const LEGACY_STORAGE_KEYS = [ + 'oneos-energy-spot-cash-intake-v3', + 'oneos-energy-spot-cash-intake-v2', +]; + +function seedDays(): StationCashIntakeDay[] { + return [ + { + id: 'day-st-fs-nanhai-2026-08-02', + stationId: 'st-fs-nanhai', + stationName: "佛山南海羚牛加氢站", + bizDate: '2026-08-02', + totalAmount: 2530.42, + lines: [ + { id: 'line-st-fs-nanhai-2026-08-02-0', customerName: "东展供应链(广州)有限公司", amount: 655.12, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-02-1', customerName: "广东开鸿氢能科技有限公司", amount: 503.88, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-02-2', customerName: "现代氢能科技有限公司", amount: 760.38, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-02-3', customerName: "广东氢动力科技服务有限公司", amount: 296.78, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-02-4', customerName: "羚牛氢能科技(广东)有限公司", amount: 314.26, payMethod: 'wechat_scan' }, + ], + updatedBy: "杨凤娥", + updatedAt: '2026-08-02 18:00', + }, + { + id: 'day-st-fs-nanhai-2026-08-03', + stationId: 'st-fs-nanhai', + stationName: "佛山南海羚牛加氢站", + bizDate: '2026-08-03', + totalAmount: 2193.36, + lines: [ + { id: 'line-st-fs-nanhai-2026-08-03-0', customerName: "现代氢能科技有限公司", amount: 1259.7, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-03-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 634.22, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-03-2', customerName: "东展供应链(广州)有限公司", amount: 299.44, payMethod: 'wechat_scan' }, + ], + updatedBy: "杨凤娥", + updatedAt: '2026-08-03 18:00', + }, + { + id: 'day-st-fs-nanhai-2026-08-04', + stationId: 'st-fs-nanhai', + stationName: "佛山南海羚牛加氢站", + bizDate: '2026-08-04', + totalAmount: 5216.16, + lines: [ + { id: 'line-st-fs-nanhai-2026-08-04-0', customerName: "广东沣开科技有限公司", amount: 3000.0, payMethod: 'bank_transfer' }, + { id: 'line-st-fs-nanhai-2026-08-04-1', customerName: "现代氢能科技有限公司", amount: 1024.1, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-04-2', customerName: "羚牛氢能科技(广东)有限公司", amount: 473.86, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-04-3', customerName: "广东氢动力科技服务有限公司", amount: 368.6, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-04-4', customerName: "东展供应链(广州)有限公司", amount: 163.78, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-04-5', customerName: "外省过路车", amount: 185.82, payMethod: 'wechat_scan' }, + ], + updatedBy: "杨凤娥", + updatedAt: '2026-08-04 18:00', + }, + { + id: 'day-st-fs-nanhai-2026-08-05', + stationId: 'st-fs-nanhai', + stationName: "佛山南海羚牛加氢站", + bizDate: '2026-08-05', + totalAmount: 3469.78, + lines: [ + { id: 'line-st-fs-nanhai-2026-08-05-0', customerName: "现代氢能科技有限公司", amount: 1155.58, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-05-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 608.0, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-05-2', customerName: "广东氢动力科技服务有限公司", amount: 530.86, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-05-3', customerName: "广东开鸿氢能科技有限公司", amount: 315.78, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-05-4', customerName: "东展供应链(广州)有限公司", amount: 859.56, payMethod: 'wechat_scan' }, + ], + updatedBy: "杨凤娥", + updatedAt: '2026-08-05 18:00', + }, + { + id: 'day-st-fs-nanhai-2026-08-06', + stationId: 'st-fs-nanhai', + stationName: "佛山南海羚牛加氢站", + bizDate: '2026-08-06', + totalAmount: 1006.24, + lines: [ + { id: 'line-st-fs-nanhai-2026-08-06-0', customerName: "现代氢能科技有限公司", amount: 478.8, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-06-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 527.44, payMethod: 'wechat_scan' }, + ], + updatedBy: "杨凤娥", + updatedAt: '2026-08-06 18:00', + }, + { + id: 'day-st-fs-nanhai-2026-08-07', + stationId: 'st-fs-nanhai', + stationName: "佛山南海羚牛加氢站", + bizDate: '2026-08-07', + totalAmount: 22094.18, + lines: [ + { id: 'line-st-fs-nanhai-2026-08-07-0', customerName: "东展供应链(广州)有限公司", amount: 201.02, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-07-1', customerName: "现代氢能科技有限公司", amount: 1410.18, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-07-2', customerName: "羚牛氢能科技(广东)有限公司", amount: 482.98, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-07-3', customerName: "羚牛氢能科技(广东)有限公司", amount: 20000.0, payMethod: 'bank_transfer' }, + ], + updatedBy: "杨凤娥", + updatedAt: '2026-08-07 18:00', + }, + { + id: 'day-st-fs-nanhai-2026-08-08', + stationId: 'st-fs-nanhai', + stationName: "佛山南海羚牛加氢站", + bizDate: '2026-08-08', + totalAmount: 2845.06, + lines: [ + { id: 'line-st-fs-nanhai-2026-08-08-0', customerName: "东展供应链(广州)有限公司", amount: 745.94, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-08-1', customerName: "现代氢能科技有限公司", amount: 1371.8, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-08-2', customerName: "羚牛氢能科技(广东)有限公司", amount: 727.32, payMethod: 'wechat_scan' }, + ], + updatedBy: "杨凤娥", + updatedAt: '2026-08-08 18:00', + }, + { + id: 'day-st-fs-nanhai-2026-08-09', + stationId: 'st-fs-nanhai', + stationName: "佛山南海羚牛加氢站", + bizDate: '2026-08-09', + totalAmount: 2485.58, + lines: [ + { id: 'line-st-fs-nanhai-2026-08-09-0', customerName: "现代氢能科技有限公司", amount: 775.2, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-09-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 987.62, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-09-2', customerName: "广东氢动力科技服务有限公司", amount: 250.42, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-09-3', customerName: "东展供应链(广州)有限公司", amount: 472.34, payMethod: 'wechat_scan' }, + ], + updatedBy: "杨凤娥", + updatedAt: '2026-08-09 18:00', + }, + { + id: 'day-st-fs-nanhai-2026-08-10', + stationId: 'st-fs-nanhai', + stationName: "佛山南海羚牛加氢站", + bizDate: '2026-08-10', + totalAmount: 22823.78, + lines: [ + { id: 'line-st-fs-nanhai-2026-08-10-0', customerName: "现代氢能科技有限公司", amount: 1582.7, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-10-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 757.34, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-10-2', customerName: "广东氢动力科技服务有限公司", amount: 186.2, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-10-3', customerName: "东展供应链(广州)有限公司", amount: 297.54, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-10-4', customerName: "广东瀚清能源有限公司", amount: 20000.0, payMethod: 'bank_transfer' }, + ], + updatedBy: "杨凤娥", + updatedAt: '2026-08-10 18:00', + }, + { + id: 'day-st-fs-nanhai-2026-08-11', + stationId: 'st-fs-nanhai', + stationName: "佛山南海羚牛加氢站", + bizDate: '2026-08-11', + totalAmount: 2301.04, + lines: [ + { id: 'line-st-fs-nanhai-2026-08-11-0', customerName: "现代氢能科技有限公司", amount: 755.06, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-11-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 596.36, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-11-2', customerName: "广东开鸿氢能科技有限公司", amount: 599.26, payMethod: 'wechat_scan' }, + { id: 'line-st-fs-nanhai-2026-08-11-3', customerName: "东展供应链(广州)有限公司", amount: 350.36, payMethod: 'wechat_scan' }, + ], + updatedBy: "杨凤娥", + updatedAt: '2026-08-11 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-06-26', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-06-26', + totalAmount: 10000.0, + lines: [ + { id: 'line-st-dp-dongpeng-2026-06-26-0', customerName: "广州市梅洛特物流有限公司", amount: 10000.0, payMethod: 'bank_transfer' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-06-26 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-01', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-01', + totalAmount: 1506.75, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-01-0', customerName: "广州新运多租赁有限公司", amount: 1506.75, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-01 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-02', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-02', + totalAmount: 596.4, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-02-0', customerName: "广州中味餐饮服务有限公司", amount: 96.25, payMethod: 'wechat_scan' }, + { id: 'line-st-dp-dongpeng-2026-07-02-1', customerName: "广州新运多租赁有限公司", amount: 500.15, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-02 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-03', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-03', + totalAmount: 918.4, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-03-0', customerName: "广州新运多租赁有限公司", amount: 918.4, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-03 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-04', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-04', + totalAmount: 349.3, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-04-0', customerName: "广州新运多租赁有限公司", amount: 349.3, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-04 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-07', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-07', + totalAmount: 20640.15, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-07-0', customerName: "广州市梅洛特物流有限公司", amount: 20000.0, payMethod: 'bank_transfer' }, + { id: 'line-st-dp-dongpeng-2026-07-07-1', customerName: "广州中味餐饮服务有限公司", amount: 362.25, payMethod: 'wechat_scan' }, + { id: 'line-st-dp-dongpeng-2026-07-07-2', customerName: "广州新运多租赁有限公司", amount: 277.9, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-07 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-08', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-08', + totalAmount: 896.35, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-08-0', customerName: "广州新运多租赁有限公司", amount: 896.35, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-08 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-09', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-09', + totalAmount: 902.65, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-09-0', customerName: "广州中味餐饮服务有限公司", amount: 211.4, payMethod: 'wechat_scan' }, + { id: 'line-st-dp-dongpeng-2026-07-09-1', customerName: "广州新运多租赁有限公司", amount: 691.25, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-09 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-10', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-10', + totalAmount: 1198.05, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-10-0', customerName: "广州中味餐饮服务有限公司", amount: 314.65, payMethod: 'wechat_scan' }, + { id: 'line-st-dp-dongpeng-2026-07-10-1', customerName: "广州新运多租赁有限公司", amount: 883.4, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-10 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-14', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-14', + totalAmount: 266.7, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-14-0', customerName: "广州中味餐饮服务有限公司", amount: 266.7, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-14 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-15', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-15', + totalAmount: 562.8, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-15-0', customerName: "广州中味餐饮服务有限公司", amount: 262.5, payMethod: 'wechat_scan' }, + { id: 'line-st-dp-dongpeng-2026-07-15-1', customerName: "广州新运多租赁有限公司", amount: 300.3, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-15 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-16', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-16', + totalAmount: 497.7, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-16-0', customerName: "广州中味餐饮服务有限公司", amount: 200.9, payMethod: 'wechat_scan' }, + { id: 'line-st-dp-dongpeng-2026-07-16-1', customerName: "广州新运多租赁有限公司", amount: 296.8, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-16 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-17', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-17', + totalAmount: 911.4, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-17-0', customerName: "广州新运多租赁有限公司", amount: 911.4, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-17 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-18', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-18', + totalAmount: 870.45, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-18-0', customerName: "广州新运多租赁有限公司", amount: 870.45, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-18 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-19', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-19', + totalAmount: 468.65, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-19-0', customerName: "广州中味餐饮服务有限公司", amount: 181.3, payMethod: 'wechat_scan' }, + { id: 'line-st-dp-dongpeng-2026-07-19-1', customerName: "广州新运多租赁有限公司", amount: 287.35, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-19 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-20', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-20', + totalAmount: 708.75, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-20-0', customerName: "广州新运多租赁有限公司", amount: 480.55, payMethod: 'wechat_scan' }, + { id: 'line-st-dp-dongpeng-2026-07-20-1', customerName: "广州中味餐饮服务有限公司", amount: 228.2, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-20 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-21', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-21', + totalAmount: 234.85, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-21-0', customerName: "广州新运多租赁有限公司", amount: 234.85, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-21 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-25', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-25', + totalAmount: 20686.0, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-25-0', customerName: "广州新运多租赁有限公司", amount: 686.0, payMethod: 'wechat_scan' }, + { id: 'line-st-dp-dongpeng-2026-07-25-1', customerName: "广州福满华冷链物流有限公司", amount: 20000.0, payMethod: 'bank_transfer' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-25 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-27', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-27', + totalAmount: 213.85, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-27-0', customerName: "广州新运多租赁有限公司", amount: 213.85, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-27 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-28', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-28', + totalAmount: 20872.2, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-28-0', customerName: "广州中味餐饮服务有限公司", amount: 480.9, payMethod: 'wechat_scan' }, + { id: 'line-st-dp-dongpeng-2026-07-28-1', customerName: "广州新运多租赁有限公司", amount: 391.3, payMethod: 'wechat_scan' }, + { id: 'line-st-dp-dongpeng-2026-07-28-2', customerName: "广州市梅洛特物流有限公司", amount: 20000.0, payMethod: 'bank_transfer' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-28 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-29', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-29', + totalAmount: 814.8, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-29-0', customerName: "广州中味餐饮服务有限公司", amount: 84.0, payMethod: 'wechat_scan' }, + { id: 'line-st-dp-dongpeng-2026-07-29-1', customerName: "广州新运多租赁有限公司", amount: 730.8, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-29 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-30', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-30', + totalAmount: 1450.4, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-30-0', customerName: "广州中味餐饮服务有限公司", amount: 393.05, payMethod: 'wechat_scan' }, + { id: 'line-st-dp-dongpeng-2026-07-30-1', customerName: "广州新运多租赁有限公司", amount: 1057.35, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-30 18:00', + }, + { + id: 'day-st-dp-dongpeng-2026-07-31', + stationId: 'st-dp-dongpeng', + stationName: "东鹏大道甲醇制氢一体站", + bizDate: '2026-07-31', + totalAmount: 587.65, + lines: [ + { id: 'line-st-dp-dongpeng-2026-07-31-0', customerName: "广州新运多租赁有限公司", amount: 587.65, payMethod: 'wechat_scan' }, + ], + updatedBy: "金可鹏", + updatedAt: '2026-07-31 18:00', + }, + ]; +} + +function normalizeDay(d: StationCashIntakeDay): StationCashIntakeDay { + return { + ...d, + changeLogs: Array.isArray(d.changeLogs) ? d.changeLogs : [], + }; +} + +export function loadCashIntakeDays(): StationCashIntakeDay[] { + try { + const raw = localStorage.getItem(CASH_INTAKE_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as StationCashIntakeDay[]; + if (Array.isArray(parsed) && parsed.length) return parsed.map(normalizeDay); + } + for (const key of LEGACY_STORAGE_KEYS) { + const legacy = localStorage.getItem(key); + if (!legacy) continue; + const parsed = JSON.parse(legacy) as StationCashIntakeDay[]; + if (Array.isArray(parsed) && parsed.length) { + const next = parsed.map(normalizeDay); + saveCashIntakeDays(next); + return next; + } + } + } catch { + /* ignore */ + } + return seedDays().map(normalizeDay); +} + +export function saveCashIntakeDays(days: StationCashIntakeDay[]): void { + try { + localStorage.setItem(CASH_INTAKE_STORAGE_KEY, JSON.stringify(days)); + } catch { + /* ignore */ + } +} + +export function getCashIntakeForStationRange( + days: StationCashIntakeDay[], + stationId: string, + startDate: string, + endDate: string, +): StationCashIntakeDay[] { + return days + .filter((d) => d.stationId === stationId && d.bizDate >= startDate && d.bizDate <= endDate) + .slice() + .sort((a, b) => a.bizDate.localeCompare(b.bizDate)); +} + +export function appendChangeLog( + day: StationCashIntakeDay, + entry: Omit & { 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, + }; +} diff --git a/src/modules/energy/hydrogen/common/energy-spot-cash-intake/types.ts b/src/modules/energy/hydrogen/common/energy-spot-cash-intake/types.ts new file mode 100644 index 0000000..010d83e --- /dev/null +++ b/src/modules/energy/hydrogen/common/energy-spot-cash-intake/types.ts @@ -0,0 +1,65 @@ +/** + * 站日现结进账类型(共享) + * 禁止与预充值能源账户混用(口径见 PRD,不进 UI)。 + */ + +export type SpotPayMethod = 'wechat_scan' | 'bank_transfer' | 'other'; + +export const SPOT_PAY_METHOD_LABEL: Record = { + 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'; diff --git a/src/modules/energy/hydrogen/common/mobile-list-fullscreen.css b/src/modules/energy/hydrogen/common/mobile-list-fullscreen.css new file mode 100644 index 0000000..9759789 --- /dev/null +++ b/src/modules/energy/hydrogen/common/mobile-list-fullscreen.css @@ -0,0 +1,385 @@ +.mobile-list-fullscreen-trigger { + display: none; +} + +@media (max-width: 767px) { + [data-mobile-fullscreen-list] { + position: relative; + } + + .mobile-list-fullscreen-trigger { + position: absolute; + z-index: 4; + top: 10px; + right: 10px; + display: inline-flex; + width: auto; + min-width: 124px; + height: 32px; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 10px; + border: 1px solid #dbe4f1; + border-radius: 9px; + background: #fff; + color: #fff; + background: #2563eb; + border-color: #2563eb; + box-shadow: 0 5px 14px rgb(37 99 235 / 20%); + cursor: pointer; + } + + .mobile-list-fullscreen-trigger.is-inline { + position: relative; + inset: auto; + z-index: auto; + flex: 0 0 auto; + } + + .mobile-list-fullscreen-trigger__label { + font-size: 12px; + font-weight: 700; + white-space: nowrap; + } + + .mobile-list-fullscreen-trigger::before { + position: absolute; + inset: -6px; + content: ''; + } + + [data-mobile-fullscreen-list] > h2, + [data-mobile-fullscreen-list] > .sd-section-head, + [data-mobile-fullscreen-list] > .sd-panel__head-row, + [data-mobile-fullscreen-list] > .ehb-sum-table-card__head, + [data-mobile-fullscreen-list] > .ehb-mobile-detail-tabs-head, + [data-mobile-fullscreen-list] > .ehb-daily-table-head { + box-sizing: border-box; + padding-inline-end: 112px !important; + } + + /* 复合标题区的 Tab 在第二行,只让第一行标题避让横屏按钮。 */ + [data-mobile-fullscreen-list] > .ehb-mobile-detail-tabs-head { + padding-inline-end: 14px !important; + } + + [data-mobile-fullscreen-list] > .ehb-sum-table-card__head .ehb-sum-table-card__meta, + [data-mobile-fullscreen-list] > .sd-section-head .sd-panel__meta, + [data-mobile-fullscreen-list] > .sd-panel__head-row .sd-panel__meta { + max-width: 100%; + overflow-wrap: anywhere; + } + + [data-mobile-fullscreen-list]:fullscreen, + [data-mobile-fullscreen-list].is-mobile-list-fullscreen, + [data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] { + box-sizing: border-box; + z-index: 200; + overflow: auto; + padding: 14px; + border-radius: 0; + background: #f4f7fb; + } + + [data-mobile-fullscreen-list].is-mobile-list-fullscreen, + [data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] { + position: fixed; + inset: 0; + } + + [data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] > .mobile-list-fullscreen-trigger, + [data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] > * > .mobile-list-fullscreen-trigger { + position: fixed !important; + top: 10px !important; + right: 10px !important; + z-index: 260 !important; + } + + [data-mobile-fullscreen-list]:fullscreen .ehb-sum-table-wrap, + [data-mobile-fullscreen-list]:fullscreen .ehb-table-wrap, + [data-mobile-fullscreen-list]:fullscreen .sd-table-scroll, + [data-mobile-fullscreen-list].is-mobile-list-fullscreen .ehb-sum-table-wrap, + [data-mobile-fullscreen-list].is-mobile-list-fullscreen .ehb-table-wrap, + [data-mobile-fullscreen-list].is-mobile-list-fullscreen .sd-table-scroll, + [data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table-wrap, + [data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-table-wrap, + [data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .sd-table-scroll { + max-height: none; + overflow: auto; + background: #fff; + } +} + +/* 宽表只切换阅读布局:竖屏保持原方向并由表格横向滚动。 */ +[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] { + box-sizing: border-box; + position: fixed !important; + z-index: 200 !important; + inset: 0 !important; + overflow: auto !important; + padding: 10px !important; + border-radius: 0 !important; + background: #f4f7fb !important; +} + +[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger { + position: fixed !important; + z-index: 260 !important; + top: 10px !important; + right: 10px !important; + display: inline-flex !important; + width: auto; + min-width: 92px; + height: 32px; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 10px; + border: 1px solid #dbe4f1; + border-radius: 9px; + background: #fff; + color: #4f6f9f; +} + +[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table-wrap, +[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-table-wrap, +[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .sd-table-scroll { + max-height: none !important; + overflow: auto !important; + background: #fff; +} + +[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table { + width: max-content !important; + min-width: 100% !important; +} + +[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table thead th { + position: sticky; + top: 0; + z-index: 4; +} + +[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table th:first-child, +[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table td:first-child { + position: sticky; + left: 0; + z-index: 5; + background: #fff; + box-shadow: 5px 0 9px -9px #0f172a; +} + +[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table thead th:first-child { + z-index: 7; + background: #f7f9fc; +} + +/* 汇总表的第一列只是序号;继续冻结第二列站点名称,横向滚动时保留行身份。 */ +[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table th:first-child, +[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table td:first-child { + width: 42px; + min-width: 42px; + max-width: 42px; +} + +[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table th:nth-child(2), +[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table td:nth-child(2) { + position: sticky; + left: 42px; + z-index: 5; + min-width: 190px; + background: #fff; + box-shadow: 7px 0 10px -10px #0f172a; +} + +[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table thead th:nth-child(2) { + z-index: 7; + background: #f7f9fc; +} + +[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"]::after { + position: fixed; + right: 10px; + bottom: 10px; + z-index: 250; + padding: 5px 9px; + border: 1px solid #dbe4f1; + border-radius: 999px; + background: rgb(255 255 255 / 92%); + color: #64748b; + content: '左右滑动查看更多列'; + font-size: 11px; + pointer-events: none; + box-shadow: 0 4px 12px rgb(15 23 42 / 8%); +} + +@media (max-width: 767px) and (orientation: portrait) { + [data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"], + [data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"].is-mobile-list-fallback { + transform: none !important; + transform-origin: initial !important; + } +} + +/* 下钻弹层最终移动端约束:本文件最后加载,避免旧版宽表规则反向撑开页面。 */ +@media (max-width: 767px) { + .ehb-drill-modal--unified .ehb-modal-body { + display: flex !important; + flex-direction: column !important; + width: 100% !important; + min-width: 0 !important; + padding: 12px !important; + gap: 10px !important; + overflow: hidden !important; + } + + .ehb-drill-modal--unified .ehb-modal-meta-bar { + flex: 0 0 auto !important; + width: 100% !important; + min-width: 0 !important; + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + gap: 6px !important; + overflow: visible !important; + } + + .ehb-drill-modal--unified .ehb-modal-meta-item { + min-width: 0 !important; + padding: 10px !important; + } + + .ehb-drill-modal--unified .ehb-real-drill-filter-summary, + .ehb-drill-modal--unified .ehb-drill-filter-summary-row { + display: flex !important; + flex: 0 0 auto !important; + width: 100% !important; + min-width: 0 !important; + } + + .ehb-real-drill-primary-actions { + display: grid !important; + flex: 0 0 auto; + grid-template-columns: minmax(0, 1fr) 132px; + width: 100%; + min-width: 0; + gap: 8px; + } + + .ehb-real-drill-primary-actions .ehb-real-drill-filter-summary, + .ehb-real-drill-primary-actions .mobile-list-fullscreen-trigger { + position: static !important; + width: 100% !important; + min-width: 0 !important; + height: 48px !important; + border-radius: 12px !important; + } + + .ehb-real-drill-primary-actions .ehb-real-drill-filter-summary { + display: grid !important; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 7px; + } + + .ehb-real-drill-primary-actions .ehb-real-drill-filter-summary strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .ehb-drill-modal--unified .ehb-real-drill-filter-summary { + min-height: 48px !important; + padding: 0 14px !important; + border: 1px solid #d9e3f0 !important; + border-radius: 12px !important; + background: #fff !important; + } + + .ehb-drill-modal--unified .ehb-modal-table-wrap { + flex: 1 1 auto !important; + width: 100% !important; + min-width: 0 !important; + min-height: 0 !important; + overflow: auto !important; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + } + + .ehb-drill-modal--unified .ehb-drill-filter-summary-row { + display: grid !important; + grid-template-columns: minmax(0, 1fr) auto !important; + align-items: stretch !important; + gap: 8px !important; + } + + .ehb-drill-modal--unified .ehb-drill-filter-summary-row .mobile-list-fullscreen-trigger { + position: static !important; + min-width: 124px !important; + height: 48px !important; + border-radius: 12px !important; + } + + .ehb-drill-usage-guide { + display: grid !important; + flex: 0 0 auto; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + gap: 6px; + } + + .ehb-drill-usage-guide > span { + min-width: 0; + padding: 7px 8px; + border-radius: 8px; + background: #eaf2ff; + color: #52637b; + font-size: 10px; + line-height: 1.35; + text-align: center; + } + + .ehb-drill-usage-guide strong { color: #1f5fe0; } + + .ehb-drill-modal--unified .ehb-modal-table tr[aria-expanded] > td:first-child { + position: sticky !important; + } + + .ehb-drill-modal--unified .ehb-modal-table tr[aria-expanded] > td:first-child::after { + display: inline-flex; + margin-left: 8px; + padding: 2px 6px; + border-radius: 999px; + background: #e8f1ff; + color: #2563eb; + content: '展开'; + font-size: 9px; + font-weight: 700; + white-space: nowrap; + vertical-align: middle; + } + + .ehb-drill-modal--unified .ehb-modal-table tr[aria-expanded="true"] > td:first-child::after { + background: #e7f8f1; + color: #07875f; + content: '收起'; + } +} + +html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-body { + width: 100% !important; + min-width: 0 !important; + overflow: hidden !important; +} + +html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-meta-bar { + width: 100% !important; + min-width: 0 !important; + grid-template-columns: repeat(4, minmax(0, 1fr)) !important; +} + +html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-table-wrap { + flex: 1 1 auto !important; + width: 100% !important; + min-width: 0 !important; + min-height: 0 !important; + overflow: auto !important; +} diff --git a/src/modules/energy/hydrogen/common/phone-viewport.test.ts b/src/modules/energy/hydrogen/common/phone-viewport.test.ts new file mode 100644 index 0000000..5a6e110 --- /dev/null +++ b/src/modules/energy/hydrogen/common/phone-viewport.test.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { isPhoneUserAgent, phoneRotation } from './phone-viewport.js'; +test('仅手机 UA 在竖屏宽表模式旋转,不影响平板和桌面', () => { + for (const ua of ['Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)', 'Mozilla/5.0 (Linux; Android 14) Mobile Safari/537.36']) { + assert.equal(isPhoneUserAgent(ua), true); + assert.equal(phoneRotation(ua, 390, 844), true); + assert.equal(phoneRotation(ua, 844, 390), false); + } + for (const ua of ['Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X)', 'Mozilla/5.0 (Macintosh; Intel Mac OS X)', 'Mozilla/5.0 (Linux; Android 14) Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)']) { + assert.equal(phoneRotation(ua, 390, 844), false); + } +}); diff --git a/src/modules/energy/hydrogen/common/phone-viewport.ts b/src/modules/energy/hydrogen/common/phone-viewport.ts new file mode 100644 index 0000000..f9d4319 --- /dev/null +++ b/src/modules/energy/hydrogen/common/phone-viewport.ts @@ -0,0 +1,8 @@ +/** 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; +} diff --git a/src/modules/energy/hydrogen/dev-mock-api.test.ts b/src/modules/energy/hydrogen/dev-mock-api.test.ts new file mode 100644 index 0000000..80234a1 --- /dev/null +++ b/src/modules/energy/hydrogen/dev-mock-api.test.ts @@ -0,0 +1,16 @@ +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); +}); diff --git a/src/modules/energy/hydrogen/dev-mock-api.ts b/src/modules/energy/hydrogen/dev-mock-api.ts new file mode 100644 index 0000000..bce081d --- /dev/null +++ b/src/modules/energy/hydrogen/dev-mock-api.ts @@ -0,0 +1,175 @@ +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); + }); + }, + }; +} diff --git a/src/modules/energy/hydrogen/drill-pagination.test.ts b/src/modules/energy/hydrogen/drill-pagination.test.ts new file mode 100644 index 0000000..09fa4a1 --- /dev/null +++ b/src/modules/energy/hydrogen/drill-pagination.test.ts @@ -0,0 +1,143 @@ +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 { + 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, + run: () => Promise, +) { + 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 页保护上限/, + ); + }); +}); diff --git a/src/modules/energy/hydrogen/drill/daily-detail-controls.tsx b/src/modules/energy/hydrogen/drill/daily-detail-controls.tsx new file mode 100644 index 0000000..95418e2 --- /dev/null +++ b/src/modules/energy/hydrogen/drill/daily-detail-controls.tsx @@ -0,0 +1,23 @@ +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 ; +} + +export function DailyBranchState({ columns, error, empty, onRetry }: { + columns: number; error?: string; empty?: boolean; onRetry: () => void; +}) { + return +
+ {error || (empty ? "当前范围暂无明细" : "正在加载明细…")} + {error ? : null} +
+ ; +} diff --git a/src/modules/energy/hydrogen/drill/daily-detail.test.ts b/src/modules/energy/hydrogen/drill/daily-detail.test.ts new file mode 100644 index 0000000..a9c4c9b --- /dev/null +++ b/src/modules/energy/hydrogen/drill/daily-detail.test.ts @@ -0,0 +1,52 @@ +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, / + + +
+ onStartDateChange(event.target.value)} + /> + onEndDateChange(event.target.value)} + /> +
+
+
+ + + +
+ +
+ + + {error ?
{error}
: null} + {daily ? <> +
+
+
区间加氢量
+
+ {formatFixed(daily?.kpis.totalKg ?? 0)} Kg +
+
+ {startDate} 至 {endDate} +
+
+
+
区间成本
+
+ ¥{formatFixed(daily?.kpis.totalCost ?? 0)} +
+
真实成本台账汇总
+
+
+
有效天数
+
{daily?.kpis.activeDays ?? 0}
+
+ 日均 {formatFixed(daily?.kpis.averageDailyKg ?? 0)} Kg +
+
+
+
涉及加氢站
+
+ {daily?.kpis.stationCount ?? 0} +
+
按明细站点去重
+
+
+
+
+
+ 每日加氢量{" "} + + (点击柱体下锚定位到对应日期明细) + +
+
+
+ + + 内部客户 + + + + 外部客户 + +
+ 时间单位:日 · 单位 Kg +
+
+
+
+ 峰值日 + {peak ? `${peak.date} ${formatFixed(peak.kg)} Kg` : "—"} +
+
+ 低谷日 + + {trough ? `${trough.date} ${formatFixed(trough.kg)} Kg` : "—"} + +
+
+ 零数日 + {trend.filter((row) => row.kg === 0).length} 天 +
+
+
+
+ + 均值 {formatFixed(averageKg)} Kg + +
+ {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 ( + + ); + })} +
+
+ : null} +
+
+
+ 每日加氢数据明细{" "} + + (可多层下钻:按日 → 加氢站 → 客户 → 车辆及数据源) + +
+ +
+
+
+ {([['key', '重点指标'], ['full', '完整表格']] as const).map(([mode, label]) => + )} +
+ + +
+ + {!daily ?
+ {error ? <>明细暂时无法加载 : "正在加载日期明细…"} +
: !daily.days.length ?
所选日期和车辆范围内暂无加氢记录,请调整筛选条件。
: +
+ + + + + + + + + + + + + + + + + + {(daily?.days ?? []).map((day) => { + const tree = trees[day.date]; + const open = expandedDate === day.date; + return ( + + { + 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, + }} + > + + + + + + + {open && (!tree || tree.stations.length === 0) ? 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 ( + + + + + + + + + {stationOpen && station.customers.length === 0 ? {}} /> : 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 ( + + + + + + + + + {customerOpen && (!customerRecords[customerKey] || allRecords.length === 0) ? loadCustomer(day.date, station.id, customer.id)} /> : null} + {customerOpen && + records.map((record) => ( + + + + + + + + ))} + {customerOpen && allRecords.length > 20 ? ( + + + + ) : null} + + ); + })} + {stationOpen && station.customers.length > 10 ? ( + + + + ) : null} + + ); + })} + {open && tree && tree.stations.length > 10 ? ( + + + + ) : null} + + ); + })} + +
日期 / 明细单价(元/Kg)加氢量(Kg)成本(元) / 环比预充值余额 / 数据来源
合计 + {formatFixed(daily?.kpis.totalKg ?? 0)}¥{formatFixed(daily?.kpis.totalCost ?? 0)}暂无来源
+ toggleDate(day.date)}> + {day.date}{" "} + + ({day.stationCount ?? 0} 个加氢站) + + + {formatFixed(day.kg)} + {formatFixed(day.cost)} + {formatDailyChange((day as { chainPct?: number }).chainPct)} + 暂无来源
+ + setExpandedStation(items => ({ ...items, [stationKey]: !items[stationKey] }))}> + 加氢站{station.name} + + {formatFixed(station.kg)}¥{formatFixed(station.cost)}暂无来源
+ toggleCustomer(day.date, station.id, customer.id)}> + 客户{customer.name}{" "} + + ({customer.recordCount} 笔) + + + {formatFixed(customer.kg)}¥{formatFixed(customer.cost)}点击查看真实流水
+ 车辆 · {String(record.time || "—").slice(11, 16)} + + {String(record.plateNo || "无车牌")} + {" "} + + {record.vehicleScope === "lingniu" + ? "羚牛车辆" + : "外部车辆"} + + + {sourceLabel(record.source)} + + + {verifyLabel(record.verifyStatus)} + + + {formatFixed( + Number(record.unitPrice ?? 0), + )} + + {formatFixed(Number(record.kg ?? 0))} + + ¥{formatFixed(Number(record.cost ?? 0))} + 暂无预充值余额
+ +
+ +
+ +
+
} +
+ + ); +} diff --git a/src/modules/energy/hydrogen/drill/prototype-real-drills.tsx b/src/modules/energy/hydrogen/drill/prototype-real-drills.tsx new file mode 100644 index 0000000..c45749a --- /dev/null +++ b/src/modules/energy/hydrogen/drill/prototype-real-drills.tsx @@ -0,0 +1,1387 @@ +import { Component, Fragment, useEffect, useMemo, useRef, useState } from "react"; +import type { ErrorInfo, ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { ChevronDown, ChevronLeft, Download, Search, SlidersHorizontal, Truck, X } from "lucide-react"; +import { MobileListFullscreenButton } from "../common/MobileListFullscreenButton"; +import { fetchAllH2BiDrill, fetchH2BiDrill, fetchH2BiMeta } from "../api"; +import { exportAoaSheet } from "../../../../shared/xlsx"; +import { HYDROGEN_VERIFY_START_DATE } from "../../../../shared/hydrogen-verify"; +import { bearingLabels } from "../model/bearing-labels"; +import { formatFixed } from "../model/display-format"; +import type { + H2BiDrillGroupBy, + H2BiDrillGroupRow, + H2BiDrillResponse, + H2BiAmountScope, + H2BiMetaResponse, + H2BiQuery, + H2BiVehicleScope, +} from "../types"; +import "./drill-prototype-parity.css"; + +type DrillKind = "kpi" | "customer" | "station" | "records"; + +export interface PrototypeDrillModalProps { + kind: DrillKind; + label?: string | null; + query: H2BiQuery; + onClose: () => void; +} + +class DrillTableErrorBoundary extends Component< + { children: ReactNode; resetKey: string; onRecover: () => void }, + { error: Error | null } +> { + state = { error: null as Error | null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("氢能穿透明细局部渲染失败", error, info.componentStack); + } + + componentDidUpdate(previous: Readonly<{ resetKey: string }>) { + if (previous.resetKey !== this.props.resetKey && this.state.error) { + this.setState({ error: null }); + } + } + + render() { + if (!this.state.error) return this.props.children; + return ( +
+ 当前明细字段异常,页面其他功能不受影响 + 请返回首层重新选择筛选条件;如仍失败,可关闭明细后重试。 + +
+ ); + } +} + +type DrillRootDimension = "station" | "customer"; + +type DrillState = Pick< + H2BiQuery, + | "stationId" + | "customerId" + | "customerName" + | "plateNo" + | "date" + | "month" + | "region" + | "regionGranularity" +> & { + level: H2BiDrillGroupBy; + rootDimension: DrillRootDimension; + amountScope: H2BiAmountScope; + stationName?: string; +}; + +const fleetScope = (value: "all" | "own" | "external"): H2BiVehicleScope => + value === "own" ? "lingniu" : value; +const verifyLabel = (status: unknown) => { + const value = String(status ?? "").trim().toLowerCase(); + return value === "verified" || value === "pass" || value === "已验证" + ? "已验证" + : "未验证"; +}; + +const sourceLabel = (source: unknown) => { + const value = String(source ?? "").trim().toLowerCase(); + if (value === "api") return "API接入"; + if (value === "station" || value === "station_report") return "站点上报"; + if (value === "lingniu" || value === "lingniu_report") return "羚牛上报"; + return String(source ?? "真实账本"); +}; + +const sourceClass = (source: unknown) => { + const value = String(source ?? "").trim().toLowerCase(); + if (value === "api") return "ehb-tag--source-api"; + if (value === "station" || value === "station_report") return "ehb-tag--source-station"; + return "ehb-tag--source-lingniu"; +}; + +// 分组接口的 id 在客户层并非全局唯一;展开状态必须保留当前行的可见身份。 +const rowIdentity = (row: H2BiDrillGroupRow) => + `${String(row.id)}|${row.name}|${String(row.province ?? "")}`; + +/** 原生 select 会出现双箭头且不支持长列表检索;统一为可搜索选择器。 */ +function DrillSearchSelect({ + ariaLabel, + value, + onChange, + options, + allLabel, + placeholder, + disabled = false, +}: { + ariaLabel: string; + value: string; + onChange: (value: string) => void; + options: Array<{ value: string; label: string }>; + allLabel: string; + placeholder: string; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + const [keyword, setKeyword] = useState(""); + const rootRef = useRef(null); + const inputRef = useRef(null); + const menuRef = useRef(null); + const [menuPosition, setMenuPosition] = useState({ left: 0, top: 0, width: 180, maxHeight: 280 }); + useEffect(() => { + if (!open) return; + const reposition = () => { + const rect = rootRef.current?.getBoundingClientRect(); + if (!rect) return; + const width = Math.min(Math.max(rect.width, 260), window.innerWidth - 16); + const below = window.innerHeight - rect.bottom - 12; + const above = rect.top - 12; + const upwards = below < 220 && above > below; + const maxHeight = Math.min(280, upwards ? above : below); + setMenuPosition({ left: Math.max(8, Math.min(rect.left, window.innerWidth - width - 8)), + top: upwards ? rect.top - maxHeight - 4 : rect.bottom + 4, width, maxHeight }); + }; + const escape = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); }; + reposition(); + window.addEventListener("resize", reposition); + document.addEventListener("scroll", reposition, true); + document.addEventListener("keydown", escape); + return () => { + window.removeEventListener("resize", reposition); + document.removeEventListener("scroll", reposition, true); + document.removeEventListener("keydown", escape); + }; + }, [open]); + useEffect(() => { + const close = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node) && !menuRef.current?.contains(event.target as Node)) setOpen(false); + }; + if (open) document.addEventListener("mousedown", close); + return () => document.removeEventListener("mousedown", close); + }, [open]); + useEffect(() => { + if (!open) return; + setKeyword(""); + requestAnimationFrame(() => inputRef.current?.focus()); + }, [open]); + + const selectedLabel = + options.find((option) => option.value === value)?.label ?? allLabel; + const matches = useMemo(() => { + const query = keyword.trim().toLowerCase(); + return query + ? options.filter( + (option) => + option.label.toLowerCase().includes(query) || + option.value.toLowerCase().includes(query), + ) + : options; + }, [keyword, options]); + + return ( +
+ + {open && !disabled ? createPortal( +
+ +
+ + {matches.map((option, optionIndex) => ( + + ))} + {matches.length === 0 ? ( +
无匹配项
+ ) : null} +
+
, document.body + ) : null} +
+ ); +} + +function localDateParts() { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date()); + const value = (type: Intl.DateTimeFormatPartTypes) => + parts.find((part) => part.type === type)?.value ?? ""; + return { year: value("year"), month: value("month"), day: value("day") }; +} + +function initialState(kind: DrillKind, labelInput: unknown): DrillState { + const label = String(labelInput ?? ""); + // Canonical labels use “2026年4月”. Accept the former “2026年2026-04” + // form too so a legacy label can never silently drop the month filter. + const monthParts = + label.match(/(\d{4})年(\d{1,2})月/) ?? label.match(/(\d{4})-(\d{1,2})/); + const regionMatch = label.match(/区域(市|省):(.+)/); + const stationIdMatch = label.match(/stationId=(\d+)/); + const cleanLabel = label.replace(/\|stationId=\d+$/, ""); + const today = localDateParts(); + // 对客金额、对客成本和利润都只看客户承担订单;利润减法使用完全 + // 相同订单集。成本图的另外两段则按账本 settlement_type 定向下钻。 + const amountScope: H2BiAmountScope = kind === "customer" + ? "customer" + : /我司承担成本/.test(cleanLabel) + ? "company" + : /其他成本/.test(cleanLabel) + ? "other" + : /加氢利润|对客金额|对客收入|客户收入|客户承担成本|对客成本/.test(cleanLabel) + ? "customer" + : "all"; + if (kind === "customer" || kind === "station") + return { level: "date", rootDimension: "station", amountScope }; + if (kind === "records") + return { level: "record", rootDimension: "station", amountScope }; + return { + level: stationIdMatch ? "customer" : "station", + rootDimension: "station", + amountScope, + stationId: stationIdMatch ? stationIdMatch[1] : undefined, + month: monthParts + ? `${monthParts[1]}-${monthParts[2].padStart(2, "0")}` + : cleanLabel === "本月加氢" + ? `${today.year}-${today.month}` + : undefined, + date: cleanLabel === "本日加氢" + ? `${today.year}-${today.month}-${today.day}` + : undefined, + region: regionMatch ? regionMatch[2] : undefined, + regionGranularity: regionMatch + ? regionMatch[1] === "省" + ? "province" + : "city" + : undefined, + // Keep this assignment solely to make the parsed label explicit in DevTools; + // it is not sent as a business filter. + customerName: cleanLabel.startsWith("客户:") + ? cleanLabel.slice(3) + : undefined, + }; +} + +function titleFor(level: H2BiDrillGroupBy) { + return ( + { + station: "加氢站", + customer: "客户", + date: "日期", + vehicle: "车辆", + record: "加氢记录", + } as const + )[level]; +} + +function nextState(current: DrillState, row: H2BiDrillGroupRow): DrillState { + if (current.level === "station") { + if (current.rootDimension === "customer" && current.customerId !== undefined) + return { ...current, level: "vehicle", stationId: row.id, stationName: row.name }; + return { ...current, level: "customer", stationId: row.id, stationName: row.name }; + } + if (current.level === "customer") { + if (current.rootDimension === "customer" && current.customerId === undefined) + return { + ...current, + level: "station", + customerId: Number(row.id), + customerName: row.name, + }; + return { + ...current, + level: "vehicle", + customerId: Number(row.id), + customerName: row.name, + }; + } + if (current.level === "date") + return { ...current, level: "vehicle", date: row.id }; + if (current.level === "vehicle") + return { ...current, level: "record", plateNo: row.name }; + return current; +} + +function useDrill( + query: H2BiQuery, + state: DrillState, + page: number, + pageSize: number, + enabled = true, +) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(enabled); + const key = JSON.stringify({ ...query, ...state, page, pageSize }); + useEffect(() => { + if (!enabled) { + setData(null); + setError(null); + setLoading(false); + return; + } + let alive = true; + const controller = new AbortController(); + let finishTimer: ReturnType | undefined; + const loadingStartedAt = Date.now(); + setLoading(true); + setData(null); + setError(null); + void fetchH2BiDrill({ + ...query, + ...state, + groupBy: state.level, + amountScope: state.amountScope, + page, + pageSize, + }, { + signal: controller.signal, + }) + .then((result) => alive && setData(result)) + .catch((reason: unknown) => { + if (!alive) return; + setData(null); + setError( + reason instanceof Error ? reason.message : "下钻数据加载失败", + ); + }) + .finally(() => { + if (!alive) return; + // 快速本地查询也保留一个可感知但不拖沓的过渡,避免弹层闪白。 + const remaining = Math.max(0, 320 - (Date.now() - loadingStartedAt)); + finishTimer = setTimeout(() => alive && setLoading(false), remaining); + }); + return () => { + alive = false; + controller.abort(); + if (finishTimer) clearTimeout(finishTimer); + }; + }, [enabled, key, page, pageSize]); + return { data, error, loading }; +} + +function DrillLoadingTransition() { + return ( +
+
+
+ + + 正在加载下钻数据 + 正在汇总当前层级,请稍候… + +
+
+ {Array.from({ length: 6 }, (_, index) => ( +
+ + + + +
+ ))} +
+
+ ); +} + +function BearingTags({ row }: { row: { settlementTypes?: unknown; settlementType?: unknown } }) { + return + {bearingLabels(row).map(({ label, className }) => + {label})} + ; +} + +function ExpandedChildren({ data, state, query, kind, label, depth = 1 }: { + data: H2BiDrillResponse; state: DrillState; query: H2BiQuery; kind: DrillKind; label: string; depth?: number; +}) { + return <>{data.groups.map((row) => )} + {data.records.map((record, index) => +
{String(record.orderNo || record.id)}{String(record.time || "")}
+ {record.vehicleScope === "lingniu" ? "羚牛车辆" : "外部车辆"}{sourceLabel(record.source)}{verifyLabel(record.verifyStatus)}1 笔 + + )} + ; +} + +function ExpandedMetrics({ row, kind, label, amountScope }: { + row: { kg?: unknown; cost?: unknown; revenue?: unknown }; kind: DrillKind; label: string; amountScope: H2BiAmountScope; +}) { + const cell = (value: unknown, money = false) => {money ? "¥" : ""}{formatFixed(value)}; + if (label === "加氢利润") return <>{cell(row.revenue, true)}{cell(row.cost, true)}{cell(Number(row.revenue ?? 0) - Number(row.cost ?? 0), true)}; + if (kind === "customer") return <>{cell(row.cost, true)}{cell(row.revenue, true)}未接入未接入; + return <>{cell(row.kg)}{cell(amountScope === "customer" ? row.revenue : row.cost, true)}{label === "本日加氢" || label === "本月加氢" ? — : null}; +} + +function ExpandedChild({ row, state, query, kind, label, depth }: { + row: H2BiDrillGroupRow; state: DrillState; query: H2BiQuery; kind: DrillKind; label: string; depth: number; +}) { + const [expanded, setExpanded] = useState(false); + const [page, setPage] = useState(1); + const childState = nextState(state, row); + const live = useDrill(query, childState, page, 50, expanded); + const columns = label === "加氢利润" || label === "本日加氢" || label === "本月加氢" ? 9 : kind === "customer" ? 10 : 8; + return <> + +
+ + {row.name} +
+ {titleFor(state.level)}账本汇总汇总{formatFixed(row.recordCount, 0)} 笔 + + + {expanded ? live.loading || live.error || !live.data ? {live.error ? `加载失败:${live.error},请收起后重试` : "正在加载子级数据…"} : <> + + {!live.data.groups.length && !live.data.records.length ? 当前层暂无子级数据 : null} + {page > 1 || live.data.page.hasMore ? 子级第 {page} 页 : null} + : null} + ; +} + +function GroupTable({ + state, + data, + onOpen, + expandedRowId, + expandedData, + expandedLoading, + expandedError, + onToggle, + kind, + label, + query, +}: { + state: DrillState; + data: H2BiDrillResponse; + onOpen: (row: H2BiDrillGroupRow) => void; + expandedRowId: string | null; + expandedData: H2BiDrillResponse | null; + expandedLoading: boolean; + expandedError: string | null; + onToggle: (row: H2BiDrillGroupRow) => void; + kind: DrillKind; + label: string; + query: H2BiQuery; +}) { + const safeLabel = String(label ?? ""); + const amountFor = (row: { cost?: unknown; revenue?: unknown }) => + data.amountScope === "customer" + ? Number(row.revenue ?? 0) + : Number(row.cost ?? 0); + const monthMetric = safeLabel.match(/^\d{4}年\d{1,2}月(加氢量|客户收入|成本支出)$/)?.[1]; + const stationCustomer = /^加氢站客户量:/.test(safeLabel); + const region = /^区域(?:市|省):/.test(safeLabel); + const specialized = monthMetric || stationCustomer || region; + if (specialized && (state.level === "station" || (stationCustomer && state.level === "customer"))) { + const totalKg = data.groups.reduce((sum, row) => sum + Number(row.kg || 0), 0); + return ( + + + {region ? : null} + + + {monthMetric === "加氢量" ? <> : null} + + {stationCustomer || region ? : null} + + {data.groups.map((row, index) => ( + + {region ? : null} + + + {monthMetric === "加氢量" ? <> : null} + + {stationCustomer || region ? : null} + + ))} +
#{stationCustomer ? "客户" : "加氢站"}{stationCustomer ? "类型" : "所属省份"}羚牛车辆加氢总量 (Kg)外部车辆加氢总量 (Kg){monthMetric === "客户收入" ? "客户收入 (元)" : monthMetric === "成本支出" ? "成本支出 (元)" : monthMetric === "加氢量" ? "合计加氢总量 (Kg)" : "加氢总量 (Kg)"}{stationCustomer ? "站内占比" : "区域内占比"}
{index + 1}{row.name}{stationCustomer ? 0 ? "ehb-tag--own-fleet" : "ehb-tag--ext-fleet"}`}>{row.lingniuKg > 0 ? "羚牛车辆" : "外部车辆"} : row.province || "未归属"}{formatFixed(row.lingniuKg)}{formatFixed(row.externalKg)}{monthMetric === "客户收入" ? `¥${formatFixed(row.revenue)}` : monthMetric === "成本支出" ? `¥${formatFixed(row.cost)}` : formatFixed(row.kg)}{totalKg > 0 ? `${((Number(row.kg) / totalKg) * 100).toFixed(1)}%` : "0.0%"}
+ ); + } + if (kind === "station" && state.level === "date") { + return {data.groups.map((row, index) => { const previous = Number(data.groups[index + 1]?.kg || 0); const change = previous > 0 ? ((Number(row.kg) - previous) / previous) * 100 : null; return onOpen(row)} style={{ cursor: "pointer" }}>; })}
日期加氢笔数加氢量 (Kg)较前日氢费收入 (元)平均单价 (元/Kg)
📅 {row.name}{formatFixed(row.recordCount, 0)} 笔{formatFixed(row.kg)}= 0 ? "ehb-day-change is-up" : "ehb-day-change is-down"}>{change === null ? "—" : `${change >= 0 ? "+" : ""}${change.toFixed(1)}%`}¥{formatFixed(row.revenue)}¥{Number(row.kg) > 0 ? (Number(row.revenue) / Number(row.kg)).toFixed(2) : "0.00"}
; + } + if (kind === "customer" && state.level === "date") { + return {data.groups.map((row) => onOpen(row)} style={{ cursor: "pointer" }}>)}
日期 / 车牌明细加氢站承担加氢量 (Kg)成本支出 (元)应收 (元)已收未收
📅 {row.name}客户{formatFixed(row.kg)}¥{formatFixed(row.cost)}¥{formatFixed(row.revenue)}未接入未接入
; + } + if (state.level === "record") { + return ( + + + + + + + + + + + + + + + {state.stationName ? ( + + + + + ) : null} + {state.customerName ? ( + + + + + ) : null} + {state.plateNo ? ( + + + + + ) : null} + {data.records.map((record, index) => ( + + + + + + + + + + + ))} + +
加氢站 / 客户 / 车辆与来源明细类型 / 归属承担方式数据来源及凭证号核对状态加氢笔数加氢总量 (Kg)加氢金额 (元)
{state.stationName}
加氢站真实账本流水
└─ 客户:{state.customerName}
客户真实账本流水
└─ {state.plateNo}
车辆真实账本流水
+
+ + 订单编号 {String(record.orderNo || record.id || "—")} + + + {String(record.time || "—")} · {String(record.stationName || "未关联站点")} · {String(record.customerName || "未关联客户")} + +
+
+ + {record.vehicleScope === "lingniu" ? "羚牛车辆" : "外部车辆"} + + + + + + {sourceLabel(record.source)} + + + + {verifyLabel(record.verifyStatus)} + + + 1 笔 + {formatFixed(record.kg)} + ¥{formatFixed(amountFor(record))} +
+ ); + } + const isProfit = label === "加氢利润"; + const isMonth = label === "本月加氢"; + const isDay = label === "本日加氢"; + const isStationBill = kind === "station"; + const isCustomerBill = kind === "customer"; + return ( + + + + + + + + + + {isProfit ? <> : isMonth ? <> : isDay ? <> : isCustomerBill ? <> : <>} + + + + {state.stationName ? ( + + + + + + + + + + + ) : null} + {state.customerName && state.level !== "customer" ? ( + + + + + + + + + + + ) : null} + {data.groups.map((row, index) => { + const rowKey = rowIdentity(row); + const expanded = expandedRowId === rowKey; + const nextLevel = titleFor(nextState(state, row).level); + return ( + + onOpen(row)} + style={{ cursor: "pointer" }} + title={`点击整行查看${nextLevel}`} + > + + + + + + + {isProfit ? <> : isMonth || isDay ? <> : isCustomerBill ? <> : <>} + + {expanded && !expandedLoading && !expandedError && expandedData ? : null} + {expanded ? ( + + + + ) : null} + + ); + })} + +
{isStationBill || isCustomerBill ? "日期 / 车牌明细" : "加氢站 / 客户 / 车辆与来源明细"}类型 / 归属承担方式数据来源及凭证号核对状态加氢笔数收入 (元)成本 (元)利润 (元)本月加氢量 (Kg)本月加氢费 (元)加氢费占累计本日加氢量 (Kg)本日加氢费 (元)加氢费占月比成本支出 (元)应收 (元)已收未收加氢总量 (Kg)加氢金额 (元)
+
{state.stationName}
+
加氢站真实账本流水
+
└─ 客户:{state.customerName}
+
客户真实账本流水
+
+ + + + {state.level === "customer" ? "客户:" : ""} + {row.name} + + {row.province ? {row.province} : null} + + +
+
+ {state.level === "vehicle" ? ( + 0 ? "ehb-tag--own-fleet" : "ehb-tag--ext-fleet"}`} + > + {row.lingniuKg > 0 ? "羚牛车辆" : "外部车辆"} + + ) : state.level === "station" ? ( + + 覆盖 {formatFixed(row.customerCount, 0)} 家客户 + + ) : ( + 聚合 + )} + + + + + {state.level === "vehicle" ? "真实账本流水" : "全量自动归集"} + + + + {state.level === "station" ? "汇总" : "点击展开"} + + {formatFixed(row.recordCount, 0)} 笔¥{formatFixed(row.revenue)}¥{formatFixed(row.cost)}¥{formatFixed(Number(row.revenue) - Number(row.cost))}{formatFixed(row.kg)}¥{formatFixed(amountFor(row))}¥{formatFixed(row.cost)}¥{formatFixed(row.revenue)}未接入未接入{formatFixed(row.kg)}¥{formatFixed(amountFor(row))}
+
+ {expandedLoading ? "正在加载子级数据…" : expandedError ? ( + 子级数据加载失败:{expandedError}。请收起后重新展开。 + ) : expandedData && (expandedData.groups.length || expandedData.records.length) ? null : "当前层暂无子级数据"} + {expandedData?.page.hasMore ? ( +

+ 此处仅预览前 {expandedData.page.itemCount ?? 50} 条;进入完整明细后可继续翻页。 +

+ ) : null} + {!expandedLoading && !expandedError ? ( + + ) : null} +
+
+ ); +} + +function DrillSummaryCards({ kind, label: labelInput, data }: { kind: DrillKind; label: unknown; data: H2BiDrillResponse | null }) { + const label = String(labelInput ?? ""); + const summary = data?.summary; + const groups = data?.groups ?? []; + const kg = Number(summary?.kg ?? 0); + const cost = Number(summary?.cost ?? 0); + const revenue = Number(summary?.revenue ?? 0); + const stationCount = Number(summary?.stationCount ?? groups.length); + const ownKg = groups.reduce((sum, row) => sum + Number(row.lingniuKg || 0), 0); + const externalKg = groups.reduce((sum, row) => sum + Number(row.externalKg || 0), 0); + const monthMetric = label.match(/^\d{4}年\d{1,2}月(加氢量|客户收入|成本支出)$/)?.[1]; + let cards: Array<[string, string, string?]>; + if (label === "加氢利润") cards = [["收入合计", `¥${formatFixed(revenue)}`, "income"], ["成本合计", `¥${formatFixed(cost)}`, "cost"], ["加氢利润", `¥${formatFixed(revenue - cost)}`, "profit"], ["覆盖加氢站数", `${stationCount} 站`]]; + else if (label === "本月加氢") cards = [["本月加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["本月加氢费", `¥${formatFixed(cost / 10000)} 万元`, "cost"], ["加氢费占累计", "按所选月份", "profit"], ["覆盖加氢站数", `${stationCount} 站`]]; + else if (label === "本日加氢") cards = [["本日加氢量", `${formatFixed(kg)} Kg`, "volume"], ["本日加氢费", `¥${formatFixed(cost)}`, "cost"], ["加氢费占月比", "按所选日期", "profit"], ["覆盖加氢站数", `${stationCount} 站`]]; + else if (monthMetric === "加氢量") cards = [["羚牛车辆加氢总量", `${formatFixed(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatFixed(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatFixed(kg)} Kg`], ["覆盖加氢站数", `${stationCount} 站`]]; + else if (monthMetric === "客户收入") cards = [["客户收入合计", `¥${formatFixed(revenue)}`, "income"], ["站均收入", `¥${formatFixed(revenue / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount} 站`]]; + else if (monthMetric === "成本支出") cards = [["成本支出合计", `¥${formatFixed(cost)}`, "cost"], ["站均成本", `¥${formatFixed(cost / Math.max(1, stationCount))}`], ["覆盖加氢站数", `${stationCount} 站`]]; + else if (/^加氢站客户量:/.test(label)) cards = [["加氢站", label.replace(/^加氢站客户量:/, "")], ["羚牛车辆加氢总量", `${formatFixed(ownKg)} Kg`, "volume"], ["外部车辆加氢总量", `${formatFixed(externalKg)} Kg`, "cost"], ["合计加氢总量", `${formatFixed(kg)} Kg`]]; + else if (/^区域(?:市|省):/.test(label)) cards = [["区域", label.replace(/^区域(?:市|省):/, "")], ["加氢总量", `${formatFixed(kg / 1000)} T`, "volume"], ["覆盖加氢站数", `${stationCount} 站`]]; + else if (kind === "station") cards = [["加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["氢费收入", `¥${formatFixed(revenue / 10000)} 万元`, "income"], ["平均单价", `¥${kg > 0 ? formatFixed(revenue / kg) : "0.00"} /Kg`], ["加氢笔数", `${formatFixed(summary?.recordCount ?? 0, 0)} 笔`]]; + else if (kind === "customer") cards = [["承担方", "客户承担"], ["加氢量", `${formatFixed(kg / 1000)} T`, "volume"], ["成本支出", `¥${formatFixed(cost / 10000)} 万元`, "cost"], ["应收", `¥${formatFixed(revenue)} 元`, "income"], ["已收", "未接入"], ["未收", "未接入"]]; + else cards = [["数据归集总量", `${formatFixed(kg / 1000)} T`, "volume"], ["数据总金额", `¥${formatFixed(cost / 10000)} 万元`, "income"], ["覆盖加氢站数", `${stationCount} 站`], ["来源记录完整度", `${summary?.recordCount ? formatFixed((Number(summary.traceableRecordCount) / Number(summary.recordCount)) * 100, 0) : "0"}%(含账本来源字段)`, "cost"]]; + return
{cards.map(([title, value, tone]) =>
{title}{value}
)}
; +} + +/** Preserves the prototype overlay/card/meta/table anatomy, but every row is live ledger data. */ +export function PrototypeDrillModal({ + kind, + label: labelInput, + query, + onClose, +}: PrototypeDrillModalProps) { + const label = String(labelInput ?? ""); + const [state, setState] = useState(() => ({ + ...initialState(kind, label), + })); + const [history, setHistory] = useState([]); + const [search, setSearch] = useState(""); + const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); + const [expandedRow, setExpandedRow] = useState(null); + const [meta, setMeta] = useState(null); + const [page, setPage] = useState(1); + const [exportingAll, setExportingAll] = useState(false); + const [exportError, setExportError] = useState(null); + const [fleetCategoryFilter, setFleetCategoryFilter] = useState< + "all" | "own" | "external" + >(() => + query.vehicleScope === "lingniu" + ? "own" + : query.vehicleScope === "external" + ? "external" + : "all", + ); + const drillGuardId = useRef(`h2-drill-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const historyRef = useRef([]); + const historyScrollRef = useRef([]); + const pendingScrollRef = useRef(null); + const tableWrapRef = useRef(null); + const exportControllerRef = useRef(null); + const stateRef = useRef(state); + const forceCloseRef = useRef(false); + const onCloseRef = useRef(onClose); + + useEffect(() => { + historyRef.current = history; + stateRef.current = state; + onCloseRef.current = onClose; + }, [history, onClose, state]); + + useEffect(() => { + const marker = drillGuardId.current; + const currentState = window.history.state as Record | null; + if (currentState?.__h2DrillGuard !== marker) { + window.history.pushState( + { ...(currentState ?? {}), __h2DrillGuard: marker }, + "", + window.location.href, + ); + } + const handlePopState = () => { + if (forceCloseRef.current || historyRef.current.length === 0) { + onCloseRef.current(); + return; + } + const previous = historyRef.current.at(-1); + if (!previous) { + onCloseRef.current(); + return; + } + const remaining = historyRef.current.slice(0, -1); + historyRef.current = remaining; + stateRef.current = previous; + setHistory(remaining); + setState(previous); + setExpandedRow(null); + window.history.pushState( + { ...(window.history.state ?? {}), __h2DrillGuard: marker }, + "", + window.location.href, + ); + }; + window.addEventListener("popstate", handlePopState); + return () => window.removeEventListener("popstate", handlePopState); + }, []); + + const requestClose = () => { + const currentState = window.history.state as Record | null; + if (currentState?.__h2DrillGuard === drillGuardId.current) { + forceCloseRef.current = true; + window.history.back(); + return; + } + onClose(); + }; + useEffect(() => { + let alive = true; + void fetchH2BiMeta().then((result) => alive && setMeta(result)).catch(() => { + // The drill itself remains usable even when the optional select metadata is unavailable. + }); + return () => { + alive = false; + }; + }, []); + useEffect(() => { + if (!meta || !/^加氢站客户量:/.test(label) || state.stationId) return; + const stationName = label.replace(/^加氢站客户量:/, "").replace(/\|stationId=\d+$/, "").trim(); + const station = meta.stations.find((item) => item.name === stationName); + if (!station) return; + setState((current) => ({ + ...current, + level: "customer", + stationId: String(station.id), + stationName: station.name, + })); + }, [label, meta, state.stationId]); + const liveQuery = useMemo( + () => ({ + ...query, + vehicleScope: fleetScope(fleetCategoryFilter), + ...(label.replace(/\|stationId=\d+$/, "") === "待核对订单" + ? { verifyScope: "unverified" as const } + : {}), + }), + [fleetCategoryFilter, label, query], + ); + const live = useDrill(liveQuery, state, page, 100); + const expandedState = useMemo( + () => expandedRow ? nextState(state, expandedRow) : state, + [expandedRow, state], + ); + const expandedLive = useDrill(liveQuery, expandedState, 1, 50, Boolean(expandedRow)); + const drillFilterKey = JSON.stringify({ liveQuery, state }); + useEffect(() => { + setPage(1); + // Full exports are tied to the exact filter and drill level that started + // them. Changing either must not leave an old export running in the + // background or present its result as if it belonged to the new view. + if (exportControllerRef.current) { + exportControllerRef.current.abort(); + exportControllerRef.current = null; + setExportingAll(false); + } + setExportError(null); + }, [drillFilterKey]); + useEffect(() => () => exportControllerRef.current?.abort(), []); + const data = useMemo(() => { + if (!live.data || !search.trim()) return live.data; + const term = search.trim().toLowerCase(); + return { + ...live.data, + groups: live.data.groups.filter((row) => + JSON.stringify(row).toLowerCase().includes(term), + ), + records: live.data.records.filter((row) => + JSON.stringify(row).toLowerCase().includes(term), + ), + }; + }, [live.data, search]); + const cleanLabel = label.replace(/\|stationId=\d+$/, ""); + const isFlatKpi = /^\d{4}年\d{1,2}月(?:加氢量|客户收入|成本支出)$/.test(cleanLabel) || /^加氢站客户量:/.test(cleanLabel) || /^区域(?:市|省):/.test(cleanLabel); + const customerBearingScope = live.data?.amountScope === "customer"; + const profit = + Number(live.data?.summary.revenue ?? 0) - + Number(live.data?.summary.cost ?? 0); + const open = (row: H2BiDrillGroupRow) => { + historyScrollRef.current.push(tableWrapRef.current?.scrollTop ?? 0); + setHistory((items) => [...items, state]); + setState(nextState(state, row)); + setPage(1); + setExpandedRow(null); + }; + const toggle = (row: H2BiDrillGroupRow) => { + setExpandedRow((current) => current && rowIdentity(current) === rowIdentity(row) ? null : row); + }; + const stationOptions = meta?.stations ?? []; + const customerOptions = + state.level === "customer" + ? live.data?.groups.map((row) => ({ id: row.id, name: row.name })) ?? [] + : state.customerId && state.customerName + ? [{ id: String(state.customerId), name: state.customerName }] + : []; + const vehicleOptions = + state.level === "vehicle" + ? live.data?.groups.map((row) => ({ id: row.id, name: row.name })) ?? [] + : state.plateNo + ? [{ id: state.plateNo, name: state.plateNo }] + : []; + const resetToRoot = (overrides: Partial = {}) => { + setHistory([]); + historyScrollRef.current = []; + setSearch(""); + setExpandedRow(null); + setState({ ...initialState(kind, label), ...overrides }); + }; + const changeStation = (value: string) => { + setExpandedRow(null); + if (!value) { + resetToRoot({ rootDimension: state.rootDimension, level: state.rootDimension }); + return; + } + resetToRoot({ + stationId: value, + stationName: stationOptions.find((station) => String(station.id) === value)?.name, + level: "customer", + rootDimension: "station", + }); + }; + const changeCustomer = (value: string) => { + setExpandedRow(null); + if (!value) { + if (state.stationId) setState((current) => ({ ...current, level: "customer", customerId: undefined, plateNo: undefined })); + return; + } + setHistory([]); + setSearch(""); + setState((current) => ({ + ...current, + customerId: Number(value), + customerName: customerOptions.find((customer) => customer.id === value)?.name, + plateNo: undefined, + level: "vehicle", + })); + }; + const changeVehicle = (value: string) => { + setExpandedRow(null); + if (!value) { + setState((current) => ({ ...current, level: "vehicle", plateNo: undefined })); + return; + } + setHistory([]); + setSearch(""); + setState((current) => ({ ...current, plateNo: value, level: "record" })); + }; + const changeAmountScope = (value: string) => { + setExpandedRow(null); + const amountScope = (value || "all") as H2BiAmountScope; + setHistory([]); + setSearch(""); + setState((current) => ({ ...current, amountScope })); + }; + const exportData = (exported: H2BiDrillResponse, suffix: string) => { + const rows: Array> = [ + [ + "加氢站 / 客户 / 车辆与凭证链路", + "加氢量(Kg)", + "成本金额(元)", + "对客金额(元)", + ], + ]; + if (state.level === "record") { + exported.records.forEach((record) => + rows.push([ + `${String(record.stationName || "未关联站点")} / ${String(record.customerName || "未关联客户")} / ${String(record.plateNo || "无车牌")} / ${String(record.orderNo || record.id || "—")}`, + Number(record.kg ?? 0), + Number(record.cost ?? 0), + Number(record.revenue ?? 0), + ]), + ); + } else { + exported.groups.forEach((row) => + rows.push([row.name, row.kg, row.cost, row.revenue]), + ); + } + exportAoaSheet(rows, `${cleanLabel}_${suffix}_真实账本穿透.xlsx`, "真实账本穿透"); + }; + const exportCurrent = () => { + if (data) exportData(data, `第${page}页`); + }; + const exportAll = async () => { + if (exportingAll) { + exportControllerRef.current?.abort(); + return; + } + const controller = new AbortController(); + exportControllerRef.current = controller; + setExportingAll(true); + setExportError(null); + try { + const complete = await fetchAllH2BiDrill({ + ...liveQuery, + ...state, + groupBy: state.level, + amountScope: state.amountScope, + }, { signal: controller.signal }); + if (!controller.signal.aborted) exportData(complete, "全部"); + } catch (reason) { + if (!controller.signal.aborted) { + setExportError(reason instanceof Error ? reason.message : "全量导出失败,未生成文件"); + } + } finally { + if (exportControllerRef.current === controller) { + exportControllerRef.current = null; + setExportingAll(false); + } + } + }; + const switchRootDimension = (rootDimension: DrillRootDimension) => { + if (kind !== "kpi" || state.rootDimension === rootDimension) return; + setHistory([]); + setSearch(""); + setExpandedRow(null); + setState({ + ...initialState(kind, label), + rootDimension, + level: rootDimension, + }); + }; + const back = () => { + const previous = history.at(-1); + if (!previous) { + requestClose(); + return; + } + pendingScrollRef.current = historyScrollRef.current.pop() ?? 0; + setHistory((items) => items.slice(0, -1)); + setState(previous); + setExpandedRow(null); + }; + useEffect(() => { + if (live.loading || pendingScrollRef.current === null) return; + const scrollTop = pendingScrollRef.current; + pendingScrollRef.current = null; + requestAnimationFrame(() => tableWrapRef.current?.scrollTo({ top: scrollTop })); + }, [live.loading, state]); + const pathStates = [...history, state]; + const pathLabel = (item: DrillState, index: number) => { + if (index === 0) return cleanLabel; + if (item.level === "customer") return item.stationName || "加氢站"; + if (item.level === "station") return item.customerName || "客户"; + if (item.level === "vehicle") return item.customerName || item.date || "明细"; + if (item.level === "record") return item.plateNo || "加氢记录"; + return titleFor(item.level); + }; + const jumpToPath = (index: number) => { + if (index >= pathStates.length - 1) return; + setState(pathStates[index]); + setHistory(pathStates.slice(0, index)); + historyScrollRef.current = historyScrollRef.current.slice(0, index); + setSearch(""); + setExpandedRow(null); + }; + const scope = + state.date || + state.month || + query.date || + query.month || + (query.startDate && query.endDate + ? `${query.startDate} 至 ${query.endDate}` + : `${query.year}-01-01 至 ${localDateParts().year}-${localDateParts().month}-${localDateParts().day}`); + const displayTitle = /累计加氢(?:量|费)/.test(cleanLabel) + ? "累计加氢量与费用明细" + : /\d{4}年/.test(cleanLabel) + ? `${cleanLabel}明细` + : `「${query.year}」${cleanLabel}明细`; + return ( +
+
event.stopPropagation()} + > +
+
+ +
+
{displayTitle}
+
{scope.replaceAll("-", ".").replace(" 至 ", " — ")}
+
+
+
+ +
+
+ +
+ {kind === "kpi" && history.length === 0 && !isFlatKpi ? ( +
+ + +
+ ) : null} + +{cleanLabel === "待核对订单" ?
核对功能上线期初:{HYDROGEN_VERIFY_START_DATE}(含)。仅统计当前查询范围与上线期初之后的交集,且只包含有车牌、已关联羚牛车辆的订单;无车牌、未关联羚牛车辆及上线期初之前的订单不计入待核对。
: null} + {cleanLabel === "加氢利润" && customerBearingScope ? ( +
+ 利润口径:客户承担订单的对客总价 ¥ + {formatFixed(live.data?.summary.revenue)} − 成本总价 ¥ + {formatFixed(live.data?.summary.cost)} = ¥{formatFixed(profit)} +
+ ) : null} +
+ + +
+
+
+ ({ + value: String(station.id), + label: station.name, + }))} + allLabel="全部加氢站" + placeholder="搜索加氢站" + /> + {!isFlatKpi ? ({ + value: String(customer.id), + label: customer.name, + }))} + allLabel="全部客户" + placeholder="搜索客户" + disabled={!state.stationId} + /> : null} + {!isFlatKpi ? ({ + value: vehicle.name, + label: vehicle.name, + }))} + allLabel="全部车辆" + placeholder="搜索车牌" + disabled={!state.customerId} + /> : null} + {!isFlatKpi ? : null} +
+ {([ + ["all", "全部车辆"], + ["own", "羚牛车辆"], + ["external", "外部车辆"], + ] as const).map(([value, text]) => ( + + ))} +
+ + + 当前表按页加载(每页最多 100 条);“导出全部”才会逐页读取完整结果。 + {exportError ? {exportError} : null} +
+ +
+
+ 点击一行 进入下一级明细 + 左右滑动 查看更多字段 +
+
+ {live.loading ? ( + + ) : live.error ? ( +
{live.error}
+ ) : data && + (state.level === "record" + ? data.records.length + : data.groups.length) > 0 ? ( + resetToRoot()} + > + + + ) : ( +
当前筛选范围暂无数据
+ )} +
+ {data ? ( +
+ 第 {page} 页 · 本页 {data.page.itemCount ?? (state.level === "record" ? data.records.length : data.groups.length)} 条 + + +
+ ) : null} +
+
+
+ ); +} + +export function prototypeFleetScope(value: "all" | "own" | "external") { + return fleetScope(value); +} diff --git a/src/modules/energy/hydrogen/drill/real-daily-mobile.css b/src/modules/energy/hydrogen/drill/real-daily-mobile.css new file mode 100644 index 0000000..e942714 --- /dev/null +++ b/src/modules/energy/hydrogen/drill/real-daily-mobile.css @@ -0,0 +1,189 @@ +/* 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; + } +} diff --git a/src/modules/energy/hydrogen/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2 b/src/modules/energy/hydrogen/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2 new file mode 100644 index 0000000..4917f43 Binary files /dev/null and b/src/modules/energy/hydrogen/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2 differ diff --git a/src/modules/energy/hydrogen/fonts/jetbrains-mono/JetBrainsMono-ExtraBold.woff2 b/src/modules/energy/hydrogen/fonts/jetbrains-mono/JetBrainsMono-ExtraBold.woff2 new file mode 100644 index 0000000..8f88c54 Binary files /dev/null and b/src/modules/energy/hydrogen/fonts/jetbrains-mono/JetBrainsMono-ExtraBold.woff2 differ diff --git a/src/modules/energy/hydrogen/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2 b/src/modules/energy/hydrogen/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2 new file mode 100644 index 0000000..669d04c Binary files /dev/null and b/src/modules/energy/hydrogen/fonts/jetbrains-mono/JetBrainsMono-Medium.woff2 differ diff --git a/src/modules/energy/hydrogen/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2 b/src/modules/energy/hydrogen/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2 new file mode 100644 index 0000000..40da427 Binary files /dev/null and b/src/modules/energy/hydrogen/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2 differ diff --git a/src/modules/energy/hydrogen/fonts/jetbrains-mono/JetBrainsMono-SemiBold.woff2 b/src/modules/energy/hydrogen/fonts/jetbrains-mono/JetBrainsMono-SemiBold.woff2 new file mode 100644 index 0000000..5ead7b0 Binary files /dev/null and b/src/modules/energy/hydrogen/fonts/jetbrains-mono/JetBrainsMono-SemiBold.woff2 differ diff --git a/src/modules/energy/hydrogen/fonts/jetbrains-mono/OFL.txt b/src/modules/energy/hydrogen/fonts/jetbrains-mono/OFL.txt new file mode 100644 index 0000000..8bee414 --- /dev/null +++ b/src/modules/energy/hydrogen/fonts/jetbrains-mono/OFL.txt @@ -0,0 +1,93 @@ +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. diff --git a/src/modules/energy/hydrogen/fonts/jetbrains-mono/README.md b/src/modules/energy/hydrogen/fonts/jetbrains-mono/README.md new file mode 100644 index 0000000..ef71767 --- /dev/null +++ b/src/modules/energy/hydrogen/fonts/jetbrains-mono/README.md @@ -0,0 +1,10 @@ +# JetBrains Mono(OneOS 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。 diff --git a/src/modules/energy/hydrogen/fonts/jetbrains-mono/jetbrains-mono.css b/src/modules/energy/hydrogen/fonts/jetbrains-mono/jetbrains-mono.css new file mode 100644 index 0000000..0a1e4b9 --- /dev/null +++ b/src/modules/energy/hydrogen/fonts/jetbrains-mono/jetbrains-mono.css @@ -0,0 +1,45 @@ +/** + * 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; +} diff --git a/src/modules/energy/hydrogen/independent-entry.test.ts b/src/modules/energy/hydrogen/independent-entry.test.ts new file mode 100644 index 0000000..1884769 --- /dev/null +++ b/src/modules/energy/hydrogen/independent-entry.test.ts @@ -0,0 +1,37 @@ +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 /); +}); diff --git a/src/modules/energy/hydrogen/index.tsx b/src/modules/energy/hydrogen/index.tsx new file mode 100644 index 0000000..b11c025 --- /dev/null +++ b/src/modules/energy/hydrogen/index.tsx @@ -0,0 +1,9 @@ +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 ; +} diff --git a/src/modules/energy/hydrogen/model/bearing-labels.test.ts b/src/modules/energy/hydrogen/model/bearing-labels.test.ts new file mode 100644 index 0000000..92c5d4e --- /dev/null +++ b/src/modules/energy/hydrogen/model/bearing-labels.test.ts @@ -0,0 +1,18 @@ +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), ["未明确"]); + } +}); diff --git a/src/modules/energy/hydrogen/model/bearing-labels.ts b/src/modules/energy/hydrogen/model/bearing-labels.ts new file mode 100644 index 0000000..e68add2 --- /dev/null +++ b/src/modules/energy/hydrogen/model/bearing-labels.ts @@ -0,0 +1,13 @@ +const labels: Record = { + "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()]; +} diff --git a/src/modules/energy/hydrogen/model/daily-detail-format.ts b/src/modules/energy/hydrogen/model/daily-detail-format.ts new file mode 100644 index 0000000..a99ac50 --- /dev/null +++ b/src/modules/energy/hydrogen/model/daily-detail-format.ts @@ -0,0 +1,15 @@ +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> { + return [ + ["日期", "加氢站数", "加氢量(Kg)", "成本(元)"], + ["区间合计", daily.kpis.stationCount, daily.kpis.totalKg, daily.kpis.totalCost], + ...daily.days.map(day => [day.date, day.stationCount ?? "—", day.kg, day.cost]), + ]; +} diff --git a/src/modules/energy/hydrogen/model/display-format.test.ts b/src/modules/energy/hydrogen/model/display-format.test.ts new file mode 100644 index 0000000..bf00070 --- /dev/null +++ b/src/modules/energy/hydrogen/model/display-format.test.ts @@ -0,0 +1,19 @@ +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"); +}); diff --git a/src/modules/energy/hydrogen/model/display-format.ts b/src/modules/energy/hydrogen/model/display-format.ts new file mode 100644 index 0000000..8d30239 --- /dev/null +++ b/src/modules/energy/hydrogen/model/display-format.ts @@ -0,0 +1,33 @@ +/** + * 氢能看板的数值格式化。 + * + * 此前两个下钻视图各自复制了一份同样的实现(共 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, + }); +} diff --git a/src/modules/energy/hydrogen/station-daily/.spec/requirements-prd.md b/src/modules/energy/hydrogen/station-daily/.spec/requirements-prd.md new file mode 100644 index 0000000..61f27bc --- /dev/null +++ b/src/modules/energy/hydrogen/station-daily/.spec/requirements-prd.md @@ -0,0 +1,63 @@ +# 加氢站日报 · 产品需求说明(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) diff --git a/src/modules/energy/hydrogen/station-daily/SdCustomerMultiSelect.tsx b/src/modules/energy/hydrogen/station-daily/SdCustomerMultiSelect.tsx new file mode 100644 index 0000000..08788a6 --- /dev/null +++ b/src/modules/energy/hydrogen/station-daily/SdCustomerMultiSelect.tsx @@ -0,0 +1,128 @@ +/** + * 客户多选(能源 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(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 ( +
+ + {open ? ( +
+
+ setQ(e.target.value)} + placeholder="搜索客户" + aria-label="搜索客户" + /> + {q ? ( + + ) : null} +
+
+ + +
+
    + {filtered.length === 0 ? ( +
  • 无匹配客户
  • + ) : ( + filtered.map((name) => { + const checked = allSelected || value.includes(name); + return ( +
  • + +
  • + ); + }) + )} +
+
+ ) : null} +
+ ); +}; diff --git a/src/modules/energy/hydrogen/station-daily/SdDateRangePicker.tsx b/src/modules/energy/hydrogen/station-daily/SdDateRangePicker.tsx new file mode 100644 index 0000000..50f2a03 --- /dev/null +++ b/src/modules/energy/hydrogen/station-daily/SdDateRangePicker.tsx @@ -0,0 +1,263 @@ +/** + * 站日报 · 起止日期区间(精确到日)+ 本日/本周/本月快捷 + * 能源 BI 皮 · 禁原生 type=date · 禁 V2 + */ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react'; + +function pad2(n: number) { + return n < 10 ? `0${n}` : `${n}`; +} + +export function parseYmd(value: string) { + const parts = value.split('-'); + const year = parseInt(parts[0], 10) || 2026; + const month = parseInt(parts[1], 10) || 1; + const day = parseInt(parts[2], 10) || 1; + return { year, month, day }; +} + +export function toYmd(year: number, month: number, day: number) { + return `${year}-${pad2(month)}-${pad2(day)}`; +} + +export function displayYmd(value: string) { + const { year, month, day } = parseYmd(value); + return `${year}-${pad2(month)}-${pad2(day)}`; +} + +function ymdFromDate(d: Date) { + return toYmd(d.getFullYear(), d.getMonth() + 1, d.getDate()); +} + +function startOfWeek(d: Date) { + const x = new Date(d); + const day = x.getDay(); + const diff = day === 0 ? -6 : 1 - day; // 周一起 + x.setDate(x.getDate() + diff); + return x; +} + +export function rangeShortcuts(anchor = new Date()): Record { + const today = ymdFromDate(anchor); + const sow = startOfWeek(anchor); + const eow = new Date(sow); + eow.setDate(sow.getDate() + 6); + const som = toYmd(anchor.getFullYear(), anchor.getMonth() + 1, 1); + const eomDate = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0); + return { + today: { start: today, end: today }, + week: { start: ymdFromDate(sow), end: ymdFromDate(eow) }, + month: { start: som, end: ymdFromDate(eomDate) }, + }; +} + +type PickTarget = 'start' | 'end'; + +export const SdDateRangePicker: React.FC<{ + label?: string; + start: string; + end: string; + onChange: (next: { start: string; end: string }) => void; + align?: 'left' | 'right'; + /** 日历锚点日期(原型固定业务日,避免本机今天跑偏) */ + anchorYmd?: string; +}> = ({ label = '查询日期', start, end, onChange, align = 'right', anchorYmd }) => { + const [open, setOpen] = useState(false); + const [picking, setPicking] = useState('start'); + const [draftStart, setDraftStart] = useState(start); + const [draftEnd, setDraftEnd] = useState(end); + const rootRef = useRef(null); + + const anchor = useMemo(() => { + if (anchorYmd) { + const p = parseYmd(anchorYmd); + return new Date(p.year, p.month - 1, p.day); + } + return new Date(); + }, [anchorYmd]); + + const startP = useMemo(() => parseYmd(draftStart), [draftStart]); + const endP = useMemo(() => parseYmd(draftEnd), [draftEnd]); + const focus = picking === 'start' ? startP : endP; + const [viewYear, setViewYear] = useState(focus.year); + const [viewMonth, setViewMonth] = useState(focus.month); + + useEffect(() => { + if (!open) return; + setDraftStart(start); + setDraftEnd(end); + setPicking('start'); + const p = parseYmd(start); + setViewYear(p.year); + setViewMonth(p.month); + }, [open, start, end]); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, [open]); + + const daysInMonth = new Date(viewYear, viewMonth, 0).getDate(); + const firstWeekday = new Date(viewYear, viewMonth - 1, 1).getDay(); + const days = Array.from({ length: daysInMonth }, (_, i) => i + 1); + const blanks = Array.from({ length: firstWeekday }, (_, i) => i); + + const goPrev = (e: React.MouseEvent) => { + e.stopPropagation(); + if (viewMonth === 1) { + setViewYear((y) => y - 1); + setViewMonth(12); + } else setViewMonth((m) => m - 1); + }; + + const goNext = (e: React.MouseEvent) => { + e.stopPropagation(); + if (viewMonth === 12) { + setViewYear((y) => y + 1); + setViewMonth(1); + } else setViewMonth((m) => m + 1); + }; + + const inRange = (ymd: string) => ymd >= draftStart && ymd <= draftEnd; + const isEdge = (ymd: string) => ymd === draftStart || ymd === draftEnd; + + const pickDay = (day: number, e: React.MouseEvent) => { + e.stopPropagation(); + const ymd = toYmd(viewYear, viewMonth, day); + if (picking === 'start') { + const nextStart = ymd; + const nextEnd = ymd > draftEnd ? ymd : draftEnd; + setDraftStart(nextStart); + setDraftEnd(nextEnd); + setPicking('end'); + return; + } + if (ymd < draftStart) { + setDraftStart(ymd); + setDraftEnd(draftStart); + } else { + setDraftEnd(ymd); + } + setPicking('start'); + }; + + const applyDraft = () => { + const a = draftStart <= draftEnd ? draftStart : draftEnd; + const b = draftStart <= draftEnd ? draftEnd : draftStart; + onChange({ start: a, end: b }); + setOpen(false); + }; + + const applyShortcut = (key: 'today' | 'week' | 'month') => { + const map = rangeShortcuts(anchor); + const r = map[key]; + setDraftStart(r.start); + setDraftEnd(r.end); + onChange(r); + setOpen(false); + }; + + return ( +
+ + + {open ? ( +
+
+ + + +
+ +
+ + +
+ +
+ +
+ {viewYear}年{pad2(viewMonth)}月 +
+ +
+ +
+ {['日', '一', '二', '三', '四', '五', '六'].map((w) => ( + {w} + ))} +
+ +
+ {blanks.map((i) => ( + + ))} + {days.map((d) => { + const ymd = toYmd(viewYear, viewMonth, d); + const cls = [ + 'sd-date__day', + isEdge(ymd) ? 'is-selected' : '', + inRange(ymd) ? 'is-in-range' : '', + ] + .filter(Boolean) + .join(' '); + return ( + + ); + })} +
+ +
+ + +
+
+ ) : null} +
+ ); +}; diff --git a/src/modules/energy/hydrogen/station-daily/StationDailyApp.tsx b/src/modules/energy/hydrogen/station-daily/StationDailyApp.tsx new file mode 100644 index 0000000..4f8a59d --- /dev/null +++ b/src/modules/energy/hydrogen/station-daily/StationDailyApp.tsx @@ -0,0 +1,531 @@ +// @ts-nocheck — verbatim 8113 prototype source; runtime DOM/CSS is intentionally preserved. +/** + * 加氢站日报 · 查询区间总览 → 钻取单站 + * 起止日期驱动计算;对外只叙事 + */ +import React, { useEffect, useMemo, useState } from 'react'; +import { ArrowUpRight, Fuel, MapPin, RefreshCw, X } from 'lucide-react'; +import { MobileListFullscreenButton } from '../common/MobileListFullscreenButton'; +import { fetchHydrogenStationBoard } from '../../api'; +import type { HydrogenStationBoardResponse } from '../../types'; +import type { StationDailyVolumeRow } from './data/mockStationDaily'; +import { SdDateRangePicker } from './SdDateRangePicker'; +import { StationDailyDetailView } from './StationDailyDetailView'; +import './styles.css'; + +function money(n: number) { + return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function kg(n: number) { + return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +/** 闭区间日历天数 */ +function calendarDays(start: string, end: string): string[] { + const days: string[] = []; + const cursor = new Date(`${start}T00:00:00`); + const last = new Date(`${end}T00:00:00`); + while (cursor <= last) { + const y = cursor.getFullYear(); + const m = String(cursor.getMonth() + 1).padStart(2, '0'); + const d = String(cursor.getDate()).padStart(2, '0'); + days.push(`${y}-${m}-${d}`); + cursor.setDate(cursor.getDate() + 1); + } + return days; +} + +type StationOverview = { + id: string; + name: string; + region: string; + endKg: number; + endAmt: number; + endVehicles: number; + rangeKg: number; + rangeAmt: number; + cashDays: number; + cashTotal: number; + vols: StationDailyVolumeRow[]; + share: number; +}; + +type KpiDrill = 'volumeTotal' | 'volumeDays' | 'cashDays' | null; + +function StationTrend({ vols }: { vols: StationDailyVolumeRow[] }) { + const recent = vols.slice(-7); + const peak = Math.max(...recent.map((v) => v.quantityKg), 1); + return ( +
+ 近7日趋势 +
+ {recent.map((v) => ( +
+ + {v.date} + {kg(v.quantityKg)} Kg + + + + {v.quantityKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} + + + + {v.date.slice(5)} +
+ ))} + {recent.length === 0 ? 暂无趋势数据 : null} +
+
+ ); +} + +function localIsoDate(date: Date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +const defaultRange = (() => { + const end = new Date(); + const start = new Date(end); + start.setDate(end.getDate() - 9); + return { startDate: localIsoDate(start), end: localIsoDate(end) }; +})(); + +function isAllowedSingleStation(name: string) { + return name.includes('东鹏大道') || (name.includes('佛山南海') && name.includes('羚牛')); +} + +type StationDailyAppProps = { + embedded?: boolean; + startDate?: string; + endDate?: string; + onStartDateChange?: (value: string) => void; + onEndDateChange?: (value: string) => void; + refreshToken?: number; + onLoadingChange?: (loading: boolean) => void; +}; + +export const StationDailyApp: React.FC = ({ + embedded = false, + startDate: controlledStartDate, + endDate: controlledEndDate, + onStartDateChange, + onEndDateChange, + refreshToken = 0, + onLoadingChange, +}) => { + const [localStartDate, setLocalStartDate] = useState(defaultRange.startDate); + const [localEndDate, setLocalEndDate] = useState(defaultRange.end); + const startDate = controlledStartDate ?? localStartDate; + const endDate = controlledEndDate ?? localEndDate; + const setStartDate = onStartDateChange ?? setLocalStartDate; + const setEndDate = onEndDateChange ?? setLocalEndDate; + const [drillStationId, setDrillStationId] = useState(null); + // 初值不用伪造时间戳;成功回调会写入真实 latestLedgerTime,缺数据时与之一致。 + const [updatedAt, setUpdatedAt] = useState('暂无账本更新时间'); + const [tick, setTick] = useState(0); + const [kpiDrill, setKpiDrill] = useState(null); + const [liveBoard, setLiveBoard] = useState(null); + const [liveError, setLiveError] = useState(null); + const [liveLoading, setLiveLoading] = useState(true); + const [stationCashBoard, setStationCashBoard] = useState(null); + const [stationCashError, setStationCashError] = useState(null); + + useEffect(() => { + let active = true; + setLiveLoading(true); + onLoadingChange?.(true); + setLiveError(null); + fetchHydrogenStationBoard({ startDate, endDate, force: tick > 0 }) + .then((result) => { + if (!active) return; + setLiveBoard(result); + setUpdatedAt(result.summary.latestLedgerTime ?? '暂无账本更新时间'); + setLiveLoading(false); + onLoadingChange?.(false); + }) + .catch((reason: unknown) => { + if (!active) return; + setLiveError(reason instanceof Error ? reason.message : '单站统计加载失败'); + setLiveLoading(false); + onLoadingChange?.(false); + }); + return () => { active = false; }; + }, [startDate, endDate, tick, refreshToken]); + + useEffect(() => { + if (!drillStationId) return; + requestAnimationFrame(() => { + window.scrollTo({ top: 0, behavior: 'auto' }); + document.querySelector('.ehb-body')?.scrollTo({ top: 0, behavior: 'auto' }); + }); + }, [drillStationId]); + + const overviewRows: StationOverview[] = useMemo(() => { + const base = (liveBoard?.stations ?? []) + .map((st) => { + const vols = st.dailyKg.map((row) => ({ + date: row.date, + stationId: String(st.id), + quantityKg: row.kg, + unitPrice: st.kg > 0 ? st.fee / st.kg : 0, + amountYuan: st.kg > 0 ? row.kg * st.fee / st.kg : 0, + vehicleCount: 0, + })); + const endVol = vols.find((r) => r.date === endDate) || vols[vols.length - 1]; + return { + id: String(st.id), + name: st.name, + region: [st.province, st.city].filter(Boolean).join(' · '), + endKg: endVol?.quantityKg ?? 0, + endAmt: endVol?.amountYuan ?? 0, + endVehicles: st.recordCount, + rangeKg: st.kg, + rangeAmt: st.fee, + cashDays: st.paymentCount, + cashTotal: st.paymentAmount, + vols, + share: st.share, + }; + }); + return base + .filter((station) => isAllowedSingleStation(station.name)) + .sort((a, b) => b.rangeKg - a.rangeKg || a.name.localeCompare(b.name, 'zh-CN')); + }, [liveBoard, endDate]); + + // 自营站列表完整展示,点击其中一站再进入单站详情。 + const boardRows = overviewRows; + const stationIdsKey = overviewRows.map((row) => row.id).sort().join(','); + + // 总览请求的 summary.daily 是全站口径;单站现结日表必须另以真实 stationId 查询,不能按比例拆分。 + useEffect(() => { + if (!stationIdsKey) { + setStationCashBoard(null); + return; + } + let active = true; + setStationCashBoard(null); + setStationCashError(null); + Promise.all(stationIdsKey.split(',').map((id) => + fetchHydrogenStationBoard({ startDate, endDate, stationId: Number(id), force: tick > 0 }))) + .then((result) => { + if (active) setStationCashBoard(result); + }) + .catch((reason: unknown) => { + if (active) setStationCashError(reason instanceof Error ? reason.message : '当前站点现结流水加载失败'); + }); + return () => { active = false; }; + }, [stationIdsKey, startDate, endDate, tick]); + + const totals = useMemo(() => { + return { + rangeKg: boardRows.reduce((sum, row) => sum + row.rangeKg, 0), + rangeAmt: boardRows.reduce((sum, row) => sum + row.rangeAmt, 0), + cashTotal: boardRows.reduce((sum, row) => sum + row.cashTotal, 0), + endKg: boardRows.reduce((sum, row) => sum + row.endKg, 0), + endVehicles: boardRows.reduce((sum, row) => sum + row.endVehicles, 0), + }; + }, [boardRows]); + + /** 区间内按日汇总(全站 · 无量日补 0) */ + const dailyAgg = useMemo(() => { + const map = new Map(); + boardRows.forEach((station) => station.vols.forEach((row) => { + const current = map.get(row.date) ?? { date: row.date, kg: 0, amt: 0, vehicles: 0 }; + current.kg += row.quantityKg; + current.amt += row.amountYuan; + map.set(row.date, current); + })); + return calendarDays(startDate, endDate).map((date) => map.get(date) || { date, kg: 0, amt: 0, vehicles: 0 }); + }, [boardRows, startDate, endDate]); + + /** 当前选中站点的区间日现结;接口成功后才把无进账日期显式为 0。 */ + const cashDaily = useMemo(() => { + if (!stationCashBoard || stationCashBoard.some((board) => !board.selected)) return null; + const map = new Map(); + for (const board of stationCashBoard) { + for (const row of board.selected!.daily) map.set(row.date, (map.get(row.date) ?? 0) + row.paymentAmount); + } + return calendarDays(startDate, endDate).map((date) => ({ date, amount: map.get(date) || 0 })); + }, [stationCashBoard, startDate, endDate]); + + const handleRefresh = () => { + setTick((n) => n + 1); + }; + + const openStation = (id: string) => setDrillStationId(id); + + if (drillStationId) { + const detail = ( + setDrillStationId(null)} + onRefresh={handleRefresh} + onRangeChange={({ start, end }) => { + setStartDate(start); + setEndDate(end); + }} + /> + ); + if (embedded) { + return
{detail}
; + } + return ( +
+
{detail}
+
+ ); + } + + const hasCash = boardRows.some((r) => r.cashDays > 0); + const dayCount = calendarDays(startDate, endDate).length; + + const cockpit = ( + <> +
+ {!embedded ? ( +
+

羚牛氢能 · 加氢站经营

+

加氢站日报

+

最后更新时间 {updatedAt}

+
+ ) : null} + {!embedded ?
+ { + setStartDate(start); + setEndDate(end); + }} + /> + +
: null} +
+ +
+
+
+ 经营概览 + {startDate} 至 {endDate} +
+ {boardRows.length} 个站点 +
+
+ 数据来源:加氢业务账本;现结金额来自加氢站收款流水 +
+
+ +
+
统计金额¥{money(totals.rangeAmt)}
+ +
+
+
+ +
+
+
加氢站
+
{boardRows.length}
+
统计站点数
+
来源:加氢业务账本(保留范围内无加氢记录的站点)
区间:{startDate} 至 {endDate}
+
+ + + +
+ + {liveLoading && !liveBoard ? ( +
+ + 正在读取单站真实统计数据 + 加载完成前不展示业务零值 +
+ ) : liveLoading ?
正在更新,暂时保留上一份有效数据…
: null} + {liveError ?
单站统计加载失败:{liveError}
: null} + +
+
+

站点经营概况

+ + + + 全部自营站 · {boardRows.length} 站 +
+ +
+ {boardRows.map((r) => ( + + ))} +
+
+ + {kpiDrill ? ( +
+ +
+
+ {kpiDrill === 'cashDays' && cashDaily ? ( + + + + + + + + + {cashDaily.map((d) => ( + + + + + ))} + +
日期现结金额(元)
{d.date}{money(d.amount)}
+ ) : kpiDrill === 'cashDays' ? ( +
+ {stationCashError ? `当前站点现结流水加载失败:${stationCashError}` : '正在读取当前站点现结流水…'} +
+ ) : ( + + + + + + + + + + + {dailyAgg.map((d) => ( + + + + + + + ))} + +
日期加氢车次加氢量(Kg)加氢金额(元)
{d.date}{d.vehicles}{kg(d.kg)}{money(d.amt)}
+ )} +
+
+ + ) : null} + + ); + + if (embedded) { + return
{cockpit}
; + } + + return ( +
+
{cockpit}
+
+ ); +}; diff --git a/src/modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx b/src/modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx new file mode 100644 index 0000000..983cd83 --- /dev/null +++ b/src/modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx @@ -0,0 +1,884 @@ +// @ts-nocheck — verbatim 8113 prototype source; runtime DOM/CSS is intentionally preserved. +/** + * 单站日报明细 · 对齐汇报 Excel 精简表集 + * 近7日红涨绿跌 · 月环比同色 · 列表最多10条可展开 + */ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { ArrowLeft, ChevronDown, ChevronUp, Download, RefreshCw } from 'lucide-react'; +import { exportAoaSheet } from '../../../../shared/xlsx'; +import { MobileListFullscreenButton } from '../common/MobileListFullscreenButton'; +import { + SPOT_PAY_METHOD_LABEL, + type StationCashIntakeDay, +} from '../common/energy-spot-cash-intake/index'; +import { fetchHydrogenStationBoard } from '../../api'; +import { fetchAllH2BiDrillRecords } from '../api'; +import { PrototypeDrillModal } from '../drill/prototype-real-drills'; +import { MobileDailyList, MobileCustomerMonthList } from './StationMobileLists'; +import type { HydrogenStationBoardResponse } from '../../types'; +import { + monthTotals, + stationTrendDateLabel, +} from './data/mockStationDaily'; +import { SdCustomerMultiSelect } from './SdCustomerMultiSelect'; +import { SdDateRangePicker } from './SdDateRangePicker'; +import { customerMonthLabel, customerMonthRange, dateRangeLabel } from './station-month-range'; + +const ROW_LIMIT = 10; + +function money(n: number) { + return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function kg(n: number) { + return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function padYmd(raw: string) { + const parts = String(raw || '').split(/[-/]/); + if (parts.length < 3) return raw; + const y = parts[0]; + const m = String(Number(parts[1]) || 0).padStart(2, '0'); + const d = String(Number(parts[2]) || 0).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +/** 股市色:升红 · 降绿 · 平灰 */ +function stockDeltaClass(curr: number, prev: number | null | undefined): string { + if (prev == null || !Number.isFinite(prev)) return ''; + if (curr > prev) return 'is-stock-up'; + if (curr < prev) return 'is-stock-down'; + return 'is-stock-flat'; +} + +/** 普通业务数值不使用涨跌色;仅零值弱化、负余额标记风险。 */ +function businessValueClass(n: number, negativeIsRisk = false): string { + if (n === 0) return 'is-zero'; + if (negativeIsRisk && n < 0) return 'is-risk-negative'; + return ''; +} + +function DeltaMark({ curr, prev }: { curr: number; prev: number | null | undefined }) { + if (prev == null || !Number.isFinite(prev) || curr === prev) return null; + return ( + + {curr > prev ? '▲' : '▼'} + + ); +} + +function MoreToggle({ + expanded, + total, + onToggle, +}: { + expanded: boolean; + total: number; + onToggle: () => void; +}) { + if (total <= ROW_LIMIT) return null; + return ( + + ); +} + +export const StationDailyDetailView: React.FC<{ + stationId: string; + asOf: string; + rangeStart: string; + rangeEnd: string; + updatedAt: string; + onBack: () => void; + onRefresh: () => void; + onRangeChange: (next: { start: string; end: string }) => void; +}> = ({ stationId, asOf, rangeStart, rangeEnd, updatedAt, onBack, onRefresh, onRangeChange }) => { + const [cashTick, setCashTick] = useState(0); + const [selectedCustomers, setSelectedCustomers] = useState([]); + const [hoverDate, setHoverDate] = useState(null); + const [expandCust, setExpandCust] = useState(false); + const [customerMonthlyMetric, setCustomerMonthlyMetric] = useState<'volume' | 'fee'>('volume'); + const [expandBal, setExpandBal] = useState(false); + const [expandCash, setExpandCash] = useState(false); + const [mobileDetailTab, setMobileDetailTab] = useState<'daily' | 'customer' | 'balance' | 'cash'>('daily'); + const [mobileMonthKey, setMobileMonthKey] = useState(''); + const [orderDate, setOrderDate] = useState(null); + const [liveBoard, setLiveBoard] = useState(null); + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(null); + const exportController = useRef(null); + const [liveError, setLiveError] = useState(null); + const [liveLoading, setLiveLoading] = useState(true); + + useEffect(() => { + let active = true; + exportController.current?.abort(); + exportController.current = null; + setExporting(false); + setExportError(null); + setOrderDate(null); + setLiveError(null); + setLiveLoading(true); + // 新的站点或日期范围不能沿用旧响应;否则月列已变而数值仍属上一查询。 + setLiveBoard(null); + fetchHydrogenStationBoard({ startDate: rangeStart, endDate: rangeEnd, stationId: Number(stationId), force: cashTick > 0 }).then((board) => { + if (!active) return; + setLiveBoard(board); + setLiveLoading(false); + }).catch((reason: unknown) => { + if (!active) return; + setLiveError(reason instanceof Error ? reason.message : '站点详情加载失败'); + setLiveLoading(false); + }); + return () => { active = false; exportController.current?.abort(); }; + }, [stationId, rangeStart, rangeEnd, updatedAt, cashTick]); + + const liveStation = liveBoard?.stations.find((station) => String(station.id) === String(stationId)); + const stationName = liveStation?.name || stationId; + const region = liveStation ? [liveStation.province, liveStation.city].filter(Boolean).join(' · ') : ''; + + const startDate = rangeStart; + const end = rangeEnd; + // 接口口径:截至查询结束日的近 12 个月;缺失月只补展示槽位,不新增业务数据。 + const customerMonthKeys = useMemo(() => customerMonthRange(end), [end]); + + useEffect(() => { + const latestMonth = customerMonthKeys[customerMonthKeys.length - 1] ?? ''; + if (!customerMonthKeys.includes(mobileMonthKey)) setMobileMonthKey(latestMonth); + }, [customerMonthKeys, mobileMonthKey]); + + const volumeRows = useMemo( + () => (liveBoard?.selected?.daily ?? []).map((row) => ({ + date: row.date, + stationId, + quantityKg: row.kg, + unitPrice: row.avgPrice, + amountYuan: row.fee, + vehicleCount: row.recordCount, + })), + [liveBoard, stationId], + ); + + /** 近 7 日(按日期升序取末 7 条) */ + const volume7 = useMemo(() => { + const sorted = [...volumeRows].sort((a, b) => a.date.localeCompare(b.date)); + return sorted.slice(-7); + }, [volumeRows]); + + const volume7Kg = volume7.reduce((s, r) => s + r.quantityKg, 0); + const volume7Amt = volume7.reduce((s, r) => s + r.amountYuan, 0); + const volume7Vehicles = volume7.reduce((s, r) => s + r.vehicleCount, 0); + + const asOfVolume = volumeRows.find((r) => r.date === asOf) || volumeRows[volumeRows.length - 1]; + const rangeKg = volumeRows.reduce((s, r) => s + r.quantityKg, 0); + const rangeAmt = volumeRows.reduce((s, r) => s + r.amountYuan, 0); + const monthKg = volumeRows + .filter((r) => r.date.startsWith(asOf.slice(0, 7))) + .reduce((s, r) => s + r.quantityKg, 0); + + const prevDayKg = useMemo(() => { + if (!asOfVolume) return null; + const sorted = [...volumeRows].sort((a, b) => a.date.localeCompare(b.date)); + const idx = sorted.findIndex((r) => r.date === asOfVolume.date); + return idx > 0 ? sorted[idx - 1].quantityKg : null; + }, [asOfVolume, volumeRows]); + + const customerMonthRows = liveBoard?.selected?.customerMonths; + const allCustCells = useMemo( + () => { + const byCustomer = new Map>(); + for (const row of customerMonthRows ?? []) { + const months = byCustomer.get(row.customerName) ?? {}; + const monthKey = String(row.month).slice(0, 7); + months[monthKey] = (months[monthKey] ?? 0) + row.kg; + byCustomer.set(row.customerName, months); + } + return [...byCustomer].map(([customerName, months]) => ({ stationId, customerName, months })); + }, + [customerMonthRows, stationId], + ); + const customerOptions = useMemo( + () => allCustCells.map((c) => c.customerName), + [allCustCells], + ); + + const custCells = useMemo(() => { + if (selectedCustomers.length === 0) return allCustCells; + const set = new Set(selectedCustomers); + return allCustCells.filter((c) => set.has(c.customerName)); + }, [allCustCells, selectedCustomers]); + + const allFeeCells = useMemo(() => { + const byCustomer = new Map>(); + for (const row of customerMonthRows ?? []) { + const months = byCustomer.get(row.customerName) ?? {}; + const monthKey = String(row.month).slice(0, 7); + months[monthKey] = (months[monthKey] ?? 0) + row.fee; + byCustomer.set(row.customerName, months); + } + return [...byCustomer].map(([customerName, months]) => ({ stationId, customerName, months })); + }, [customerMonthRows, stationId]); + const feeCells = useMemo(() => { + const allowed = new Set(custCells.map((row) => row.customerName)); + return allFeeCells.filter((row) => allowed.has(row.customerName)); + }, [custCells, allFeeCells]); + const kgMonthTot = useMemo(() => monthTotals(custCells, customerMonthKeys), [custCells, customerMonthKeys]); + const feeMonthTot = useMemo(() => monthTotals(feeCells, customerMonthKeys), [feeCells, customerMonthKeys]); + + const custVisible = expandCust ? custCells : custCells.slice(0, ROW_LIMIT); + const feeVisible = expandCust ? feeCells : feeCells.slice(0, ROW_LIMIT); + + const cashDays: StationCashIntakeDay[] = useMemo(() => { + void cashTick; + return (liveBoard?.selected?.daily ?? []).filter((row) => row.paymentAmount > 0).map((row) => ({ + id: `payment-${stationId}-${row.date}`, + stationId, + stationName, + bizDate: row.date, + totalAmount: row.paymentAmount, + lines: [{ id: `payment-line-${stationId}-${row.date}`, customerName: '站点现结汇总', amount: row.paymentAmount, payMethod: 'other' }], + updatedBy: '只读账本', + updatedAt, + })); + }, [liveBoard, stationId, stationName, updatedAt, cashTick]); + + const cashLines = useMemo( + () => + (liveBoard?.selected?.externalReceipts?.rows ?? []).map((row) => ({ + ...row, bizDate: row.date, + sourceLabel: row.source === 'spot_daily_auto' ? '现结自动汇总' + : row.source === 'external_recharge_manual' ? '手工充值' : `未知来源(${row.source})`, + payLabel: row.payMethod === 'corporate' ? '对公转账' : row.payMethod === 'wechat' ? '微信' : row.payMethod || '未注明', + })), + [liveBoard], + ); + const receiptTotal = cashLines.reduce((sum, row) => sum + row.amount, 0); + const cashVisible = expandCash ? cashLines : cashLines.slice(0, ROW_LIMIT); + + const cashTotal = cashDays.reduce((s, d) => s + d.totalAmount, 0); + const balanceRows = useMemo( + () => [], + [], + ); + const balVisible = expandBal ? balanceRows : balanceRows.slice(0, ROW_LIMIT); + const balanceSubtotal = useMemo( + () => ({ + recharge: balanceRows.reduce((s, b) => s + b.rechargeOrSpotYuan, 0), + prepaid: balanceRows.reduce((s, b) => s + b.consumePrepaidYuan, 0), + spot: balanceRows.reduce((s, b) => s + b.consumeSpotYuan, 0), + balance: balanceRows.reduce((s, b) => s + b.balanceYuan, 0), + }), + [balanceRows], + ); + const maxKg = Math.max(...volumeRows.map((r) => r.quantityKg), 1); + const hoverRow = hoverDate ? volumeRows.find((r) => r.date === hoverDate) : null; + + const handleExport = async () => { + if (liveLoading || liveError || !liveBoard || exportController.current) return; + const controller = new AbortController(); + exportController.current = controller; + setExporting(true); + setExportError(null); + try { + if (!liveBoard?.selected?.customerMonths || !liveBoard?.selected?.externalReceipts) { + throw new Error('客户数据未完整返回,暂不导出,请刷新后重试'); + } + const result = await fetchAllH2BiDrillRecords({ + year: Number(rangeStart.slice(0, 4)), startDate: rangeStart, endDate: rangeEnd, + vehicleScope: 'all', verifyScope: 'all', stationId, + }, { signal: controller.signal }); + if (controller.signal.aborted) return; + const vehicleRows = result.records.map((row) => ({ + date: String(row.time ?? '').slice(0, 10), + plateNo: String(row.plateNo ?? '未关联车牌'), + customerName: String(row.customerName ?? '未关联客户'), + fleet: row.vehicleScope === 'lingniu' ? 'own' : 'external', + quantityKg: Number(row.kg) || 0, + unitPrice: Number(row.unitPrice) || 0, + amountYuan: Number(row.cost) || 0, + })); + const monthHeads = customerMonthKeys.map(customerMonthLabel); + const aoa: (string | number)[][] = [ + [`${stationName} · 站日报(查询日期 ${asOf})`], + ['统计区间', `${startDate} 至 ${end}`], + ['最后更新时间', updatedAt], + [], + ['近7日加氢量'], + ['日期', '加氢量(Kg)', '单价', '金额(元)', '车次'], + ...volume7.map((r) => [r.date, r.quantityKg, r.unitPrice, r.amountYuan, r.vehicleCount]), + [], + ['客户月加氢量(Kg)'], + ['客户', ...monthHeads], + ...custCells.map((c) => [ + c.customerName, + ...customerMonthKeys.map((m) => c.months[m] ?? 0), + ]), + [], + ['客户月加氢费(元)'], + ['客户', ...monthHeads], + ...feeCells.map((c) => [ + c.customerName, + ...customerMonthKeys.map((m) => c.months[m] ?? 0), + ]), + [], + ['客户氢费收支汇总'], + ['客户', '充值/现金结算', '扣预付消费', '现结消费', '余额', '备注'], + ...balanceRows.map((b) => [ + b.customerName, + b.rechargeOrSpotYuan, + b.consumePrepaidYuan, + b.consumeSpotYuan, + b.balanceYuan, + b.remark || '', + ]), + ['未接入完整账户余额,不以零代替缺失数据'], + [], + ['外部客户进账(当前租户全部外部客户,不按站点归属;不计入单站收益)'], + ['日期', '客户', '付款方式', '金额(元)', '来源', '源记录数', '刷新时间', '记录ID'], + ...cashLines.map((l) => [l.bizDate, l.customerName, l.payLabel, l.amount, l.sourceLabel, l.sourceRecordCount, l.updatedAt ?? '', l.id]), + [], + ['自营站全部车辆客户加氢月度(不含手工充值)'], + ['月份', '客户', '加氢量(Kg)', '加氢金额(元)', '记录数'], + ...(liveBoard?.selected?.customerMonths ?? []).map((r) => [r.month, r.customerName, r.kg, r.fee, r.recordCount]), + [], + ['车辆加氢明细(查询区间全部真实账本记录)', result.records.length], + ['日期', '车牌', '客户', '归属', '加氢量(Kg)', '单价', '金额(元)'], + ...vehicleRows.map((r) => [ + r.date, + r.plateNo, + r.customerName, + r.fleet === 'own' ? '羚牛车辆' : '外部车辆', + r.quantityKg, + r.unitPrice, + r.amountYuan, + ]), + ]; + exportAoaSheet(aoa, `站日报取证_${stationName}_查询${asOf}.xlsx`, '站日报取证'); + } catch (reason) { + if (!controller.signal.aborted) setExportError(reason instanceof Error ? reason.message : '导出失败,请重试'); + } finally { + if (exportController.current === controller) { + exportController.current = null; + setExporting(false); + } + } + }; + + const renderMonthCells = ( + months: Record, + fmt: (n: number) => string, + ) => + customerMonthKeys.map((m, i) => { + const curr = months[m] ?? 0; + const prevKey = i > 0 ? customerMonthKeys[i - 1] : null; + const prev = prevKey != null ? (months[prevKey] ?? 0) : null; + const cls = stockDeltaClass(curr, prev); + const zeroClass = curr === 0 ? 'is-zero' : ''; + const currentMonthClass = + i === customerMonthKeys.length - 1 ? 'is-current-month' : ''; + return ( + + {fmt(curr)} + + + ); + }); + + if (liveLoading || liveError) { + return ( +
+
+
+ +
+

{stationName}

+

{startDate} 至 {end}

+
+
+
+
+ {liveError ? `站点详情加载失败:${liveError}` : '正在读取当前站点真实统计数据,完成前不展示业务零值'} +
+
+ ); + } + + return ( +
+
+
+ +
+

{stationName}

+

+ {region ? `${region} · ` : ''} + {startDate} 至 {end} +

+

最后更新时间 {updatedAt}

+
+
+
+ + + + {exporting ? : null} +
+
+ {exportError ?
导出失败:{exportError}。未生成文件,请缩小日期范围或重试。
: null} + + {liveError ? ( +
站点详情加载失败:{liveError}
+ ) : null} + +
+
+
当日加氢
+
+ {asOfVolume ? kg(asOfVolume.quantityKg) : '0.00'} + Kg + +
+
+ {asOfVolume + ? `¥${money(asOfVolume.amountYuan)} · ${asOfVolume.vehicleCount} 车次` + : '¥0.00 · 0 车次'} +
+
+
+
查询区间加氢
+
+ {kg(rangeKg)} + Kg +
+
¥{money(rangeAmt)} · {dateRangeLabel(startDate, end)}
+
+
+
区间内本月加氢
+
+ {kg(monthKg)} + Kg +
+
{asOf.slice(0, 7)} · 仅计入查询区间
+
+
+
单站现结流水
+
+ {money(cashTotal)} + +
+
+ {cashDays.length ? `${cashDays.length} 天有进账 · ${dateRangeLabel(startDate, end)}` : `0 天有进账 · ${dateRangeLabel(startDate, end)}`} +
不含客户级充值/进账 +
+
+
+ +
+
+
+ 经营明细 + +
+
+ {([ + ['daily', '日加氢'], + ['customer', '客户月度'], + ['balance', '收支'], + ['cash', '进账'], + ] as const).map(([key, label]) => ( + + ))} +
+
+ +
+

加氢站每日加氢量汇总(近 7 日)

+ +
+ + + + + + + + + + + + + + + + + + + + + {volume7.length === 0 ? ( + + + + ) : ( + volume7.map((r, i) => { + const prev = i > 0 ? volume7[i - 1].quantityKg : null; + // 较昨日:用完整序列前一日更准 + const fullIdx = volumeRows.findIndex((x) => x.date === r.date); + const prevFull = + fullIdx > 0 ? volumeRows[fullIdx - 1].quantityKg : prev; + const diff = prevFull == null ? null : r.quantityKg - prevFull; + const cls = stockDeltaClass(r.quantityKg, prevFull); + return ( + + + + + + + + + ); + }) + )} + +
日期加氢量(Kg)较昨日单价金额(元)车次
近 7 日合计{kg(volume7Kg)}00{money(volume7Amt)}{volume7Vehicles}
+ 本窗暂无加氢量 +
{padYmd(r.date)} + {kg(r.quantityKg)} + + + {diff == null ? '0.00' : `${diff > 0 ? '+' : ''}${kg(diff)}`} + {r.unitPrice}{money(r.amountYuan)}{r.vehicleCount}
+
+
+ +
+
+

区间加氢量趋势

+ + + {stationName} + +
+
setHoverDate(null)} + > + {volumeRows.length === 0 ? ( +
+ 本窗暂无趋势 +
+ ) : ( + volumeRows.map((r) => ( +
setHoverDate(r.date)} + > +
{r.quantityKg.toFixed(0)}
+
+
+
+
+ {stationTrendDateLabel(r.date)} + {stationTrendDateLabel(r.date).slice(5)} +
+
+ )) + )} + {hoverRow ? ( +
+
{padYmd(hoverRow.date)}
+
+ + {stationName} + + {kg(hoverRow.quantityKg)} Kg · ¥{money(hoverRow.amountYuan)} + +
+
{hoverRow.vehicleCount} 车次
+
+ ) : null} +
+
+ +
+
+
+

客户月度汇总(近 12 个月)

+
+ + +
+
+ +
+

自营站全部车辆与客户,包含羚牛及外部车辆;未关联客户保留统计,不含手工充值。

+ +
+ + + + + {customerMonthKeys.map((m, i) => ( + + ))} + + + + + + {customerMonthlyMetric === 'volume' + ? renderMonthCells(kgMonthTot, kg) + : renderMonthCells(feeMonthTot, money)} + + {(customerMonthlyMetric === 'volume' ? custCells : feeCells).length === 0 ? ( + + + + ) : ( + (customerMonthlyMetric === 'volume' ? custVisible : feeVisible).map((c) => ( + + + {renderMonthCells(c.months, customerMonthlyMetric === 'volume' ? kg : money)} + + )) + )} + +
客户 + {customerMonthLabel(m)} +
合计
+ {!customerMonthRows ? '客户数据暂不可用' : '无匹配客户'} +
{c.customerName}
+
+
+ + + + {custVisible.map((customer) => { + const feeCustomer = feeCells.find((item) => item.customerName === customer.customerName); + const volumeValue = customer.months[mobileMonthKey] ?? 0; + const feeValue = feeCustomer?.months[mobileMonthKey] ?? 0; + return ; + })} + +
客户加氢量(Kg)加氢费(元)
{customer.customerName}{kg(volumeValue)}{money(feeValue)}
+
+ setExpandCust((v) => !v)} + /> +
+ +
+
+

客户氢费收支汇总

+

完整账户余额未接入:新进账表不能单独推算余额或经营利润。

+
+ {balVisible.map((customer) => ( +
+
{customer.customerName}{customer.remark || '账户正常'}
+
¥{money(customer.balanceYuan)}余额
+
充值 ¥{money(customer.rechargeOrSpotYuan)} · 扣预付 ¥{money(customer.consumePrepaidYuan)} · 现结 ¥{money(customer.consumeSpotYuan)}
+
+ ))} +
+
+ + + + + + + + + + + + + {balanceRows.length === 0 ? ( + + + + ) : ( + <> + {balVisible.map((b) => ( + + + + + + + + + ))} + + + + + + + + + )} + +
客户充值/现金结算扣预付现结余额备注
+ 暂无收支汇总 +
{b.customerName} + {money(b.rechargeOrSpotYuan)} + + {money(b.consumePrepaidYuan)} + + {money(b.consumeSpotYuan)} + + {money(b.balanceYuan)} + {b.remark || ''}
小计 + {money(balanceSubtotal.recharge)} + {money(balanceSubtotal.prepaid)}{money(balanceSubtotal.spot)} + {money(balanceSubtotal.balance)} + +
+
+ setExpandBal((v) => !v)} /> +
+ +
+
+

外部客户充值/现结进账

+ {liveBoard?.selected?.externalReceipts ? `合计 ¥${money(receiptTotal)} · ${cashLines.length} 条` : '金额暂不可用'} +
+

当前租户全部外部客户,不按站点归属。现结自动汇总已包含在加氢业务中,不重复计入单站现结或收益;充值也不等于利润。

+ {!cashLines.length ?

{liveBoard?.selected?.externalReceipts ? '查询区间暂无客户进账记录' : '客户进账数据暂不可用:接口未返回新数据,请刷新或检查服务版本。'}

: null} +
+ {cashVisible.map((line) => ( +
+
{line.customerName}{line.bizDate}
+
¥{money(line.amount)}{line.payLabel}
+
{line.sourceLabel} · {line.sourceRecordCount} 条源记录{line.updatedAt ? ` · 更新 ${line.updatedAt}` : ''}
+
+ ))} +
+
+ + + + + + + + + + + + {cashLines.length === 0 ? ( + + + + ) : ( + cashVisible.map((l) => ( + + + + + + + + )) + )} + +
充值日期客户付款方式来源 / 源记录数金额(元)
+ {liveBoard?.selected?.externalReceipts ? '本窗暂无进账明细' : '客户进账数据暂不可用'} +
{l.bizDate}{l.customerName}{l.payLabel}{l.sourceLabel} / {l.sourceRecordCount}{money(l.amount)}
+
+ setExpandCash((v) => !v)} /> +
+
+
+ {orderDate ? setOrderDate(null)} /> : null} +
+ ); +}; diff --git a/src/modules/energy/hydrogen/station-daily/StationMobileLists.tsx b/src/modules/energy/hydrogen/station-daily/StationMobileLists.tsx new file mode 100644 index 0000000..6d9ac35 --- /dev/null +++ b/src/modules/energy/hydrogen/station-daily/StationMobileLists.tsx @@ -0,0 +1,104 @@ +import React, { useMemo, useState } from 'react'; +import './station-mobile-lists.css'; +import { + customerMonthTotal, + customerMetricValue, + formatKg, + formatMoney, + mobileCustomerRows, + mobileDailyRows, + monthLabel, + type CustomerMetric, + type StationDailyVolumeRow, + type StationMonthlyCustomer, +} from './station-mobile-list-model'; + +export type { CustomerMetric, StationDailyVolumeRow, StationMonthlyCustomer } from './station-mobile-list-model'; + +export interface MobileDailyListProps { + /** The selected near-seven-day rows; `allRows` remains the source for actual prior-day comparison. */ + rows: StationDailyVolumeRow[]; + allRows: StationDailyVolumeRow[]; + loading?: boolean; + error?: string | null; + onOpenOrders: (date: string) => void; +} + +export function MobileDailyList({ rows, allRows, loading = false, error = null, onOpenOrders }: MobileDailyListProps) { + const dailyRows = useMemo(() => mobileDailyRows(rows, allRows), [rows, allRows]); + if (loading) return
日报正在加载…
; + if (error) return
日报加载失败:{error}
; + if (!dailyRows.length) return
暂无日报数据
; + + return
+

最新日期在前 · 点日期展开车次、单价和订单

+
日期加氢量金额
+
+ {dailyRows.map((row) => { + const hasRecord = row.vehicleCount > 0; + const delta = row.previousKg == null ? '无前日数据' : `${row.quantityKg - row.previousKg >= 0 ? '+' : ''}${formatKg(row.quantityKg - row.previousKg)} kg`; + return
+ + {row.date.slice(5)}{row.date.slice(0, 4)} + {formatKg(row.quantityKg)}kg + ¥{formatMoney(row.amountYuan)}{!hasRecord && 无记录} + +
+ 车次:{row.vehicleCount} 次单价:¥{formatMoney(row.unitPrice)}/kg较昨日:{delta} + +
+
; + })} +
+
; +} + +export interface MobileCustomerMonthListProps { + /** Continuous YYYY-MM values, including months with no business records. */ + months: string[]; + volumeCustomers: StationMonthlyCustomer[]; + feeCustomers: StationMonthlyCustomer[]; + month: string; + onMonthChange: (month: string) => void; + metric: CustomerMetric; + onMetricChange: (metric: CustomerMetric) => void; + loading?: boolean; + error?: string | null; +} + +export function MobileCustomerMonthList({ months, volumeCustomers, feeCustomers, month, onMonthChange, metric, onMetricChange, loading = false, error = null }: MobileCustomerMonthListProps) { + const [search, setSearch] = useState(''); + const customers = metric === 'volume' ? volumeCustomers : feeCustomers; + const sortedCustomers = useMemo(() => mobileCustomerRows(customers, month, search), [customers, month, search]); + const total = useMemo(() => customerMonthTotal(customers, month), [customers, month]); + if (loading) return
客户月度数据正在加载…
; + if (error) return
客户月度加载失败:{error}
; + + return
+
+ +
+ + +
+ {monthLabel(month)}合计:{metric === 'volume' ? `${formatKg(total)} kg` : `¥${formatMoney(total)}`} + setSearch(event.target.value)} placeholder="搜索全部客户" aria-label="搜索全部客户" /> +
+

点客户展开近 12 个月 · 合计不随搜索缩减

+ {!sortedCustomers.length ?

{search ? '未找到匹配客户' : '该月暂无客户数据'}

:
+ {sortedCustomers.map((customer) =>
+ {customer.customerName}{metric === 'volume' ? `${formatKg(customerMetricValue(customer, month))} kg` : `¥${formatMoney(customerMetricValue(customer, month))}`} +
+ {months.map((item) => { + const value = customerMetricValue(customer, item); + const max = Math.max(...months.map((key) => customerMetricValue(customer, key)), 0); + const width = max > 0 ? `${(value / max) * 100}%` : '0%'; + return
{item}{metric === 'volume' ? `${formatKg(value)} kg` : `¥${formatMoney(value)}`}
; + })} +
+
)} +
} +
; +} diff --git a/src/modules/energy/hydrogen/station-daily/data/mockStationDaily.ts b/src/modules/energy/hydrogen/station-daily/data/mockStationDaily.ts new file mode 100644 index 0000000..92f61c8 --- /dev/null +++ b/src/modules/energy/hydrogen/station-daily/data/mockStationDaily.ts @@ -0,0 +1,3530 @@ +/** + * 站日报 · 量侧 Mock(仅本尊提供的 Excel 实站,禁造站) + * - 佛山南海:杨凤娥《羚牛佛山南海加氢站明细(普特)-截止8.11.xlsx》 + * - 东鹏大道:金可鹏等《加氢记录-东鹏大道站-20260802.xlsx》 + */ + +export interface StationDailyVolumeRow { + stationId: string; + date: string; + shortDate: string; + quantityKg: number; + unitPrice: number; + amountYuan: number; + vehicleCount: number; +} + +export interface StationCustomerMonthCell { + stationId: string; + customerName: string; + months: Record; +} + +export interface StationVehicleDetailRow { + stationId: string; + date: string; + plateNo: string; + quantityKg: number; + unitPrice: number; + amountYuan: number; + customerName: string; + fleet: 'own' | 'external'; +} + +export const STATION_DAILY_STATIONS = [ + { id: 'st-fs-nanhai', name: '佛山南海羚牛加氢站', region: '广东佛山' }, + { id: 'st-dp-dongpeng', name: '东鹏大道甲醇制氢一体站', region: '广东广州' }, +] as const; + +const FS = 'st-fs-nanhai'; +const DP = 'st-dp-dongpeng'; + +/** 客户月矩阵月份列(南海 Excel 列;缺 5 月) */ +export const STATION_DAILY_MONTH_KEYS = ['9', '10', '11', '12', '1', '2', '3', '4', '6', '7', '8'] as const; + +/** 月份列展示:跨年时带年份,避免只看「9月」混乱 */ +export function stationMonthLabel(monthKey: string, asOfYmd: string): string { + const asOfYear = Number(asOfYmd.slice(0, 4)); + const m = Number(monthKey); + if (!Number.isFinite(asOfYear) || !Number.isFinite(m)) return `${monthKey}月`; + const year = m >= 9 ? asOfYear - 1 : asOfYear; + return `${year}年${m}月`; +} + +/** 趋势轴日期:始终带年 */ +export function stationTrendDateLabel(ymd: string): string { + return ymd; // YYYY-MM-DD +} + +/** 默认统计截止:南海 Excel 最新日;东鹏大道最新日为 2026-07-31,改截止日可看 */ +export const STATION_DAILY_AS_OF = '2026-08-11'; + +export const MOCK_STATION_VOLUME_10D: StationDailyVolumeRow[] = [ + { stationId: FS, date: '2026-08-02', shortDate: '08-02', quantityKg: 79.27, unitPrice: 38, amountYuan: 3012.26, vehicleCount: 14 }, + { stationId: FS, date: '2026-08-03', shortDate: '08-03', quantityKg: 84.16, unitPrice: 38, amountYuan: 3198.08, vehicleCount: 16 }, + { stationId: FS, date: '2026-08-04', shortDate: '08-04', quantityKg: 90.04, unitPrice: 38, amountYuan: 3421.52, vehicleCount: 21 }, + { stationId: FS, date: '2026-08-05', shortDate: '08-05', quantityKg: 128.68, unitPrice: 38, amountYuan: 4889.84, vehicleCount: 24 }, + { stationId: FS, date: '2026-08-06', shortDate: '08-06', quantityKg: 58.77, unitPrice: 38, amountYuan: 2233.38, vehicleCount: 16 }, + { stationId: FS, date: '2026-08-07', shortDate: '08-07', quantityKg: 69.58, unitPrice: 38, amountYuan: 2644.04, vehicleCount: 14 }, + { stationId: FS, date: '2026-08-08', shortDate: '08-08', quantityKg: 87.84, unitPrice: 38, amountYuan: 3337.92, vehicleCount: 16 }, + { stationId: FS, date: '2026-08-09', shortDate: '08-09', quantityKg: 76.46, unitPrice: 38, amountYuan: 2905.48, vehicleCount: 14 }, + { stationId: FS, date: '2026-08-10', shortDate: '08-10', quantityKg: 117.61, unitPrice: 38, amountYuan: 4469.18, vehicleCount: 23 }, + { stationId: FS, date: '2026-08-11', shortDate: '08-11', quantityKg: 91.39, unitPrice: 38, amountYuan: 3473.34, vehicleCount: 19 }, + { stationId: DP, date: '2026-06-18', shortDate: '06-18', quantityKg: 107.62, unitPrice: 38.5, amountYuan: 4143.37, vehicleCount: 15 }, + { stationId: DP, date: '2026-06-22', shortDate: '06-22', quantityKg: 15.86, unitPrice: 38.5, amountYuan: 610.61, vehicleCount: 3 }, + { stationId: DP, date: '2026-06-23', shortDate: '06-23', quantityKg: 85.45, unitPrice: 38.5, amountYuan: 3289.82, vehicleCount: 13 }, + { stationId: DP, date: '2026-06-24', shortDate: '06-24', quantityKg: 30.83, unitPrice: 38.5, amountYuan: 1186.95, vehicleCount: 5 }, + { stationId: DP, date: '2026-06-26', shortDate: '06-26', quantityKg: 11.88, unitPrice: 38.5, amountYuan: 457.38, vehicleCount: 2 }, + { stationId: DP, date: '2026-07-01', shortDate: '07-01', quantityKg: 186.05, unitPrice: 35, amountYuan: 6511.75, vehicleCount: 28 }, + { stationId: DP, date: '2026-07-02', shortDate: '07-02', quantityKg: 196.63, unitPrice: 35, amountYuan: 6882.05, vehicleCount: 29 }, + { stationId: DP, date: '2026-07-03', shortDate: '07-03', quantityKg: 162.0, unitPrice: 35, amountYuan: 5670.0, vehicleCount: 25 }, + { stationId: DP, date: '2026-07-04', shortDate: '07-04', quantityKg: 96.87, unitPrice: 35, amountYuan: 3390.45, vehicleCount: 14 }, + { stationId: DP, date: '2026-07-07', shortDate: '07-07', quantityKg: 198.57, unitPrice: 35, amountYuan: 6949.95, vehicleCount: 30 }, + { stationId: DP, date: '2026-07-08', shortDate: '07-08', quantityKg: 174.9, unitPrice: 35, amountYuan: 6121.5, vehicleCount: 21 }, + { stationId: DP, date: '2026-07-09', shortDate: '07-09', quantityKg: 178.46, unitPrice: 35, amountYuan: 6246.1, vehicleCount: 25 }, + { stationId: DP, date: '2026-07-10', shortDate: '07-10', quantityKg: 218.81, unitPrice: 35, amountYuan: 7658.35, vehicleCount: 31 }, + { stationId: DP, date: '2026-07-14', shortDate: '07-14', quantityKg: 216.79, unitPrice: 35, amountYuan: 7587.65, vehicleCount: 26 }, + { stationId: DP, date: '2026-07-15', shortDate: '07-15', quantityKg: 24.36, unitPrice: 35, amountYuan: 852.6, vehicleCount: 4 }, + { stationId: DP, date: '2026-07-16', shortDate: '07-16', quantityKg: 181.41, unitPrice: 35, amountYuan: 6349.35, vehicleCount: 26 }, + { stationId: DP, date: '2026-07-17', shortDate: '07-17', quantityKg: 102.84, unitPrice: 35, amountYuan: 3599.4, vehicleCount: 18 }, + { stationId: DP, date: '2026-07-18', shortDate: '07-18', quantityKg: 169.36, unitPrice: 35, amountYuan: 5927.6, vehicleCount: 22 }, + { stationId: DP, date: '2026-07-19', shortDate: '07-19', quantityKg: 113.23, unitPrice: 35, amountYuan: 3963.05, vehicleCount: 16 }, + { stationId: DP, date: '2026-07-20', shortDate: '07-20', quantityKg: 78.02, unitPrice: 35, amountYuan: 2730.7, vehicleCount: 14 }, + { stationId: DP, date: '2026-07-21', shortDate: '07-21', quantityKg: 85.03, unitPrice: 35, amountYuan: 2976.05, vehicleCount: 12 }, + { stationId: DP, date: '2026-07-22', shortDate: '07-22', quantityKg: 141.24, unitPrice: 35, amountYuan: 4943.4, vehicleCount: 22 }, + { stationId: DP, date: '2026-07-25', shortDate: '07-25', quantityKg: 172.26, unitPrice: 35, amountYuan: 6029.1, vehicleCount: 24 }, + { stationId: DP, date: '2026-07-27', shortDate: '07-27', quantityKg: 23.74, unitPrice: 35, amountYuan: 830.9, vehicleCount: 4 }, + { stationId: DP, date: '2026-07-28', shortDate: '07-28', quantityKg: 133.22, unitPrice: 35, amountYuan: 4662.7, vehicleCount: 21 }, + { stationId: DP, date: '2026-07-29', shortDate: '07-29', quantityKg: 59.82, unitPrice: 35, amountYuan: 2093.7, vehicleCount: 10 }, + { stationId: DP, date: '2026-07-30', shortDate: '07-30', quantityKg: 237.38, unitPrice: 35, amountYuan: 8308.3, vehicleCount: 33 }, + { stationId: DP, date: '2026-07-31', shortDate: '07-31', quantityKg: 68.14, unitPrice: 35, amountYuan: 2384.9, vehicleCount: 13 }, +]; + +export const MOCK_CUSTOMER_MONTH_KG: StationCustomerMonthCell[] = [ + { + stationId: FS, + customerName: "广东氢动力科技服务有限公司", + months: { '9': 33.72, '10': 191.13, '11': 320.52, '12': 567.44, '1': 541.85, '2': 414.8, '3': 440.41, '4': 275.51, '6': 49.46, '7': 134.56, '8': 66.49 }, + }, + { + stationId: FS, + customerName: "羚牛氢能科技(广东)有限公司", + months: { '9': 51.05, '10': 584.17, '11': 416.24, '12': 651.01, '1': 625.93, '2': 533.13, '3': 1217.31, '4': 2936.04, '6': 126.53, '7': 1342.7, '8': 313.7 }, + }, + { + stationId: FS, + customerName: "广州福满华冷链物流有限公司", + months: { '9': 5.72, '10': 25.44, '11': 15.85, '12': 16.36, '1': 24.56, '2': 17.79, '3': 5.67, '4': 2.39, '6': 3.08, '7': 14.87 }, + }, + { + stationId: FS, + customerName: "广东清运科技有限公司", + months: { '10': 79.11, '11': 215.94, '12': 191.48, '1': 234.09, '2': 259.59, '3': 175.99, '4': 183.54 }, + }, + { + stationId: FS, + customerName: "东展供应链(广州)有限公司", + months: { '10': 102.92, '11': 761.89, '12': 229.17, '1': 114.17, '2': 68.04, '3': 71.87, '4': 36.51, '6': 67.58, '7': 184.64, '8': 114.5 }, + }, + { + stationId: FS, + customerName: "昇美新能源有限公司", + months: { '11': 14.07, '2': 25.41, '7': 3.68 }, + }, + { + stationId: FS, + customerName: "佛山市南海腾威汽车贸易有限公司", + months: { '11': 222.44, '12': 4301.37, '1': 45.94 }, + }, + { + stationId: FS, + customerName: "广东云韬氢能科技有限公司", + months: { '12': 2363.47 }, + }, + { + stationId: FS, + customerName: "广东沣开科技有限公司", + months: { '1': 103.08, '2': 37.65, '3': 230.14, '4': 141.82, '6': 58.1, '7': 255.32, '8': 69.49 }, + }, + { + stationId: FS, + customerName: "现代氢能科技有限公司", + months: { '1': 107.73, '2': 179.28, '3': 206.92, '4': 564.29, '6': 90.56, '7': 504.95, '8': 300.29 }, + }, + { + stationId: FS, + customerName: "广东中氢联达新能源投资有限公司", + months: { '1': 1329.17, '2': 1620.28, '3': 241.11 }, + }, + { + stationId: FS, + customerName: "广州东逸物流有限公司", + months: { '2': 44.2 }, + }, + { + stationId: FS, + customerName: "广州铁语物流运输有限公司", + months: { '3': 66.18, '4': 62.0, '6': 33.81 }, + }, + { + stationId: FS, + customerName: "佛山市南海绿氢投资有限公司", + months: { '3': 7.14 }, + }, + { + stationId: FS, + customerName: "佛山市南海区瀚洁城市环境管理有限公司大沥分公司", + months: { '4': 13.43 }, + }, + { + stationId: FS, + customerName: "佛山市南海区狮山镇惠鑫绿色供应链有限公司", + months: { '3': 45.18, '4': 146.27 }, + }, + { + stationId: FS, + customerName: "广东开鸿氢能科技有限公司", + months: { '7': 85.52, '8': 37.34 }, + }, + { + stationId: FS, + customerName: "广东中氢顺答汽车科技有限公司", + months: { '8': 50.92 }, + }, + { + stationId: FS, + customerName: "广东瀚清能源有限公司", + months: { '8': 5.66 }, + }, + { + stationId: DP, + customerName: "羚牛氢能科技(广东)有限公司", + months: { '6': 146.81, '7': 1828.45 }, + }, + { + stationId: DP, + customerName: "广州市梅洛特物流有限公司", + months: { '6': 11.88, '7': 776.74 }, + }, + { + stationId: DP, + customerName: "广州新运多租赁有限公司", + months: { '6': 72.07, '7': 400.09 }, + }, + { + stationId: DP, + customerName: "广州星达供应链管理有限公司", + months: { '6': 20.88, '7': 90.16 }, + }, + { + stationId: DP, + customerName: "广州中味餐饮服务有限公司", + months: { '7': 88.06 }, + }, + { + stationId: DP, + customerName: "广州福满华冷链物流有限公司", + months: { '7': 35.62 }, + }, +]; + +export function customerMonthFeeFromKg(cells: StationCustomerMonthCell[]): StationCustomerMonthCell[] { + return cells.map((c) => { + const months: Record = {}; + for (const [k, v] of Object.entries(c.months)) { + months[k] = Math.round(v * 38 * 100) / 100; + } + return { stationId: c.stationId, customerName: c.customerName, months }; + }); +} + +export const MOCK_STATION_VEHICLE_DETAILS: StationVehicleDetailRow[] = [ + { + stationId: FS, + date: '2026-08-02', + plateNo: "粤AH80311", + quantityKg: 2.6, + unitPrice: 38, + amountYuan: 98.8, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-02', + plateNo: "粤EFH7893", + quantityKg: 0.34, + unitPrice: 38, + amountYuan: 12.92, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-02', + plateNo: "粤EFH7893", + quantityKg: 8.86, + unitPrice: 38, + amountYuan: 336.68, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-02', + plateNo: "粤EFD4833", + quantityKg: 8.04, + unitPrice: 38, + amountYuan: 305.52, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-02', + plateNo: "粤AHD5206", + quantityKg: 9.41, + unitPrice: 38, + amountYuan: 357.58, + customerName: "广东沣开科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-02', + plateNo: "粤AH95660", + quantityKg: 4.25, + unitPrice: 38, + amountYuan: 161.5, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-02', + plateNo: "粤AHD7219", + quantityKg: 7.17, + unitPrice: 38, + amountYuan: 272.46, + customerName: "广东开鸿氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-02', + plateNo: "粤AGR5056", + quantityKg: 6.0, + unitPrice: 38, + amountYuan: 228.0, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-02', + plateNo: "粤AH81683", + quantityKg: 8.57, + unitPrice: 38, + amountYuan: 325.66, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-02', + plateNo: "粤EFD4993", + quantityKg: 7.81, + unitPrice: 38, + amountYuan: 296.78, + customerName: "广东氢动力科技服务有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-02', + plateNo: "粤AHD7576", + quantityKg: 6.09, + unitPrice: 38, + amountYuan: 231.42, + customerName: "广东开鸿氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-02', + plateNo: "粤AGQ3028", + quantityKg: 2.27, + unitPrice: 38, + amountYuan: 86.26, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-02', + plateNo: "粤AGE1516", + quantityKg: 3.27, + unitPrice: 38, + amountYuan: 124.26, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-02', + plateNo: "粤AH81231", + quantityKg: 4.59, + unitPrice: 38, + amountYuan: 174.42, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-03', + plateNo: "粤AH81683", + quantityKg: 5.84, + unitPrice: 38, + amountYuan: 221.92, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-03', + plateNo: "粤AGQ8399", + quantityKg: 6.25, + unitPrice: 38, + amountYuan: 237.5, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-03', + plateNo: "粤EFF1075", + quantityKg: 1.64, + unitPrice: 38, + amountYuan: 62.32, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-03', + plateNo: "粤AH30131", + quantityKg: 7.47, + unitPrice: 38, + amountYuan: 283.86, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-03', + plateNo: "粤AHD8221", + quantityKg: 8.76, + unitPrice: 38, + amountYuan: 332.88, + customerName: "广东沣开科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-03', + plateNo: "粤AGR5056", + quantityKg: 5.41, + unitPrice: 38, + amountYuan: 205.58, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-03', + plateNo: "粤AH95660", + quantityKg: 5.59, + unitPrice: 38, + amountYuan: 212.42, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-03', + plateNo: "粤AH30108", + quantityKg: 8.39, + unitPrice: 38, + amountYuan: 318.82, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-03', + plateNo: "粤AH65622", + quantityKg: 5.86, + unitPrice: 38, + amountYuan: 222.68, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-03', + plateNo: "粤EFJ2488", + quantityKg: 7.56, + unitPrice: 38, + amountYuan: 287.28, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-03', + plateNo: "粤EFJ2488", + quantityKg: 0.32, + unitPrice: 38, + amountYuan: 12.16, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-03', + plateNo: "粤AGP2009", + quantityKg: 3.95, + unitPrice: 38, + amountYuan: 150.1, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-03', + plateNo: "粤AGQ3028", + quantityKg: 7.35, + unitPrice: 38, + amountYuan: 279.3, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-03', + plateNo: "粤AGE1516", + quantityKg: 4.22, + unitPrice: 38, + amountYuan: 160.36, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-03', + plateNo: "粤AGP5766", + quantityKg: 3.93, + unitPrice: 38, + amountYuan: 149.34, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-03', + plateNo: "粤EFK1052", + quantityKg: 1.62, + unitPrice: 38, + amountYuan: 61.56, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤AFG2090", + quantityKg: 5.66, + unitPrice: 38, + amountYuan: 215.08, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤AH95660", + quantityKg: 4.83, + unitPrice: 38, + amountYuan: 183.54, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "豫AHA9677", + quantityKg: 4.89, + unitPrice: 38, + amountYuan: 185.82, + customerName: "外省过路车", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤AH65622", + quantityKg: 5.02, + unitPrice: 38, + amountYuan: 190.76, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤EFF1075", + quantityKg: 2.17, + unitPrice: 38, + amountYuan: 82.46, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤EFK1052", + quantityKg: 1.62, + unitPrice: 38, + amountYuan: 61.56, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤AH81683", + quantityKg: 4.74, + unitPrice: 38, + amountYuan: 180.12, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤AGR5056", + quantityKg: 7.62, + unitPrice: 38, + amountYuan: 289.56, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤AH81691", + quantityKg: 3.94, + unitPrice: 38, + amountYuan: 149.72, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤EFD4878", + quantityKg: 0.33, + unitPrice: 38, + amountYuan: 12.54, + customerName: "广东氢动力科技服务有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤EFD4878", + quantityKg: 9.37, + unitPrice: 38, + amountYuan: 356.06, + customerName: "广东氢动力科技服务有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤AGW5861", + quantityKg: 8.42, + unitPrice: 38, + amountYuan: 319.96, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤EFP0270", + quantityKg: 1.93, + unitPrice: 38, + amountYuan: 73.34, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤AGQ8376", + quantityKg: 4.48, + unitPrice: 38, + amountYuan: 170.24, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤AGQ9555", + quantityKg: 4.59, + unitPrice: 38, + amountYuan: 174.42, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤EFQ3560", + quantityKg: 2.27, + unitPrice: 38, + amountYuan: 86.26, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤EFD4887", + quantityKg: 3.95, + unitPrice: 38, + amountYuan: 150.1, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤EFD4887", + quantityKg: 0.36, + unitPrice: 38, + amountYuan: 13.68, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤AGQ8399", + quantityKg: 5.41, + unitPrice: 38, + amountYuan: 205.58, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤AGE1516", + quantityKg: 3.59, + unitPrice: 38, + amountYuan: 136.42, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-04', + plateNo: "粤AGP5766", + quantityKg: 4.85, + unitPrice: 38, + amountYuan: 184.3, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤EFH2360", + quantityKg: 1.41, + unitPrice: 38, + amountYuan: 53.58, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤EFP2610", + quantityKg: 3.01, + unitPrice: 38, + amountYuan: 114.38, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤AH95660", + quantityKg: 6.33, + unitPrice: 38, + amountYuan: 240.54, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤AH65622", + quantityKg: 7.03, + unitPrice: 38, + amountYuan: 267.14, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤AGR5056", + quantityKg: 5.55, + unitPrice: 38, + amountYuan: 210.9, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤EFQ3650", + quantityKg: 2.53, + unitPrice: 38, + amountYuan: 96.14, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤EFJ4661", + quantityKg: 7.78, + unitPrice: 38, + amountYuan: 295.64, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤AGN6150", + quantityKg: 5.2, + unitPrice: 38, + amountYuan: 197.6, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤EFP3757", + quantityKg: 2.06, + unitPrice: 38, + amountYuan: 78.28, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤AGQ8399", + quantityKg: 6.64, + unitPrice: 38, + amountYuan: 252.32, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤AFG2090", + quantityKg: 2.33, + unitPrice: 38, + amountYuan: 88.54, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤EFQ3560", + quantityKg: 2.34, + unitPrice: 38, + amountYuan: 88.92, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤EFH7825", + quantityKg: 5.6, + unitPrice: 38, + amountYuan: 212.8, + customerName: "广东氢动力科技服务有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤EFJ0697", + quantityKg: 8.37, + unitPrice: 38, + amountYuan: 318.06, + customerName: "广东氢动力科技服务有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤E00438F", + quantityKg: 11.48, + unitPrice: 38, + amountYuan: 436.24, + customerName: "广东氢动力科技服务有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤EFH8009", + quantityKg: 4.88, + unitPrice: 38, + amountYuan: 185.44, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤EFJ4899", + quantityKg: 9.96, + unitPrice: 38, + amountYuan: 378.48, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤EFP3757", + quantityKg: 1.8, + unitPrice: 38, + amountYuan: 68.4, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤AGP4422", + quantityKg: 6.29, + unitPrice: 38, + amountYuan: 239.02, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤AHD7219", + quantityKg: 8.31, + unitPrice: 38, + amountYuan: 315.78, + customerName: "广东开鸿氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤AGE1516", + quantityKg: 3.77, + unitPrice: 38, + amountYuan: 143.26, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤AGP5766", + quantityKg: 4.16, + unitPrice: 38, + amountYuan: 158.08, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤AH30131", + quantityKg: 5.43, + unitPrice: 38, + amountYuan: 206.34, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-05', + plateNo: "粤AH81231", + quantityKg: 6.42, + unitPrice: 38, + amountYuan: 243.96, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-06', + plateNo: "粤EFL2452", + quantityKg: 9.2, + unitPrice: 38, + amountYuan: 349.6, + customerName: "广东沣开科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-06', + plateNo: "粤EFK1052", + quantityKg: 1.96, + unitPrice: 38, + amountYuan: 74.48, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-06', + plateNo: "粤EFQ3737", + quantityKg: 0.85, + unitPrice: 38, + amountYuan: 32.3, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-06', + plateNo: "粤EFQ3737", + quantityKg: 1.77, + unitPrice: 38, + amountYuan: 67.26, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-06', + plateNo: "粤AGP4422", + quantityKg: 1.71, + unitPrice: 38, + amountYuan: 65.1, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-06', + plateNo: "粤AGP4422", + quantityKg: 6.65, + unitPrice: 38, + amountYuan: 252.7, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-06', + plateNo: "粤AH81683", + quantityKg: 3.26, + unitPrice: 38, + amountYuan: 123.88, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-06', + plateNo: "粤AGR5056", + quantityKg: 5.52, + unitPrice: 38, + amountYuan: 209.76, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-06', + plateNo: "粤AH65622", + quantityKg: 2.29, + unitPrice: 38, + amountYuan: 87.02, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-06', + plateNo: "粤AH65622", + quantityKg: 4.18, + unitPrice: 38, + amountYuan: 158.84, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-06', + plateNo: "粤EFF1075", + quantityKg: 2.02, + unitPrice: 38, + amountYuan: 76.76, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-06', + plateNo: "粤AGQ1308", + quantityKg: 7.13, + unitPrice: 38, + amountYuan: 270.94, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-06', + plateNo: "粤EFJ2150", + quantityKg: 2.91, + unitPrice: 38, + amountYuan: 110.58, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-06', + plateNo: "粤EFJ6025", + quantityKg: 2.41, + unitPrice: 38, + amountYuan: 91.58, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-06', + plateNo: "粤AGE1516", + quantityKg: 4.04, + unitPrice: 38, + amountYuan: 153.52, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-06', + plateNo: "粤AH81683", + quantityKg: 2.87, + unitPrice: 38, + amountYuan: 109.06, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-07', + plateNo: "粤EFP2610", + quantityKg: 1.79, + unitPrice: 38, + amountYuan: 68.02, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-07', + plateNo: "粤AH81683", + quantityKg: 2.88, + unitPrice: 38, + amountYuan: 109.44, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-07', + plateNo: "粤AGR5056", + quantityKg: 5.64, + unitPrice: 38, + amountYuan: 214.32, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-07', + plateNo: "粤AH65622", + quantityKg: 6.24, + unitPrice: 38, + amountYuan: 237.12, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-07', + plateNo: "粤AGQ8399", + quantityKg: 5.49, + unitPrice: 38, + amountYuan: 208.62, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-07', + plateNo: "粤AGQ8399", + quantityKg: 2.06, + unitPrice: 38, + amountYuan: 78.28, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-07', + plateNo: "粤AH80311", + quantityKg: 3.3, + unitPrice: 38, + amountYuan: 125.4, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-07', + plateNo: "粤EFH7886", + quantityKg: 5.29, + unitPrice: 38, + amountYuan: 201.02, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-07', + plateNo: "粤AH80311", + quantityKg: 6.9, + unitPrice: 38, + amountYuan: 262.2, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-07', + plateNo: "粤AH81231", + quantityKg: 4.27, + unitPrice: 38, + amountYuan: 162.26, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-07', + plateNo: "粤AH30131", + quantityKg: 8.7, + unitPrice: 38, + amountYuan: 330.6, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-07', + plateNo: "粤AH95660", + quantityKg: 4.82, + unitPrice: 38, + amountYuan: 183.16, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-07', + plateNo: "粤AGE1516", + quantityKg: 5.13, + unitPrice: 38, + amountYuan: 194.94, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-07', + plateNo: "粤AGP5766", + quantityKg: 7.07, + unitPrice: 38, + amountYuan: 268.66, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-08', + plateNo: "粤EFH7893", + quantityKg: 9.38, + unitPrice: 38, + amountYuan: 356.44, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-08', + plateNo: "粤AH30131", + quantityKg: 5.3, + unitPrice: 38, + amountYuan: 201.4, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-08', + plateNo: "粤AH81683", + quantityKg: 5.98, + unitPrice: 38, + amountYuan: 227.24, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-08', + plateNo: "粤AGR5056", + quantityKg: 5.11, + unitPrice: 38, + amountYuan: 194.18, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-08', + plateNo: "粤AGZ5633", + quantityKg: 7.33, + unitPrice: 38, + amountYuan: 278.54, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-08', + plateNo: "粤EFD4887", + quantityKg: 3.95, + unitPrice: 38, + amountYuan: 150.1, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-08', + plateNo: "粤AH80311", + quantityKg: 3.64, + unitPrice: 38, + amountYuan: 138.32, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-08', + plateNo: "粤AGP4422", + quantityKg: 8.82, + unitPrice: 38, + amountYuan: 335.16, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-08', + plateNo: "粤AH95660", + quantityKg: 5.02, + unitPrice: 38, + amountYuan: 190.76, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-08', + plateNo: "粤AH81231", + quantityKg: 2.94, + unitPrice: 38, + amountYuan: 111.72, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-08', + plateNo: "粤AH95660", + quantityKg: 5.89, + unitPrice: 38, + amountYuan: 223.82, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-08', + plateNo: "粤AHD8221", + quantityKg: 8.84, + unitPrice: 38, + amountYuan: 335.92, + customerName: "广东沣开科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-08', + plateNo: "粤EFH7886", + quantityKg: 2.78, + unitPrice: 38, + amountYuan: 105.64, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-08', + plateNo: "粤EFH4388", + quantityKg: 3.52, + unitPrice: 38, + amountYuan: 133.76, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-08', + plateNo: "粤AGE1516", + quantityKg: 4.13, + unitPrice: 38, + amountYuan: 156.94, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-08', + plateNo: "粤AGP5766", + quantityKg: 5.21, + unitPrice: 38, + amountYuan: 197.98, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-09', + plateNo: "粤EFH4388", + quantityKg: 3.7, + unitPrice: 38, + amountYuan: 140.6, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-09', + plateNo: "粤EFH4388", + quantityKg: 0.35, + unitPrice: 38, + amountYuan: 13.3, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-09', + plateNo: "粤AGQ3028", + quantityKg: 9.09, + unitPrice: 38, + amountYuan: 345.42, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-09', + plateNo: "粤AGQ8399", + quantityKg: 6.39, + unitPrice: 38, + amountYuan: 242.82, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-09', + plateNo: "粤AGR5056", + quantityKg: 6.59, + unitPrice: 38, + amountYuan: 250.42, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-09', + plateNo: "粤EFJ4899", + quantityKg: 8.38, + unitPrice: 38, + amountYuan: 318.44, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-09', + plateNo: "粤AH81683", + quantityKg: 3.34, + unitPrice: 38, + amountYuan: 126.92, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-09', + plateNo: "粤AH30108", + quantityKg: 5.36, + unitPrice: 38, + amountYuan: 203.68, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-09', + plateNo: "粤AFG2090", + quantityKg: 4.66, + unitPrice: 38, + amountYuan: 177.08, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-09', + plateNo: "粤AH95660", + quantityKg: 5.54, + unitPrice: 38, + amountYuan: 210.52, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-09', + plateNo: "粤AGP5766", + quantityKg: 4.86, + unitPrice: 38, + amountYuan: 184.68, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-09', + plateNo: "粤AH81231", + quantityKg: 6.16, + unitPrice: 38, + amountYuan: 234.08, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-09', + plateNo: "粤AGP9719", + quantityKg: 5.45, + unitPrice: 38, + amountYuan: 207.1, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-09', + plateNo: "粤EFH3822", + quantityKg: 6.59, + unitPrice: 38, + amountYuan: 250.42, + customerName: "广东氢动力科技服务有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤AH30131", + quantityKg: 6.32, + unitPrice: 38, + amountYuan: 240.16, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤AH30131", + quantityKg: 0.34, + unitPrice: 38, + amountYuan: 12.92, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤AGW5861", + quantityKg: 8.59, + unitPrice: 38, + amountYuan: 326.42, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤E00211F", + quantityKg: 12.04, + unitPrice: 38, + amountYuan: 457.52, + customerName: "广东氢动力科技服务有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤AH80311", + quantityKg: 4.89, + unitPrice: 38, + amountYuan: 185.82, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤AH65622", + quantityKg: 4.23, + unitPrice: 38, + amountYuan: 160.74, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤AH95660", + quantityKg: 4.24, + unitPrice: 38, + amountYuan: 161.12, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤AGR5056", + quantityKg: 5.77, + unitPrice: 38, + amountYuan: 219.26, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤AGP5685", + quantityKg: 5.94, + unitPrice: 38, + amountYuan: 225.72, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤AHD7725", + quantityKg: 9.67, + unitPrice: 38, + amountYuan: 367.46, + customerName: "广东沣开科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤AHD7725", + quantityKg: 0.32, + unitPrice: 38, + amountYuan: 12.16, + customerName: "广东沣开科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤EFP3757", + quantityKg: 2.52, + unitPrice: 38, + amountYuan: 95.76, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤EFF1075", + quantityKg: 1.33, + unitPrice: 38, + amountYuan: 50.54, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤EFL8320", + quantityKg: 1.84, + unitPrice: 38, + amountYuan: 69.92, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤EFH3886", + quantityKg: 4.9, + unitPrice: 38, + amountYuan: 186.2, + customerName: "广东氢动力科技服务有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤AH81231", + quantityKg: 7.27, + unitPrice: 38, + amountYuan: 276.26, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤AGP4422", + quantityKg: 8.62, + unitPrice: 38, + amountYuan: 327.56, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤AH95660", + quantityKg: 5.77, + unitPrice: 38, + amountYuan: 219.26, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤EFJ2488", + quantityKg: 0.37, + unitPrice: 38, + amountYuan: 14.06, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤EFJ2488", + quantityKg: 7.46, + unitPrice: 38, + amountYuan: 283.48, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤AGP5766", + quantityKg: 5.54, + unitPrice: 38, + amountYuan: 210.52, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤AHD5206", + quantityKg: 5.28, + unitPrice: 38, + amountYuan: 200.64, + customerName: "广东沣开科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-10', + plateNo: "粤AFG2090", + quantityKg: 4.36, + unitPrice: 38, + amountYuan: 165.68, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤AH65622", + quantityKg: 5.94, + unitPrice: 38, + amountYuan: 225.72, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤AHD7219", + quantityKg: 9.92, + unitPrice: 38, + amountYuan: 376.96, + customerName: "广东开鸿氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤AHD7219", + quantityKg: 0.32, + unitPrice: 38, + amountYuan: 12.16, + customerName: "广东开鸿氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤EFH8783", + quantityKg: 5.28, + unitPrice: 38, + amountYuan: 200.64, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤AHD7576", + quantityKg: 5.53, + unitPrice: 38, + amountYuan: 210.14, + customerName: "广东开鸿氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤EFL8320", + quantityKg: 2.28, + unitPrice: 38, + amountYuan: 86.64, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤AGP5685", + quantityKg: 8.11, + unitPrice: 38, + amountYuan: 308.18, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤AGR5056", + quantityKg: 4.64, + unitPrice: 38, + amountYuan: 176.32, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤AH30131", + quantityKg: 7.36, + unitPrice: 38, + amountYuan: 279.68, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤EFD5822", + quantityKg: 5.66, + unitPrice: 38, + amountYuan: 215.08, + customerName: "广东瀚清能源有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤AGP5766", + quantityKg: 4.56, + unitPrice: 38, + amountYuan: 173.8, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤EFD4887", + quantityKg: 3.94, + unitPrice: 38, + amountYuan: 149.72, + customerName: "东展供应链(广州)有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤AHD5338", + quantityKg: 9.96, + unitPrice: 38, + amountYuan: 378.48, + customerName: "广东沣开科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤AH81231", + quantityKg: 4.18, + unitPrice: 38, + amountYuan: 158.84, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤AH81231", + quantityKg: 2.39, + unitPrice: 38, + amountYuan: 90.82, + customerName: "现代氢能科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤AGP4422", + quantityKg: 6.48, + unitPrice: 38, + amountYuan: 246.24, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤EFQ5650", + quantityKg: 0.85, + unitPrice: 38, + amountYuan: 32.3, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤EFQ5650", + quantityKg: 1.52, + unitPrice: 38, + amountYuan: 57.76, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: FS, + date: '2026-08-11', + plateNo: "粤EFF1075", + quantityKg: 2.47, + unitPrice: 38, + amountYuan: 93.86, + customerName: "广东中氢顺答汽车科技有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AFH3562", + quantityKg: 5.66, + unitPrice: 35, + amountYuan: 198.1, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AG17852", + quantityKg: 5.93, + unitPrice: 35, + amountYuan: 207.55, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤A03569F", + quantityKg: 19.87, + unitPrice: 35, + amountYuan: 695.45, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AGR9758", + quantityKg: 8.42, + unitPrice: 35, + amountYuan: 294.7, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AGP5755", + quantityKg: 8.14, + unitPrice: 35, + amountYuan: 284.9, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AFP1332", + quantityKg: 6.35, + unitPrice: 35, + amountYuan: 222.25, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AH65535", + quantityKg: 5.12, + unitPrice: 35, + amountYuan: 179.2, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AH81311", + quantityKg: 6.36, + unitPrice: 35, + amountYuan: 222.6, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AGP6579", + quantityKg: 7.86, + unitPrice: 35, + amountYuan: 275.1, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AGF9106", + quantityKg: 6.17, + unitPrice: 35, + amountYuan: 215.95, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AGR8536", + quantityKg: 7.22, + unitPrice: 35, + amountYuan: 252.7, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AG39007", + quantityKg: 4.93, + unitPrice: 35, + amountYuan: 172.55, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AGH6240", + quantityKg: 5.4, + unitPrice: 35, + amountYuan: 189.0, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AH81566", + quantityKg: 5.12, + unitPrice: 35, + amountYuan: 179.2, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AGP3688", + quantityKg: 7.0, + unitPrice: 35, + amountYuan: 245.0, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AGR5538", + quantityKg: 6.71, + unitPrice: 35, + amountYuan: 234.85, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AGR0298", + quantityKg: 6.1, + unitPrice: 35, + amountYuan: 213.5, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AH98809", + quantityKg: 3.53, + unitPrice: 35, + amountYuan: 123.55, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AGR1128", + quantityKg: 5.57, + unitPrice: 35, + amountYuan: 194.95, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AH21159", + quantityKg: 4.55, + unitPrice: 35, + amountYuan: 159.25, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AH87393", + quantityKg: 4.05, + unitPrice: 35, + amountYuan: 141.75, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-22', + plateNo: "粤AGR9816", + quantityKg: 1.18, + unitPrice: 35, + amountYuan: 41.3, + customerName: "广州星达供应链管理有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AH81566", + quantityKg: 6.3, + unitPrice: 35, + amountYuan: 220.5, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AH98809", + quantityKg: 8.33, + unitPrice: 35, + amountYuan: 291.55, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AH21159", + quantityKg: 8.98, + unitPrice: 35, + amountYuan: 314.3, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AH95298", + quantityKg: 8.14, + unitPrice: 35, + amountYuan: 284.9, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AH99322", + quantityKg: 8.92, + unitPrice: 35, + amountYuan: 312.2, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AFH3562", + quantityKg: 6.44, + unitPrice: 35, + amountYuan: 225.4, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AG17852", + quantityKg: 6.29, + unitPrice: 35, + amountYuan: 220.15, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AGH6240", + quantityKg: 6.15, + unitPrice: 35, + amountYuan: 215.25, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AFP1332", + quantityKg: 5.96, + unitPrice: 35, + amountYuan: 208.6, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AGR5278", + quantityKg: 5.28, + unitPrice: 35, + amountYuan: 184.8, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AGP6637", + quantityKg: 8.04, + unitPrice: 35, + amountYuan: 281.4, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AGP9773", + quantityKg: 7.05, + unitPrice: 35, + amountYuan: 246.75, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AGF9106", + quantityKg: 6.08, + unitPrice: 35, + amountYuan: 212.8, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AGZ2231", + quantityKg: 4.23, + unitPrice: 35, + amountYuan: 148.05, + customerName: "广州福满华冷链物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AGP9728", + quantityKg: 6.92, + unitPrice: 35, + amountYuan: 242.2, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AGR8559", + quantityKg: 8.02, + unitPrice: 35, + amountYuan: 280.7, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AGP9787", + quantityKg: 8.56, + unitPrice: 35, + amountYuan: 299.6, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AGR4655", + quantityKg: 9.2, + unitPrice: 35, + amountYuan: 322.0, + customerName: "广州福满华冷链物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AGR5538", + quantityKg: 7.4, + unitPrice: 35, + amountYuan: 259.0, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AGR1578", + quantityKg: 6.96, + unitPrice: 35, + amountYuan: 243.6, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AGP5636", + quantityKg: 9.13, + unitPrice: 35, + amountYuan: 319.55, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AGR9816", + quantityKg: 3.49, + unitPrice: 35, + amountYuan: 122.15, + customerName: "广州星达供应链管理有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AH65535", + quantityKg: 7.73, + unitPrice: 35, + amountYuan: 270.55, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-25', + plateNo: "粤AH87393", + quantityKg: 8.66, + unitPrice: 35, + amountYuan: 303.1, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-27', + plateNo: "粤AH87393", + quantityKg: 4.87, + unitPrice: 35, + amountYuan: 170.45, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-27', + plateNo: "粤AH81311", + quantityKg: 8.27, + unitPrice: 35, + amountYuan: 289.45, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-27', + plateNo: "粤AGR5278", + quantityKg: 6.11, + unitPrice: 35, + amountYuan: 213.85, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-27', + plateNo: "粤AGR9816", + quantityKg: 4.49, + unitPrice: 35, + amountYuan: 157.15, + customerName: "广州星达供应链管理有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AG84993", + quantityKg: 6.53, + unitPrice: 35, + amountYuan: 228.55, + customerName: "广州中味餐饮服务有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AGR6500", + quantityKg: 7.97, + unitPrice: 35, + amountYuan: 278.95, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AGP5758", + quantityKg: 4.68, + unitPrice: 35, + amountYuan: 163.8, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AGR9825", + quantityKg: 3.65, + unitPrice: 35, + amountYuan: 127.75, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AGE4080", + quantityKg: 6.57, + unitPrice: 35, + amountYuan: 229.95, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AGW8256", + quantityKg: 7.21, + unitPrice: 35, + amountYuan: 252.35, + customerName: "广州中味餐饮服务有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AH81566", + quantityKg: 3.83, + unitPrice: 35, + amountYuan: 134.05, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AH27798", + quantityKg: 8.53, + unitPrice: 35, + amountYuan: 298.55, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AG17852", + quantityKg: 6.7, + unitPrice: 35, + amountYuan: 234.5, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AGR5538", + quantityKg: 5.48, + unitPrice: 35, + amountYuan: 191.8, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AGF9106", + quantityKg: 6.18, + unitPrice: 35, + amountYuan: 216.3, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AGF4063", + quantityKg: 6.23, + unitPrice: 35, + amountYuan: 218.05, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AGR5278", + quantityKg: 5.7, + unitPrice: 35, + amountYuan: 199.5, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AGZ6135", + quantityKg: 5.64, + unitPrice: 35, + amountYuan: 197.4, + customerName: "广州福满华冷链物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AGZ2231", + quantityKg: 7.05, + unitPrice: 35, + amountYuan: 246.75, + customerName: "广州福满华冷链物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AH98933", + quantityKg: 8.54, + unitPrice: 35, + amountYuan: 298.9, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AGH6240", + quantityKg: 6.57, + unitPrice: 35, + amountYuan: 229.95, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AGR5556", + quantityKg: 9.37, + unitPrice: 35, + amountYuan: 327.95, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AGP5653", + quantityKg: 7.99, + unitPrice: 35, + amountYuan: 279.65, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AH87393", + quantityKg: 4.74, + unitPrice: 35, + amountYuan: 165.9, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-28', + plateNo: "粤AH81311", + quantityKg: 4.06, + unitPrice: 35, + amountYuan: 142.1, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-29', + plateNo: "粤AGR5099", + quantityKg: 7.51, + unitPrice: 35, + amountYuan: 262.85, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-29', + plateNo: "粤AFH3562", + quantityKg: 6.28, + unitPrice: 35, + amountYuan: 219.8, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-29', + plateNo: "粤AGR9818", + quantityKg: 7.18, + unitPrice: 35, + amountYuan: 251.3, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-29', + plateNo: "粤AGR9899", + quantityKg: 7.25, + unitPrice: 35, + amountYuan: 253.75, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-29', + plateNo: "粤AH98809", + quantityKg: 4.85, + unitPrice: 35, + amountYuan: 169.75, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-29', + plateNo: "粤AGR9816", + quantityKg: 4.64, + unitPrice: 35, + amountYuan: 162.4, + customerName: "广州星达供应链管理有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-29', + plateNo: "粤AGP9787", + quantityKg: 6.08, + unitPrice: 35, + amountYuan: 212.8, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-29', + plateNo: "粤AGR5538", + quantityKg: 6.43, + unitPrice: 35, + amountYuan: 225.05, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-29', + plateNo: "粤AG84993", + quantityKg: 2.4, + unitPrice: 35, + amountYuan: 84.0, + customerName: "广州中味餐饮服务有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-29', + plateNo: "粤AGR5278", + quantityKg: 7.2, + unitPrice: 35, + amountYuan: 252.0, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AG39007", + quantityKg: 5.93, + unitPrice: 35, + amountYuan: 207.55, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGP9773", + quantityKg: 8.2, + unitPrice: 35, + amountYuan: 287.0, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGQ8398", + quantityKg: 7.92, + unitPrice: 35, + amountYuan: 277.2, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AG84993", + quantityKg: 4.92, + unitPrice: 35, + amountYuan: 172.2, + customerName: "广州中味餐饮服务有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AH81311", + quantityKg: 8.21, + unitPrice: 35, + amountYuan: 287.35, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGP5359", + quantityKg: 7.4, + unitPrice: 35, + amountYuan: 259.0, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGR5278", + quantityKg: 7.86, + unitPrice: 35, + amountYuan: 275.1, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGW8256", + quantityKg: 6.31, + unitPrice: 35, + amountYuan: 220.85, + customerName: "广州中味餐饮服务有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤A00859F", + quantityKg: 20.09, + unitPrice: 35, + amountYuan: 703.15, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGG4975", + quantityKg: 6.11, + unitPrice: 35, + amountYuan: 213.85, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGP5636", + quantityKg: 7.68, + unitPrice: 35, + amountYuan: 268.8, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGR5278", + quantityKg: 5.27, + unitPrice: 35, + amountYuan: 184.45, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AG32783", + quantityKg: 6.05, + unitPrice: 35, + amountYuan: 211.75, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AH87393", + quantityKg: 6.25, + unitPrice: 35, + amountYuan: 218.75, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGP5659", + quantityKg: 4.82, + unitPrice: 35, + amountYuan: 168.7, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGR8799", + quantityKg: 4.35, + unitPrice: 35, + amountYuan: 152.25, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AH98809", + quantityKg: 4.99, + unitPrice: 35, + amountYuan: 174.65, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGR1578", + quantityKg: 8.69, + unitPrice: 35, + amountYuan: 304.15, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGG0490", + quantityKg: 4.42, + unitPrice: 35, + amountYuan: 154.7, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤A03569F", + quantityKg: 19.16, + unitPrice: 35, + amountYuan: 670.6, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGP5718", + quantityKg: 3.12, + unitPrice: 35, + amountYuan: 109.2, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGZ2231", + quantityKg: 4.8, + unitPrice: 35, + amountYuan: 168.0, + customerName: "广州福满华冷链物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGZ6135", + quantityKg: 4.7, + unitPrice: 35, + amountYuan: 164.5, + customerName: "广州福满华冷链物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGR9758", + quantityKg: 8.16, + unitPrice: 35, + amountYuan: 285.6, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGR9816", + quantityKg: 5.46, + unitPrice: 35, + amountYuan: 191.1, + customerName: "广州星达供应链管理有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGR9899", + quantityKg: 7.91, + unitPrice: 35, + amountYuan: 276.85, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGP5755", + quantityKg: 7.21, + unitPrice: 35, + amountYuan: 252.35, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGP9717", + quantityKg: 8.7, + unitPrice: 35, + amountYuan: 304.5, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AGR0298", + quantityKg: 7.48, + unitPrice: 35, + amountYuan: 261.8, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AH27798", + quantityKg: 4.33, + unitPrice: 35, + amountYuan: 151.55, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AH95298", + quantityKg: 5.23, + unitPrice: 35, + amountYuan: 183.05, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AH81311", + quantityKg: 6.18, + unitPrice: 35, + amountYuan: 216.3, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-30', + plateNo: "粤AH99322", + quantityKg: 9.47, + unitPrice: 35, + amountYuan: 331.45, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-31', + plateNo: "粤AH98809", + quantityKg: 5.68, + unitPrice: 35, + amountYuan: 198.8, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-31', + plateNo: "粤AGR5028", + quantityKg: 8.35, + unitPrice: 35, + amountYuan: 292.25, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-31', + plateNo: "粤AGR8536", + quantityKg: 7.14, + unitPrice: 35, + amountYuan: 249.9, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-31', + plateNo: "粤AGR5538", + quantityKg: 6.67, + unitPrice: 35, + amountYuan: 233.45, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-31', + plateNo: "粤AGP6579", + quantityKg: 6.56, + unitPrice: 35, + amountYuan: 229.6, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-31', + plateNo: "粤AGR5278", + quantityKg: 6.15, + unitPrice: 35, + amountYuan: 215.25, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-31', + plateNo: "粤AGR3288", + quantityKg: 6.55, + unitPrice: 35, + amountYuan: 229.25, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-31', + plateNo: "粤AH21159", + quantityKg: 3.8, + unitPrice: 35, + amountYuan: 133.0, + customerName: "广州市梅洛特物流有限公司", + fleet: 'external', + }, + { + stationId: DP, + date: '2026-07-31', + plateNo: "粤AGP5138", + quantityKg: 6.04, + unitPrice: 35, + amountYuan: 211.4, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-31', + plateNo: "粤AFH3562", + quantityKg: 3.58, + unitPrice: 35, + amountYuan: 125.3, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-31', + plateNo: "粤AGH6240", + quantityKg: 3.65, + unitPrice: 35, + amountYuan: 127.75, + customerName: "羚牛氢能科技(广东)有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-31', + plateNo: "粤AGR8799", + quantityKg: 3.94, + unitPrice: 35, + amountYuan: 137.9, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, + { + stationId: DP, + date: '2026-07-31', + plateNo: "粤AGP5139", + quantityKg: 0.03, + unitPrice: 35, + amountYuan: 1.05, + customerName: "广州新运多租赁有限公司", + fleet: 'own', + }, +]; + +export interface StationCustomerBalanceRow { + stationId: string; + customerName: string; + rechargeOrSpotYuan: number; + consumePrepaidYuan: number; + consumeSpotYuan: number; + balanceYuan: number; + remark?: string; +} + +/** 客户氢费收支汇总(汇报 Excel) */ +export const MOCK_CUSTOMER_BALANCE: StationCustomerBalanceRow[] = [ + { + stationId: 'st-fs-nanhai', + customerName: "广东氢动力科技服务有限公司", + rechargeOrSpotYuan: 112162.21, + consumePrepaidYuan: 45485.63, + consumeSpotYuan: 62162.21, + balanceYuan: 4514.37, + }, + { + stationId: 'st-fs-nanhai', + customerName: "广州福满华冷链物流有限公司", + rechargeOrSpotYuan: 4693.45, + consumePrepaidYuan: 0.0, + consumeSpotYuan: 4693.45, + balanceYuan: 0.0, + }, + { + stationId: 'st-fs-nanhai', + customerName: "广东清运科技有限公司", + rechargeOrSpotYuan: 70000.0, + consumePrepaidYuan: 47479.04, + consumeSpotYuan: 0.0, + balanceYuan: 22520.96, + }, + { + stationId: 'st-fs-nanhai', + customerName: "羚牛氢能科技(广东)有限公司", + rechargeOrSpotYuan: 308832.16, + consumePrepaidYuan: 159416.71, + consumeSpotYuan: 167024.17, + balanceYuan: -17608.72, + remark: "月结", + }, + { + stationId: 'st-fs-nanhai', + customerName: "东展供应链(广州)有限公司", + rechargeOrSpotYuan: 62797.68, + consumePrepaidYuan: 35211.75, + consumeSpotYuan: 27585.93, + balanceYuan: -0.0, + }, + { + stationId: 'st-fs-nanhai', + customerName: "昇美新能源有限公司", + rechargeOrSpotYuan: 1591.99, + consumePrepaidYuan: 0.0, + consumeSpotYuan: 1591.99, + balanceYuan: 0.0, + }, + { + stationId: 'st-fs-nanhai', + customerName: "佛山市南海腾威汽车贸易有限公司", + rechargeOrSpotYuan: 159941.25, + consumePrepaidYuan: 159941.25, + consumeSpotYuan: 0.0, + balanceYuan: -0.0, + }, + { + stationId: 'st-fs-nanhai', + customerName: "广东云韬氢能科技有限公司", + rechargeOrSpotYuan: 82721.48, + consumePrepaidYuan: 0.0, + consumeSpotYuan: 82721.48, + balanceYuan: 0.0, + }, + { + stationId: 'st-fs-nanhai', + customerName: "广东沣开科技有限公司", + rechargeOrSpotYuan: 34857.85, + consumePrepaidYuan: 32292.29, + consumeSpotYuan: 857.85, + balanceYuan: 1707.71, + }, + { + stationId: 'st-fs-nanhai', + customerName: "现代氢能科技有限公司", + rechargeOrSpotYuan: 73585.87, + consumePrepaidYuan: 0.0, + consumeSpotYuan: 73585.87, + balanceYuan: 0.0, + }, + { + stationId: 'st-fs-nanhai', + customerName: "广东中氢联达新能源投资有限公司", + rechargeOrSpotYuan: 111669.6, + consumePrepaidYuan: 111669.6, + consumeSpotYuan: 0.0, + balanceYuan: 0.0, + }, + { + stationId: 'st-fs-nanhai', + customerName: "广州东逸物流有限公司", + rechargeOrSpotYuan: 1547.0, + consumePrepaidYuan: 0.0, + consumeSpotYuan: 1547.0, + balanceYuan: 0.0, + }, + { + stationId: 'st-fs-nanhai', + customerName: "广州铁语物流运输有限公司", + rechargeOrSpotYuan: 6176.87, + consumePrepaidYuan: 0.0, + consumeSpotYuan: 6176.87, + balanceYuan: 0.0, + }, + { + stationId: 'st-fs-nanhai', + customerName: "佛山市南海绿氢投资有限公司", + rechargeOrSpotYuan: 249.9, + consumePrepaidYuan: 0.0, + consumeSpotYuan: 249.9, + balanceYuan: 0.0, + }, + { + stationId: 'st-fs-nanhai', + customerName: "佛山市南海区瀚洁城市环境管理有限公司大沥分公司", + rechargeOrSpotYuan: 20000.0, + consumePrepaidYuan: 564.06, + consumeSpotYuan: 0.0, + balanceYuan: 19435.94, + }, + { + stationId: 'st-fs-nanhai', + customerName: "佛山市南海区狮山镇惠鑫绿色供应链有限公司", + rechargeOrSpotYuan: 7069.58, + consumePrepaidYuan: 4998.09, + consumeSpotYuan: 2071.49, + balanceYuan: 0.0, + }, + { + stationId: 'st-fs-nanhai', + customerName: "广东开鸿氢能科技有限公司", + rechargeOrSpotYuan: 4668.68, + consumePrepaidYuan: 0.0, + consumeSpotYuan: 4668.68, + balanceYuan: 0.0, + }, + { + stationId: 'st-fs-nanhai', + customerName: "广东中氢顺答汽车科技有限公司", + rechargeOrSpotYuan: 5000.0, + consumePrepaidYuan: 1934.96, + consumeSpotYuan: 0.0, + balanceYuan: 3065.04, + }, + { + stationId: 'st-fs-nanhai', + customerName: "广东瀚清能源有限公司", + rechargeOrSpotYuan: 20000.0, + consumePrepaidYuan: 215.08, + consumeSpotYuan: 0.0, + balanceYuan: 19784.92, + }, + { + stationId: 'st-fs-nanhai', + customerName: "外省过路车", + rechargeOrSpotYuan: 185.82, + consumePrepaidYuan: 0.0, + consumeSpotYuan: 185.82, + balanceYuan: 0.0, + }, + { + stationId: 'st-dp-dongpeng', + customerName: "广州新运多租赁有限公司", + rechargeOrSpotYuan: 13072.15, + consumePrepaidYuan: 2483.36, + consumeSpotYuan: 13072.15, + balanceYuan: -2483.36, + }, + { + stationId: 'st-dp-dongpeng', + customerName: "广州星达供应链管理有限公司", + rechargeOrSpotYuan: 0.0, + consumePrepaidYuan: 3421.64, + consumeSpotYuan: 0.0, + balanceYuan: -3421.64, + }, + { + stationId: 'st-dp-dongpeng', + customerName: "广州市梅洛特物流有限公司", + rechargeOrSpotYuan: 50000.0, + consumePrepaidYuan: 27643.28, + consumeSpotYuan: 0.0, + balanceYuan: 22356.72, + }, + { + stationId: 'st-dp-dongpeng', + customerName: "广州中味餐饮服务有限公司", + rechargeOrSpotYuan: 3082.1, + consumePrepaidYuan: 0.0, + consumeSpotYuan: 3082.1, + balanceYuan: 0.0, + }, + { + stationId: 'st-dp-dongpeng', + customerName: "羚牛氢能科技(广东)有限公司", + rechargeOrSpotYuan: 0.0, + consumePrepaidYuan: 67018.98, + consumeSpotYuan: 0.0, + balanceYuan: -67018.98, + }, + { + stationId: 'st-dp-dongpeng', + customerName: "广州福满华冷链物流有限公司", + rechargeOrSpotYuan: 20000.0, + consumePrepaidYuan: 1246.7, + consumeSpotYuan: 0.0, + balanceYuan: 18753.3, + remark: "0.06", + }, +]; + +export function monthTotals(cells: StationCustomerMonthCell[], keys: readonly string[]): Record { + const out: Record = {}; + for (const k of keys) out[k] = 0; + for (const c of cells) { + for (const k of keys) out[k] += c.months[k] || 0; + } + return out; +} + +export function windowRange(asOf: string): { startDate: string; end: string } { + const d = new Date(`${asOf}T00:00:00`); + d.setDate(d.getDate() - 9); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return { startDate: `${y}-${m}-${day}`, end: asOf }; +} + +export function stationWindowVolumes(stationId: string, startDate: string, end: string) { + return MOCK_STATION_VOLUME_10D.filter( + (r) => r.stationId === stationId && r.date >= startDate && r.date <= end, + ).sort((a, b) => a.date.localeCompare(b.date)); +} diff --git a/src/modules/energy/hydrogen/station-daily/station-mobile-list-model.test.ts b/src/modules/energy/hydrogen/station-daily/station-mobile-list-model.test.ts new file mode 100644 index 0000000..ec1a898 --- /dev/null +++ b/src/modules/energy/hydrogen/station-daily/station-mobile-list-model.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { customerMonthTotal, formatMoney, mobileCustomerRows, mobileDailyRows } from './station-mobile-list-model'; + +test('日列表倒序且从全量查询读取实际前日值', () => { + const rows = [{ date: '2026-08-11', quantityKg: 9, amountYuan: 90, vehicleCount: 1, unitPrice: 10 }]; + const result = mobileDailyRows(rows, [...rows, { date: '2026-08-10', quantityKg: 7, amountYuan: 70, vehicleCount: 1, unitPrice: 10 }]); + assert.equal(result[0].previousKg, 7); +}); + +test('客户按当前月指标排序,搜索覆盖全部客户且零值保持零', () => { + const customers = [ + { customerName: '甲运输', months: { '2026-08': 0 } }, + { customerName: '乙物流', months: { '2026-08': 12 } }, + ]; + assert.deepEqual(mobileCustomerRows(customers, '2026-08', '').map((item) => item.customerName), ['乙物流', '甲运输']); + assert.equal(customerMonthTotal(customers, '2026-08'), 12); + assert.deepEqual(mobileCustomerRows(customers, '2026-08', '甲').map((item) => item.customerName), ['甲运输']); +}); + +test('日报区分真实前日零和缺失前日,倒序展示不改变源数组', () => { + const rows = [ + { date: '2026-03-01', quantityKg: 9, amountYuan: 90, vehicleCount: 1, unitPrice: 10 }, + { date: '2026-03-02', quantityKg: 0, amountYuan: 0, vehicleCount: 0, unitPrice: 0 }, + ]; + const result = mobileDailyRows(rows, [...rows, { date: '2026-02-28', quantityKg: 0, amountYuan: 0, vehicleCount: 0, unitPrice: 0 }]); + assert.deepEqual(result.map(row => row.date), ['2026-03-02', '2026-03-01']); + assert.equal(result[1].previousKg, 0); + assert.equal(mobileDailyRows(rows, rows)[1].previousKg, null); + assert.equal(rows[0].date, '2026-03-01'); +}); + +test('货币统一保留两位小数,包括真实零', () => { + assert.equal(formatMoney(0), '0.00'); + assert.equal(formatMoney(1234.5), '1,234.50'); +}); diff --git a/src/modules/energy/hydrogen/station-daily/station-mobile-list-model.ts b/src/modules/energy/hydrogen/station-daily/station-mobile-list-model.ts new file mode 100644 index 0000000..5cae69d --- /dev/null +++ b/src/modules/energy/hydrogen/station-daily/station-mobile-list-model.ts @@ -0,0 +1,63 @@ +/** Mobile list derivations are deliberately data-only so the view never invents zeroes. */ +export interface StationDailyVolumeRow { + date: string; + quantityKg: number; + amountYuan: number; + vehicleCount: number; + unitPrice: number; +} + +export interface StationMonthlyCustomer { + customerName: string; + months: Record; +} + +export type CustomerMetric = 'volume' | 'fee'; + +export function formatKg(value: number): string { + return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 }); +} + +export function formatMoney(value: number): string { + return Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +/** Latest first; allRows supplies the true previous-day quantity even outside the seven-day view. */ +export function mobileDailyRows(rows: StationDailyVolumeRow[], allRows: StationDailyVolumeRow[]) { + const quantitiesByDate = new Map(allRows.map((row) => [row.date, row.quantityKg])); + return [...rows] + .sort((left, right) => right.date.localeCompare(left.date)) + .map((row) => { + const previous = new Date(`${row.date}T00:00:00Z`); + previous.setUTCDate(previous.getUTCDate() - 1); + const previousDate = previous.toISOString().slice(0, 10); + const previousKg = quantitiesByDate.get(previousDate); + return { ...row, previousKg: Number.isFinite(previousKg) ? previousKg : null }; + }); +} + +export function customerMetricValue(customer: StationMonthlyCustomer, month: string): number { + const value = customer.months[month]; + return Number.isFinite(value) ? value : 0; +} + +export function mobileCustomerRows( + customers: StationMonthlyCustomer[], + month: string, + search: string, +): StationMonthlyCustomer[] { + const keyword = search.trim().toLocaleLowerCase('zh-CN'); + return customers + .filter((customer) => !keyword || customer.customerName.toLocaleLowerCase('zh-CN').includes(keyword)) + .sort((left, right) => customerMetricValue(right, month) - customerMetricValue(left, month) + || left.customerName.localeCompare(right.customerName, 'zh-CN')); +} + +export function customerMonthTotal(customers: StationMonthlyCustomer[], month: string): number { + return customers.reduce((total, customer) => total + customerMetricValue(customer, month), 0); +} + +export function monthLabel(month: string): string { + const matched = /^(\d{4})-(\d{2})$/.exec(month); + return matched ? `${matched[1]}年${Number(matched[2])}月` : month; +} diff --git a/src/modules/energy/hydrogen/station-daily/station-mobile-lists.css b/src/modules/energy/hydrogen/station-daily/station-mobile-lists.css new file mode 100644 index 0000000..6e16c02 --- /dev/null +++ b/src/modules/energy/hydrogen/station-daily/station-mobile-lists.css @@ -0,0 +1,37 @@ +/* Pilot is intentionally isolated: the host chooses where to render it. */ +.sd-mobile-list-pilot { display: none; } +.sd-mobile-list__hint { margin: 4px 0 8px; color: #64748b; font-size: 11px; line-height: 1.5; } +@media (max-width: 767px) { + .sd-mobile-list-pilot { display: block; box-sizing: border-box; width: 100%; color: #24344e; font-family: var(--sd-font, -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif); } + .sd-mobile-list__head, .sd-mobile-daily__row { display: grid; grid-template-columns: .82fr .9fr 1.28fr; align-items: center; gap: 7px; } + .sd-mobile-list__head { padding: 0 2px 6px; color: #71819a; font-size: 10px; } + .sd-mobile-list__head span:not(:first-child), .sd-mobile-daily__row > span:not(:first-child) { text-align: right; } + .sd-mobile-list__detail { border-top: 1px solid #edf1f6; } + .sd-mobile-list__detail summary { list-style: none; cursor: pointer; min-height: 48px; } + .sd-mobile-list__detail summary::-webkit-details-marker { display: none; } + .sd-mobile-daily__row > span { display: flex; min-width: 0; align-items: baseline; justify-content: flex-end; gap: 3px; } + .sd-mobile-daily__row > span:first-child { justify-content: flex-start; } + .sd-mobile-daily__row strong { color: #1e3556; font-size: 13px; font-variant-numeric: tabular-nums; } + .sd-mobile-daily__row small { color: #75859c; font-size: 9px; } + .sd-mobile-list-pilot .is-zero { color: #94a3b8; } + .sd-mobile-list__expanded { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding: 0 0 10px; color: #64748b; font-size: 11px; } + .sd-mobile-list__expanded span:last-of-type { grid-column: 1 / -1; } + .sd-mobile-list__expanded button { grid-column: 1 / -1; min-height: 44px; border: 1px solid #bfdbfe; border-radius: 8px; background: #eff6ff; color: #2563eb; font: inherit; font-weight: 700; } + .sd-mobile-list__state { margin: 0; color: #64748b; font-size: 12px; text-align: center; } + .sd-mobile-list__state.is-error { color: #b42318; } + .sd-mobile-customer__controls { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } + .sd-mobile-customer__controls select, .sd-mobile-customer__controls input, .sd-mobile-customer__metric button { min-width: 0; min-height: 44px; border: 1px solid #d8e2ef; border-radius: 8px; background: #fff; color: #34445f; font: inherit; font-size: 12px; } + .sd-mobile-customer__metric { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; } + .sd-mobile-customer__metric button.is-active { border-color: #2563eb; background: #eff6ff; color: #2563eb; font-weight: 700; } + .sd-mobile-customer__controls > strong, .sd-mobile-customer__controls input { grid-column: 1 / -1; } + .sd-mobile-customer__controls > strong { color: #1e3556; font-size: 12px; } + .sd-mobile-customer__controls input { box-sizing: border-box; padding: 0 10px; } + .sd-mobile-customer__detail summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; } + .sd-mobile-customer__detail summary span { display: -webkit-box; overflow: hidden; color: #263650; font-size: 12px; font-weight: 650; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } + .sd-mobile-customer__detail summary strong { color: #1e3556; font-size: 12px; font-variant-numeric: tabular-nums; text-align: right; } + .sd-mobile-customer__trend { display: grid; gap: 4px; padding: 0 0 10px; } + .sd-mobile-customer__trend > div { display: grid; grid-template-columns: 50px minmax(0, 1fr) 82px; align-items: center; gap: 6px; color: #71819a; font-size: 10px; } + .sd-mobile-customer__trend i { display: block; height: 5px; min-width: 0; border-radius: 99px; background: #60a5fa; } + .sd-mobile-customer__trend i.is-zero { width: 0 !important; } + .sd-mobile-customer__trend strong { color: #51627b; font-size: 10px; font-variant-numeric: tabular-nums; text-align: right; } +} diff --git a/src/modules/energy/hydrogen/station-daily/station-month-range.test.ts b/src/modules/energy/hydrogen/station-daily/station-month-range.test.ts new file mode 100644 index 0000000..52de3ec --- /dev/null +++ b/src/modules/energy/hydrogen/station-daily/station-month-range.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { customerMonthLabel, customerMonthRange, dateRangeLabel, inclusiveDayCount } from './station-month-range'; + +test('客户月度范围保持截至结束日的连续 12 个月,并包含无数据的 5 月', () => { + assert.deepEqual(customerMonthRange('2026-08-31'), [ + '2025-09', '2025-10', '2025-11', '2025-12', + '2026-01', '2026-02', '2026-03', '2026-04', + '2026-05', '2026-06', '2026-07', '2026-08', + ]); +}); + +test('完整年月键不会随截止月份漂移', () => { + assert.deepEqual(customerMonthRange('2027-03-01', 3), ['2027-01', '2027-02', '2027-03']); + assert.equal(customerMonthLabel('2026-05'), '2026年5月'); +}); + +test('日期区间文案反映闭区间实际天数', () => { + assert.equal(inclusiveDayCount('2026-08-01', '2026-08-15'), 15); + assert.equal(dateRangeLabel('2026-08-01', '2026-08-15'), '2026-08-01 至 2026-08-15(共 15 天)'); + assert.equal(inclusiveDayCount('2026-08-15', '2026-08-01'), 0); +}); diff --git a/src/modules/energy/hydrogen/station-daily/station-month-range.ts b/src/modules/energy/hydrogen/station-daily/station-month-range.ts new file mode 100644 index 0000000..2719e07 --- /dev/null +++ b/src/modules/energy/hydrogen/station-daily/station-month-range.ts @@ -0,0 +1,37 @@ +/** + * 客户月度接口固定返回“截至查询结束日的近 12 个月”。 + * 键保留完整 YYYY-MM,避免跨年时把不同年份的同月合并。 + */ +export function customerMonthRange(asOfYmd: string, count = 12): string[] { + const matched = /^(\d{4})-(\d{2})-\d{2}$/.exec(asOfYmd); + if (!matched || count < 1) return []; + + const endYear = Number(matched[1]); + const endMonth = Number(matched[2]); + if (endMonth < 1 || endMonth > 12) return []; + + const months: string[] = []; + for (let offset = count - 1; offset >= 0; offset -= 1) { + const date = new Date(Date.UTC(endYear, endMonth - 1 - offset, 1)); + months.push(`${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`); + } + return months; +} + +export function customerMonthLabel(monthKey: string): string { + const matched = /^(\d{4})-(\d{2})$/.exec(monthKey); + return matched ? `${matched[1]}年${Number(matched[2])}月` : monthKey; +} + +/** 闭区间日数;用于让卡片副标题与实际查询口径保持一致。 */ +export function inclusiveDayCount(start: string, end: string): number { + const startTime = Date.parse(`${start}T00:00:00Z`); + const endTime = Date.parse(`${end}T00:00:00Z`); + if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) return 0; + return Math.floor((endTime - startTime) / 86_400_000) + 1; +} + +export function dateRangeLabel(start: string, end: string): string { + const days = inclusiveDayCount(start, end); + return days > 0 ? `${start} 至 ${end}(共 ${days} 天)` : `${start} 至 ${end}`; +} diff --git a/src/modules/energy/hydrogen/station-daily/styles.css b/src/modules/energy/hydrogen/station-daily/styles.css new file mode 100644 index 0000000..3fd07fe --- /dev/null +++ b/src/modules/energy/hydrogen/station-daily/styles.css @@ -0,0 +1,2877 @@ +/* 加氢站日报 · 对齐能源 BI 全局字排与表(禁另起一套视觉) */ +.ehb-shell--station-daily, +.sd-embedded { + --sd-ink: #0f172a; + --sd-muted: #64748b; + --sd-tertiary: #94a3b8; + --sd-line: rgba(15, 23, 42, 0.08); + --sd-cyan: #2563eb; + --sd-cyan-soft: #eff6ff; + --sd-surface: #ffffff; + --sd-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', + 'Microsoft YaHei', 'Noto Sans SC', sans-serif; + --sd-mono: 'JetBrains Mono', 'Cascadia Mono', Consolas, 'SF Mono', ui-monospace, monospace; + color: #1e293b; + font-family: var(--sd-font); + /* 禁 content-box:width:100% + padding 会吃掉页边距(详情白卡贴右缘) */ + box-sizing: border-box; +} + +.sd-mobile-combined-fullscreen-table { display: none; } + +@media (max-width: 767px) { + .sd-mobile-month-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .sd-mobile-month-controls select { width: 100%; } + .sd-mobile-customer-filter { grid-column: auto; } + .sd-mobile-record__metrics { + display: grid; + min-width: 118px; + gap: 4px; + text-align: right; + } + .sd-mobile-record__metric { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 4px; + } + .sd-mobile-record__metric strong { + color: #17243a; + font-family: var(--bi-font-mono); + font-size: 14px; + } + .sd-mobile-record__metric span { color: #71819a; font-size: 9px; } + .sd-mobile-record__metric.is-fee strong { font-size: 13px; font-weight: 700; } + .sd-mobile-record__metric.is-zero strong, + .sd-mobile-record__metric.is-zero span { color: #9aa8bb; } + .sd-embedded .sd-mobile-detail-panel:fullscreen > .sd-desktop-matrix-table, + .sd-embedded .sd-mobile-detail-panel.is-mobile-list-fullscreen > .sd-desktop-matrix-table { display: none; } + .sd-embedded .sd-mobile-detail-panel:fullscreen > .sd-mobile-combined-fullscreen-table, + .sd-embedded .sd-mobile-detail-panel.is-mobile-list-fullscreen > .sd-mobile-combined-fullscreen-table { display: block; } +} + +/* Single-station records: desktop keeps full tables, mobile uses one four-tab reading surface. */ +.sd-mobile-detail-hub { display: contents; } +.sd-mobile-detail-tabs, +.sd-mobile-record-list { display: none; } + +@media (max-width: 767px) { + .sd-embedded .sd-mobile-detail-hub { + position: relative; + display: flex; + flex-direction: column; + gap: 10px; + } + .sd-embedded .sd-mobile-trend-panel { order: 1; } + .sd-embedded .sd-mobile-detail-tabs { + order: 2; + display: block; + padding: 14px; + border: 1px solid #dfe7f1; + border-radius: 14px 14px 0 0; + background: #fff; + } + .sd-mobile-detail-tabs__title { + display: flex; + min-height: 32px; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; + color: #18263d; + font-size: 16px; + font-weight: 750; + } + .sd-mobile-detail-tabs__rail { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 3px; + padding: 3px; + border-radius: 10px; + background: #eef3f9; + } + .sd-mobile-detail-tabs__rail button { + min-height: 44px; + border: 0; + border-radius: 8px; + background: transparent; + color: #62738d; + font-size: 11px; + font-weight: 700; + } + .sd-mobile-detail-tabs__rail button.is-active { + background: #fff; + color: #2f6bff; + box-shadow: 0 1px 4px rgba(32, 67, 116, .12); + } + .sd-mobile-month-controls { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; + } + .sd-mobile-month-controls select { + min-width: 0; + min-height: 44px; + padding: 0 28px 0 9px; + border: 1px solid #d8e2ef; + border-radius: 9px; + background: #fff; + color: #33445f; + font-size: 11px; + } + .sd-mobile-customer-filter { grid-column: auto; } + .sd-mobile-customer-filter .sd-msel__trigger { width: 100%; max-width: none; min-width: 0; height: 44px; } + .sd-embedded .sd-mobile-detail-panel { + order: 3; + margin-top: -10px; + border-top: 0; + border-radius: 0 0 14px 14px; + } + .sd-mobile-detail-panel:not(.is-active) { display: none; } + .sd-embedded .sd-dual--ledger { display: contents; } + .sd-embedded .sd-mobile-detail-panel .sd-panel__title, + .sd-embedded .sd-mobile-detail-panel .sd-panel__head-row, + .sd-embedded .sd-mobile-detail-panel .sd-more-btn { display: none; } + .sd-mobile-record-list { display: grid; } + .sd-mobile-detail-panel .sd-mobile-record-list { padding-top: 0; } + .sd-mobile-record { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 5px 10px; + padding: 11px 0; + border-bottom: 1px solid #edf1f6; + } + .sd-mobile-record:last-child { border-bottom: 0; } + .sd-mobile-record__lead, + .sd-mobile-record__value { min-width: 0; } + .sd-mobile-record__lead strong { + display: block; + overflow: hidden; + color: #263650; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + } + .sd-mobile-record__lead span, + .sd-mobile-record__value span, + .sd-mobile-record__meta { color: #71819a; font-size: 10px; } + .sd-mobile-record__value { text-align: right; } + .sd-mobile-record__value strong { display: block; color: #17243a; font-family: var(--bi-font-mono); font-size: 15px; } + .sd-mobile-record__value strong small { color: #71819a; font-size: 9px; } + .sd-mobile-record__meta { grid-column: 1 / -1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .sd-mobile-pay-tag { display: inline-block; margin-top: 3px; padding: 2px 6px; border-radius: 8px; background: #eef4ff; color: #4f6f9f !important; } + .sd-embedded .sd-mobile-detail-panel > .sd-table-scroll { display: none; } + .sd-embedded .sd-mobile-detail-panel:fullscreen > .sd-table-scroll, + .sd-embedded .sd-mobile-detail-panel.is-mobile-list-fullscreen > .sd-table-scroll { display: block; } + .sd-embedded .sd-mobile-detail-panel:fullscreen > .sd-mobile-record-list, + .sd-embedded .sd-mobile-detail-panel.is-mobile-list-fullscreen > .sd-mobile-record-list { display: none; } + .sd-embedded .sd-mobile-detail-panel:fullscreen .sd-panel__title, + .sd-embedded .sd-mobile-detail-panel.is-mobile-list-fullscreen .sd-panel__title, + .sd-embedded .sd-mobile-detail-panel:fullscreen .sd-panel__head-row, + .sd-embedded .sd-mobile-detail-panel.is-mobile-list-fullscreen .sd-panel__head-row { display: flex; } + .sd-embedded .sd-mobile-detail-hub.is-mobile-list-fullscreen .sd-mobile-trend-panel, + .sd-embedded .sd-mobile-detail-hub.is-mobile-list-fullscreen .sd-mobile-detail-tabs__rail, + .sd-embedded .sd-mobile-detail-hub.is-mobile-list-fullscreen .sd-mobile-month-controls { + display: none; + } + .sd-embedded .sd-mobile-detail-hub.is-mobile-list-fullscreen .sd-mobile-detail-tabs { + order: 1; + flex: 0 0 auto; + padding: 0 0 10px; + border: 0; + border-bottom: 1px solid #dfe7f1; + border-radius: 0; + } + .sd-embedded .sd-mobile-detail-hub.is-mobile-list-fullscreen .sd-mobile-detail-tabs__title { + margin: 0; + } + .sd-embedded .sd-mobile-detail-hub.is-mobile-list-fullscreen .sd-mobile-detail-panel.is-active { + order: 2; + display: block; + margin: 0; + border-radius: 0; + } + .sd-embedded .sd-mobile-detail-hub.is-mobile-list-fullscreen .sd-mobile-detail-panel.is-active > .sd-mobile-record-list { + display: none; + } + .sd-embedded .sd-mobile-detail-hub.is-mobile-list-fullscreen .sd-mobile-detail-panel.is-active > .sd-table-scroll { + display: block; + } + .sd-embedded .sd-mobile-detail-hub.is-mobile-list-fullscreen .sd-mobile-detail-panel.is-active .sd-panel__title, + .sd-embedded .sd-mobile-detail-hub.is-mobile-list-fullscreen .sd-mobile-detail-panel.is-active .sd-panel__head-row { + display: flex; + } +} + +.ehb-shell--station-daily *, +.ehb-shell--station-daily *::before, +.ehb-shell--station-daily *::after, +.sd-embedded *, +.sd-embedded *::before, +.sd-embedded *::after { + box-sizing: border-box; +} + +.ehb-shell--station-daily { + min-height: 100vh; + background: #f8fafc; +} + +/* 页边距硬门禁:对齐 DESIGN §4.9.2 B1 / 能源 BI `.ehb-body`(禁左右贴边) */ +.ehb-shell--station-daily .ehb-body.sd-body { + width: 100%; + max-width: none; + margin: 0; + padding: 20px 24px 32px; + box-sizing: border-box; + min-width: 0; + overflow-x: clip; +} + +.sd-embedded { + margin-top: 4px; + width: 100%; + min-width: 0; + /* 嵌入经营看板时同样保留水平 gutter,禁左右清零 */ + padding: 4px 16px 8px; + box-sizing: border-box; +} + +.sd-detail { + width: 100%; + min-width: 0; + max-width: 100%; +} + +/* —— 顶栏 —— */ +.sd-topbar { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 18px; +} + +.sd-topbar__kicker { + margin: 0; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--sd-cyan); +} + +.sd-topbar__title { + margin: 6px 0 0; + font-size: 22px; + font-weight: 800; + letter-spacing: -0.02em; + color: var(--sd-ink); + line-height: 1.2; +} + +.sd-topbar__title--embed { + margin: 0; + font-size: 14px; + font-weight: 700; + letter-spacing: 0; + color: var(--sd-ink); +} + +.sd-topbar--embedded { + margin-bottom: 14px; +} + +.sd-panel--grow { + min-width: 0; +} + +.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel > .sd-panel__head-row.sd-external-receipt-head { + display: flex; + flex-wrap: wrap; +} + +.sd-panel__head-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.sd-panel__head-row .sd-panel__title { + margin: 0; +} + +.sd-customer-month-head { + display: flex; + min-width: 0; + align-items: center; + gap: 14px; +} + +.sd-customer-month-tabs { + display: inline-grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px; + padding: 2px; + border-radius: 9px; + background: #eef3f9; +} + +.sd-customer-month-tabs button { + min-width: 112px; + min-height: 32px; + padding: 0 12px; + border: 0; + border-radius: 7px; + background: transparent; + color: #60728d; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.sd-customer-month-tabs button.is-active { + background: #fff; + color: #2f6bff; + box-shadow: 0 1px 4px rgba(32, 67, 116, .12); +} + +.sd-customer-month-tabs button:focus-visible { + outline: 2px solid #2f6bff; + outline-offset: 1px; +} + +.sd-panel__meta { + font-size: 12px; + color: var(--sd-muted); + font-variant-numeric: tabular-nums; +} + +.ehb-table .is-neg { + color: #dc2626; + font-weight: 700; +} + +.sd-topbar__meta { + margin: 8px 0 0; + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--sd-muted); + font-variant-numeric: tabular-nums; +} + +.sd-topbar__dot { + width: 3px; + height: 3px; + border-radius: 50%; + background: #94a3b8; +} + +.sd-topbar__tools { + display: flex; + align-items: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.sd-field { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 11px; + font-weight: 700; + color: var(--sd-muted); +} + +.sd-input { + min-height: 38px; + height: 38px; + border: 1px solid var(--sd-line); + border-radius: 10px; + padding: 0 12px; + font-size: 13px; + color: var(--sd-ink); + background: #fff; + min-width: 150px; +} + +/* —— 自定义日期(禁原生 type=date) —— */ +.sd-date { + position: relative; + display: inline-block; +} + +.sd-date--right .sd-date__popover { + left: auto; + right: 0; +} + +.sd-date__trigger { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 30px; + height: 30px; + padding: 0 10px 0 12px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #ffffff; + color: var(--sd-ink); + cursor: pointer; + user-select: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.sd-date__trigger:hover, +.sd-date__trigger.is-open { + border-color: #2563eb; + background: #fff; + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12); +} + +.sd-date__label { + font-size: 12px; + font-weight: 500; + color: var(--sd-muted); + letter-spacing: 0; +} + +.sd-date__value { + font-size: 12px; + font-weight: 600; + font-family: var(--sd-mono); + font-variant-numeric: tabular-nums; + color: #0f172a; + letter-spacing: 0; +} + +.sd-date__icon { + color: #94a3b8; + flex-shrink: 0; +} + +.sd-date__trigger.is-open .sd-date__icon { + color: var(--sd-cyan); +} + +.sd-date__popover { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 40; + width: 268px; + padding: 12px; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.35); + background: #ffffff; + box-shadow: + 0 18px 40px -18px rgba(15, 23, 42, 0.28), + 0 8px 16px -10px rgba(2, 132, 199, 0.18); +} + +.sd-date__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.sd-date__title { + font-size: 14px; + font-weight: 800; + color: var(--sd-ink); + letter-spacing: -0.02em; +} + +.sd-date__nav { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: none; + border-radius: 8px; + background: #f1f5f9; + color: #475569; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} + +.sd-date__nav:hover { + background: var(--sd-cyan-soft); + color: var(--sd-cyan); +} + +.sd-date__week { + display: grid; + grid-template-columns: repeat(7, 1fr); + margin-bottom: 6px; + text-align: center; + font-size: 11px; + font-weight: 700; + color: #94a3b8; +} + +.sd-date__grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 3px; +} + +.sd-date__day { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + border: none; + border-radius: 8px; + background: transparent; + color: #334155; + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} + +.sd-date__day:hover:not(.is-empty):not(.is-selected) { + background: #f0f9ff; + color: var(--sd-cyan); +} + +.sd-date__day.is-selected { + background: var(--sd-cyan); + color: #fff; + font-weight: 800; + box-shadow: 0 6px 14px -6px rgba(2, 132, 199, 0.55); +} + +.sd-date__day.is-empty { + cursor: default; + pointer-events: none; +} + +.sd-date__footer { + display: flex; + justify-content: flex-end; + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid #eef2f7; +} + +.sd-date__today { + border: none; + background: transparent; + color: var(--sd-cyan); + font-size: 12px; + font-weight: 700; + cursor: pointer; + padding: 4px 6px; + border-radius: 6px; +} + +.sd-date__today:hover { + background: var(--sd-cyan-soft); +} + +/* —— 起止区间选择器 —— */ +.sd-date--range .sd-date__popover--range { + width: 300px; +} + +.sd-date__shortcuts { + display: flex; + gap: 6px; + margin-bottom: 10px; +} + +.sd-date__shortcuts button { + flex: 1; + height: 28px; + border: 1px solid #e2e8f0; + border-radius: 7px; + background: #f8fafc; + color: #334155; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.sd-date__shortcuts button:hover { + border-color: #93c5fd; + color: #1d4ed8; + background: #eff6ff; +} + +.sd-date__range-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + margin-bottom: 10px; +} + +.sd-date__range-tabs button { + min-height: 32px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + color: #64748b; + font-size: 11px; + font-weight: 600; + font-family: var(--sd-mono); + cursor: pointer; + padding: 4px 6px; +} + +.sd-date__range-tabs button.is-on { + border-color: #2563eb; + color: #1d4ed8; + background: #eff6ff; + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); +} + +.sd-date__day.is-in-range:not(.is-selected) { + background: #dbeafe; + color: #1e40af; + border-radius: 0; +} + +.sd-date__footer--range { + justify-content: space-between; + align-items: center; +} + +.sd-date__apply { + margin-left: auto; +} + +@media (prefers-reduced-motion: reduce) { + .sd-date__trigger, + .sd-date__nav, + .sd-date__day { + transition: none; + } +} + +.sd-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 30px; + min-height: 30px; + padding: 0 11px; + border-radius: 8px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--sd-line); + background: #ffffff; + color: #1e293b; + transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease; +} + +.sd-btn--ghost { + background: #ffffff; + border-color: var(--sd-line); + color: var(--sd-muted); +} + +.sd-btn--ghost:hover { + background: #fff; + border-color: rgba(37, 99, 235, 0.35); + color: #2563eb; +} + +.sd-btn--primary { + background: #2563eb; + color: #fff; + border-color: #2563eb; + box-shadow: none; +} + +.sd-btn--primary:hover { + background: #1d4ed8; + border-color: #1d4ed8; + color: #fff; + filter: none; +} + +/* —— Hero KPI · 对齐 ehb-daily-kpi-card —— */ +.sd-hero-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.sd-mobile-operating-overview__source { + margin: 0 0 8px; + color: #8290a6; + font-size: 10px; + line-height: 1.4; +} + +.sd-hero-kpi { + position: relative; + background: #ffffff; + border: 1px solid var(--sd-line); + border-radius: 10px; + padding: 10px 12px; + box-shadow: none; + overflow: hidden; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.sd-hero-kpi::before { + display: none; +} + +.sd-hero-kpi--accent { + background: #ffffff; +} + +.sd-hero-kpi--accent::before { + display: none; +} + +.sd-hero-kpi__label { + font-size: 12px; + font-weight: 500; + color: var(--sd-muted); + margin-bottom: 4px; +} + +.sd-hero-kpi__value { + font-size: 20px; + font-weight: 700; + color: #0f172a; + font-family: var(--sd-mono); + font-variant-numeric: tabular-nums; + line-height: 1.2; + letter-spacing: -0.02em; + margin-bottom: 6px; + display: flex; + align-items: baseline; + gap: 2px; +} + +.sd-unit { + margin-left: 2px; + font-size: 12px; + font-weight: 500; + color: var(--sd-muted); + font-family: var(--sd-font); +} + +.sd-hero-kpi__sub { + margin-top: auto; + font-size: 11px; + color: var(--sd-muted); + font-family: var(--sd-mono); + font-variant-numeric: tabular-nums; + background: #f8fafc; + border-radius: 6px; + padding: 4px 8px; +} + +.sd-hero-kpi__source { + margin-top: 6px; + color: #8290a6; + font-size: 9px; + line-height: 1.45; +} + +.sd-hero-kpi--click { + width: 100%; + text-align: left; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.sd-hero-kpi--click:hover { + border-color: #93c5fd; + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.08); +} + +.sd-hero-kpi--click:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +/* —— 占比 —— */ +.sd-share-panel { + background: #ffffff; + border: 1px solid var(--sd-line); + border-radius: 10px; + padding: 12px 14px; + margin-bottom: 12px; + box-shadow: none; + backdrop-filter: none; +} + +.sd-share-panel__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.sd-share-panel__title, +.sd-section-title, +.sd-panel__title { + margin: 0; + font-size: 14px; + font-weight: 700; + color: #1e293b; +} + +.sd-share-panel__title { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 8px; +} + +.sd-share-panel__range { + font-size: 12px; + font-weight: 500; + color: var(--sd-muted); + font-family: var(--sd-mono); + font-variant-numeric: tabular-nums; +} + +.sd-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.sd-section-head .sd-section-title { + margin-bottom: 0; +} + +.sd-board-mode { + display: inline-flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.sd-board-mode__tabs { + display: inline-flex; + gap: 3px; + padding: 3px; + border-radius: 10px; + background: #eef3f9; +} + +.sd-board-mode__tabs > button[role='tab'] { + height: 30px; + min-height: 30px; + padding: 0 12px; + border: 0; + border-radius: 8px; + background: transparent; + color: #64748b; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.sd-board-mode__tabs > button[role='tab'].is-on { + color: #1d4ed8; + background: #fff; + box-shadow: 0 1px 4px rgba(32, 67, 116, .12); +} + +.sd-board-station-select { + height: 28px; + min-height: 28px; + border: 1px solid #cbd5e1; + border-radius: 7px; + padding: 0 8px; + font-size: 12px; + color: #0f172a; + background: #fff; + max-width: 180px; +} + +.sd-station-filter-select { + min-height: 36px; + padding: 0 34px 0 12px; + border: 1px solid #d7e1ee; + border-radius: 10px; + background: #fff; + color: #245fd4; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.sd-station-single-card { + display: flex; + flex-direction: column; + gap: 10px; +} + +.sd-station-row { + width: 100%; + border: 1px solid var(--sd-line); + border-radius: 10px; + background: #fff; + padding: 12px 14px; + cursor: pointer; + text-align: left; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.sd-station-row:hover { + border-color: #93c5fd; + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.08); +} + +.sd-station-row.is-solo { + max-width: 100%; +} + +.sd-station-row__main { + display: grid; + grid-template-columns: auto minmax(180px, 1fr) minmax(320px, 1.5fr) auto; + gap: 12px 16px; + align-items: center; +} + +.sd-station-row__metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0; +} + +.sd-station-row__metrics > div { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; + padding: 0 16px; + border-inline-end: 1px solid #e5ebf3; +} + +.sd-station-row__metrics > div:last-child { border-inline-end: 0; } + +.sd-station-row__metrics strong { + font-size: 13px; + font-weight: 700; + font-family: var(--sd-mono); + font-variant-numeric: tabular-nums; + color: #0f172a; +} + +.sd-station-row__metrics strong span { + margin-left: 2px; + font-size: 11px; + font-weight: 500; + color: var(--sd-muted); + font-family: var(--sd-font); +} + +.sd-station-card__metric-source { + display: block; + margin-top: 4px; + color: #8492a8; + font: 500 9px/1.35 var(--sd-font); + white-space: normal; +} + +.sd-station-row__trend { + grid-column: 1 / -1; + padding-top: 14px; + border-top: 1px solid #edf1f6; +} + +.sd-station-row__trend-title { + display: block; + margin-bottom: 12px; + color: #71819b; + font-size: 11px; + font-weight: 650; +} + +.sd-station-row__trend-bars { + display: grid; + height: 84px; + padding-top: 10px; + box-sizing: border-box; + grid-template-columns: repeat(7, minmax(0, 1fr)); + align-items: end; + gap: 10px; +} + +.sd-station-row__trend-col { + position: relative; + display: grid; + height: 100%; + grid-template-rows: minmax(0, 1fr) 16px; + align-items: end; + justify-items: center; + gap: 4px; +} + +.sd-station-row__trend-bar-slot { + position: relative; + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: flex-end; + flex-direction: column; +} + +.sd-station-row__trend-col i { + display: block; + width: min(34px, 72%); + min-height: 8px; + border-radius: 4px 4px 1px 1px; + background: #4f83ed; +} + +.sd-station-row__trend-col.is-zero i { + min-height: 0; +} + +.sd-station-row__trend-value { + position: absolute; + left: 50%; + bottom: calc(var(--trend-bar-height, 0%) + 3px); + color: #566987; + font: 600 9px/1 var(--sd-mono); + transform: translateX(-50%); + white-space: nowrap; +} + +.sd-station-row__trend-tooltip { + position: absolute; + z-index: 5; + left: 50%; + bottom: calc(100% - 12px); + display: grid; + min-width: 112px; + padding: 7px 9px; + border: 1px solid #dbe5f4; + border-radius: 7px; + background: #172033; + color: #fff; + box-shadow: 0 8px 20px rgb(23 32 51 / 18%); + opacity: 0; + pointer-events: none; + transform: translate(-50%, 4px); + transition: opacity 120ms ease, transform 120ms ease; + white-space: nowrap; +} + +.sd-station-row__trend-tooltip strong, +.sd-station-row__trend-tooltip em { + font: 600 10px/1.35 var(--sd-mono); + font-style: normal; +} + +.sd-station-row__trend-tooltip em { + color: #bcd1ff; +} + +.sd-station-row__trend-col:hover .sd-station-row__trend-tooltip { + opacity: 1; + transform: translate(-50%, 0); +} + +.sd-station-row__trend-col small { + color: #7a8ba4; + font: 500 9px/1 var(--sd-mono); + white-space: nowrap; +} + +.sd-station-row__trend-empty { + grid-column: 1 / -1; + align-self: center; + color: #94a3b8; + font-size: 11px; + text-align: center; +} + +.sd-station-row__share { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.sd-station-row__share-bar { + height: 6px; + border-radius: 999px; + background: #e2e8f0; + overflow: hidden; +} + +.sd-station-row__share-bar > i { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #2563eb, #38bdf8); +} + +.sd-section-title { + margin-bottom: 12px; +} + +.sd-share-panel__total { + font-size: 11px; + color: var(--sd-muted); + font-family: var(--sd-mono); +} + +.sd-share-panel__total strong { + color: var(--sd-ink); + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +.sd-share-track { + display: flex; + gap: 2px; + min-height: 44px; + border-radius: 8px; + overflow: hidden; +} + +.sd-share-seg { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 8px; + min-width: 48px; + padding: 8px 12px; + border: none; + cursor: pointer; + color: #fff; + text-align: left; + transition: filter 0.15s ease; +} + +.sd-share-seg:hover { + filter: brightness(1.04); + transform: none; +} + +.sd-share-seg--0 { + background: #2563eb; +} + +.sd-share-seg--1 { + background: #0ea5e9; +} + +.sd-share-seg--2 { + background: #6366f1; + color: #fff; +} + +.sd-share-seg__pct { + font-size: 13px; + font-weight: 700; + font-variant-numeric: tabular-nums; + font-family: var(--sd-mono); + line-height: 1; + flex: 0 0 auto; +} + +.sd-share-seg__name { + font-size: 12px; + font-weight: 500; + opacity: 0.92; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.sd-share-legend { + list-style: none; + margin: 12px 0 0; + padding: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 16px; +} + +.sd-share-legend__btn { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 4px; + border: none; + background: transparent; + cursor: pointer; + text-align: left; + border-radius: 8px; +} + +.sd-share-legend__btn:hover { + background: rgba(14, 165, 233, 0.06); +} + +.sd-share-legend__swatch { + width: 10px; + height: 10px; + border-radius: 3px; + flex: 0 0 auto; +} + +.sd-share-legend__name { + flex: 1; + min-width: 0; + font-size: 12px; + font-weight: 600; + color: #334155; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sd-share-legend__val { + font-size: 12px; + font-weight: 700; + color: var(--sd-muted); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* —— 站点卡片 —— */ +.sd-station-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.sd-station-card { + display: flex; + flex-direction: column; + gap: 10px; + text-align: left; + background: #ffffff; + border: 1px solid var(--sd-line); + border-radius: 10px; + padding: 12px 14px; + cursor: pointer; + box-shadow: none; + backdrop-filter: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.sd-station-card:hover { + border-color: #93c5fd; + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.08); + transform: none; +} + +.sd-station-card:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +.sd-station-card--tone0, +.sd-station-card--tone1 { + background: #ffffff; +} + +.sd-station-card__top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.sd-station-card__identity { + display: flex; + gap: 10px; + align-items: flex-start; + min-width: 0; +} + +.sd-station-card__icon { + flex: 0 0 auto; + width: 34px; + height: 34px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + background: #eff6ff; + color: #2563eb; +} + +.sd-station-card__name { + font-size: 14px; + font-weight: 700; + color: var(--sd-ink); + line-height: 1.35; +} + +.sd-station-card__region { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 2px; + font-size: 12px; + font-weight: 500; + color: var(--sd-muted); +} + +.sd-station-card__go { + width: 32px; + height: 32px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--sd-cyan); + background: var(--sd-cyan-soft); +} + +.sd-spark { + display: flex; + align-items: flex-end; + gap: 3px; + height: 48px; + padding: 4px 2px 0; +} + +.sd-spark--empty { + align-items: center; + justify-content: center; + border-radius: 6px; + background: #f8fafc; + border: 1px dashed #e2e8f0; + color: #94a3b8; + font-size: 11px; + font-weight: 500; +} + +.sd-spark--empty::after { + content: '本区间无加氢量'; +} + +.sd-spark__col { + flex: 1; + height: 100%; + display: flex; + align-items: flex-end; +} + +.sd-spark__bar { + width: 100%; + border-radius: 3px 3px 1px 1px; + background: linear-gradient(180deg, #60a5fa, #2563eb); + min-height: 3px; +} + +.sd-station-card__metrics { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 14px; +} + +.sd-station-card__m-label { + font-size: 11px; + font-weight: 500; + color: var(--sd-muted); + margin-bottom: 2px; +} + +.sd-station-card__m-value { + font-size: 15px; + font-weight: 700; + color: #0f172a; + font-family: var(--sd-mono); + font-variant-numeric: tabular-nums; + line-height: 1.2; + letter-spacing: -0.02em; +} + +.sd-station-card__m-value span { + margin-left: 2px; + font-size: 11px; + font-weight: 500; + color: var(--sd-muted); + font-family: var(--sd-font); +} + +.sd-station-card__foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-top: 6px; + border-top: 1px dashed var(--sd-line); +} + +.sd-cash-pill { + font-size: 11px; + font-weight: 700; + padding: 4px 9px; + border-radius: 999px; +} + +.sd-cash-pill.is-ok { + background: #ecfdf5; + color: #047857; +} + +.sd-cash-pill.is-empty { + background: #f8fafc; + color: #94a3b8; +} + +.sd-station-card__share { + font-size: 12px; + font-weight: 800; + color: var(--sd-cyan); + font-variant-numeric: tabular-nums; +} + +/* —— 明细页 —— */ +.sd-detail-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.sd-detail-top__lead { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.sd-detail-top__title { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--sd-ink); + letter-spacing: -0.01em; +} + +.sd-detail-top__meta { + margin: 4px 0 0; + font-size: 12px; + color: var(--sd-muted); + font-family: var(--sd-mono); + font-variant-numeric: tabular-nums; +} + +.sd-detail-top__tools { + display: flex; + align-items: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.sd-panel { + background: #ffffff; + border: 1px solid var(--sd-line); + border-radius: 10px; + padding: 12px 14px; + box-shadow: none; + backdrop-filter: none; + min-width: 0; + max-width: 100%; + box-sizing: border-box; +} + +.sd-panel__title--sm { + font-size: 13px; + font-weight: 700; + color: #1e293b; +} + +.sd-trend { + margin-top: 8px; +} + +@media (prefers-reduced-motion: reduce) { + .sd-station-card, + .sd-share-seg, + .sd-btn { + transition: none; + } + .sd-station-card:hover, + .sd-share-seg:hover { + transform: none; + } +} + + + +/* —— BI 对齐表(修下钻错乱) —— */ +.sd-report-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); + gap: 12px; + margin-top: 12px; + align-items: stretch; +} + +.sd-dual { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; + margin-top: 12px; +} + +.sd-dual--stack { + grid-template-columns: minmax(0, 1fr); +} + +.sd-dual--cash { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); +} + +.sd-table-scroll { + overflow-x: auto; + overflow-y: visible; + margin-top: 8px; + max-width: 100%; + -webkit-overflow-scrolling: touch; +} + +.sd-table-scroll--matrix { + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; +} + +.sd-bi-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + text-align: left; + font-size: 13px; + min-width: 0; +} + +.sd-bi-table th { + font-size: 12px; + font-weight: 500; + color: var(--sd-muted); + padding: 8px 12px; + border-bottom: 1px solid var(--sd-line); + white-space: nowrap; + background: #f8fafc; + text-align: left; +} + +.sd-bi-table td { + font-size: 13px; + font-weight: 400; + color: #334155; + padding: 8px 12px; + border-bottom: 1px solid var(--sd-line); + white-space: nowrap; + vertical-align: middle; +} + +.sd-bi-table tr:last-child td { + border-bottom: none; +} + +.sd-bi-table td.is-num, +.sd-bi-table th.is-num { + text-align: right; + font-variant-numeric: tabular-nums; + font-family: var(--sd-mono); + font-size: 12px; +} + +.sd-bi-table th.is-num { + font-weight: 500; + font-size: 12px; +} + +.sd-bi-table tr:hover td { + background: #f8fafc; +} + +.sd-bi-table tr.is-total td { + font-weight: 700; + background: #f8fafc; + color: #0f172a; +} + +.sd-bi-table .is-mono { + font-family: var(--sd-mono); + font-size: 12px; + color: #475569; +} + +.sd-bi-table .is-neg { + color: #dc2626; + font-weight: 700; +} + +/* 少列报表:限宽铺满,避免超宽屏把数字列拉成大空档 */ +.sd-table-scroll--hug { + max-width: 960px; +} + +.sd-bi-table--hug { + width: 100%; + min-width: 640px; + table-layout: fixed; +} + +.sd-bi-table--hug th:first-child, +.sd-bi-table--hug td:first-child { + width: 28%; + white-space: normal; + word-break: break-word; +} + +.sd-bi-table--wide { + width: 100%; + min-width: max(100%, 880px); +} + +/* 客户月矩阵:滚动区铺满卡片;表至少撑满,列跟内容可横滚 */ +.sd-bi-table--matrix { + width: max(100%, 1080px); + min-width: 100%; + table-layout: auto; +} + +.sd-bi-table--matrix th:first-child, +.sd-bi-table--matrix td:first-child { + position: sticky; + left: 0; + z-index: 2; + background: #fff; + min-width: 168px; + max-width: 220px; + white-space: normal; + word-break: break-word; + box-shadow: 4px 0 8px -6px rgba(15, 23, 42, 0.18); +} + +.sd-bi-table--matrix thead th:first-child { + z-index: 3; + background: #f8fafc; +} + +.sd-bi-table--matrix tr.is-total td:first-child, +.sd-bi-table--matrix tr:hover td:first-child { + background: #f8fafc; +} + +.sd-bi-table--matrix th.is-num, +.sd-bi-table--matrix td.is-num { + min-width: 72px; + padding-left: 6px; + padding-right: 8px; + text-align: right; +} + +.sd-bi-table--matrix.is-amount th.is-num, +.sd-bi-table--matrix.is-amount td.is-num { + min-width: 92px; + font-size: 12px; +} + +.sd-bi-table--matrix th.is-num { + font-size: 11px; + white-space: normal; + line-height: 1.25; + max-width: 4.5em; +} + +/* 月度矩阵以数字为主、趋势为辅,避免红绿铺满整张表。 */ +.sd-bi-table--matrix td.is-stock-up, +.sd-bi-table--matrix td.is-stock-down { + color: #334155 !important; + font-weight: 500; +} + +.sd-bi-table--matrix tr.is-total td.is-stock-up, +.sd-bi-table--matrix tr.is-total td.is-stock-down { + color: #0f172a !important; + font-weight: 700; +} + +.sd-bi-table--matrix .is-stock-up .sd-delta { + color: #dc2626; +} + +.sd-bi-table--matrix .is-stock-down .sd-delta { + color: #059669; +} + +.sd-bi-table--matrix td.is-zero { + color: #94a3b8 !important; + font-weight: 400; +} + +.sd-bi-table--matrix .is-current-month { + background: #f3f7ff; +} + +.sd-bi-table--matrix thead th.is-current-month { + color: #245fd4; +} + +.sd-bi-table--matrix tr:hover td.is-current-month { + background: #edf4ff; +} + +.sd-panel__title--sm { + font-size: 13px; + font-weight: 700; + color: #1e293b; +} + +.sd-station-card__share { + font-size: 12px; + font-weight: 700; + color: #2563eb; + font-variant-numeric: tabular-nums; + font-family: var(--sd-mono); +} + +.sd-cash-pill.is-ok { + background: #eff6ff; + color: #1d4ed8; +} + +/* —— 精简明细布局 · 全宽块 + 趋势铺满 + 客户多选 —— */ +.sd-bi-table--ledger { + width: 100%; + min-width: 520px; +} + +.sd-bi-table--ledger td:first-child, +.sd-bi-table--ledger th:first-child { + white-space: normal; + word-break: break-word; + max-width: 160px; +} + +.sd-table-scroll--cash-lines { + max-height: 420px; + overflow: auto; +} + +.sd-panel--block { + width: 100%; + margin-top: 16px; +} + +.sd-dual--ledger { + margin-top: 16px; + gap: 16px; + align-items: start; +} + +.sd-bi-table--fill { + width: 100%; + min-width: 100%; +} + +.sd-detail-top__updated, +.sd-topbar__updated { + margin: 4px 0 0; + font-size: 12px; + font-weight: 500; + color: #94a3b8; + font-variant-numeric: tabular-nums; + font-family: var(--sd-mono); +} + +.sd-updated { + display: none; +} + +/* 全局数值层级:业务数值中性,只有趋势箭头使用红绿。 */ +.is-stock-up, +.is-stock-down { + color: #334155 !important; + font-weight: inherit; +} + +.is-stock-flat { + color: #64748b; +} + +.is-stock-up .sd-delta { + color: #dc2626; +} + +.is-stock-down .sd-delta { + color: #059669; +} + +.is-risk-negative { + color: #dc2626 !important; + font-weight: 700; +} + +.sd-bi-table td.is-zero { + color: #94a3b8 !important; + font-weight: 400; +} + +.sd-mobile-record__value .is-zero { + color: #94a3b8; +} + +.sd-delta { + display: inline-block; + margin-left: 3px; + font-size: 10px; + line-height: 1; + vertical-align: middle; +} + +.sd-more-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + width: 100%; + margin-top: 10px; + height: 32px; + border: 1px dashed #cbd5e1; + border-radius: 8px; + background: #f8fafc; + color: #475569; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.sd-more-btn:hover { + border-color: #93c5fd; + color: #2563eb; + background: #eff6ff; +} + +.sd-trend { + position: relative; +} + +.sd-trend-legend-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + color: #64748b; + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sd-trend-legend-dot { + width: 8px; + height: 8px; + border-radius: 2px; + background: #2563eb; + flex: 0 0 auto; + display: inline-block; +} + +.sd-trend__col.is-hover .sd-trend__bar { + filter: brightness(1.08); + outline: 2px solid rgba(37, 99, 235, 0.35); +} + +.sd-trend-tip { + position: absolute; + top: 8px; + right: 12px; + z-index: 5; + min-width: 220px; + max-width: 320px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid #e2e8f0; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12); + pointer-events: none; +} + +.sd-trend-tip__date { + font-size: 12px; + font-weight: 700; + color: #0f172a; + font-family: var(--sd-mono); + margin-bottom: 6px; +} + +.sd-trend-tip__row { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #334155; +} + +.sd-trend-tip__name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sd-trend-tip__row strong { + font-family: var(--sd-mono); + font-variant-numeric: tabular-nums; + color: #0f172a; + white-space: nowrap; +} + +.sd-trend-tip__sub { + margin-top: 4px; + font-size: 11px; + color: #94a3b8; +} + +.sd-trend--fill { + display: flex; + align-items: flex-end; + gap: 6px; + width: 100%; + min-height: 220px; + padding: 12px 4px 4px; + box-sizing: border-box; +} + +.sd-trend__col { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.sd-trend__val { + font-size: 11px; + color: #64748b; + font-variant-numeric: tabular-nums; + font-family: var(--sd-mono); +} + +.sd-trend__bar-wrap { + width: 100%; + height: 140px; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.sd-trend__bar { + width: min(42px, 70%); + border-radius: 4px 4px 2px 2px; + background: linear-gradient(180deg, #60a5fa, #2563eb); +} + +.sd-trend__date { + font-size: 10px; + color: #94a3b8; + font-family: var(--sd-mono); + font-variant-numeric: tabular-nums; + white-space: nowrap; + transform: none; + letter-spacing: -0.02em; +} + +.sd-trend__date--mobile { + display: none; +} + +.sd-msel { + position: relative; + flex: 0 0 auto; +} + +.sd-msel__trigger { + display: inline-flex; + align-items: center; + gap: 8px; + height: 30px; + max-width: 280px; + padding: 0 10px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #fff; + cursor: pointer; + color: #0f172a; +} + +.sd-msel.is-open .sd-msel__trigger, +.sd-msel__trigger:hover { + border-color: #2563eb; + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12); +} + +.sd-msel__label { + font-size: 12px; + font-weight: 500; + color: #64748b; + flex: 0 0 auto; +} + +.sd-msel__value { + font-size: 12px; + font-weight: 600; + color: #0f172a; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 160px; +} + +.sd-msel__chev { + color: #94a3b8; + flex: 0 0 auto; +} + +.sd-msel__panel { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 40; + width: min(320px, 80vw); + max-height: 320px; + display: flex; + flex-direction: column; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12); + overflow: hidden; +} + +.sd-msel__search { + position: relative; + padding: 10px 10px 6px; +} + +.sd-msel__search input { + width: 100%; + height: 32px; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 0 28px 0 10px; + font-size: 12px; + box-sizing: border-box; +} + +.sd-msel__clear { + position: absolute; + right: 16px; + top: 16px; + border: none; + background: transparent; + color: #94a3b8; + cursor: pointer; + padding: 2px; +} + +.sd-msel__actions { + display: flex; + gap: 8px; + padding: 0 10px 8px; +} + +.sd-msel__actions button { + border: none; + background: #f1f5f9; + color: #334155; + font-size: 12px; + font-weight: 600; + height: 26px; + padding: 0 10px; + border-radius: 999px; + cursor: pointer; +} + +.sd-msel__actions button:hover { + background: #e2e8f0; +} + +.sd-msel__list { + list-style: none; + margin: 0; + padding: 0 0 8px; + overflow: auto; + flex: 1; +} + +.sd-msel__opt { + width: 100%; + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 12px; + border: none; + background: transparent; + cursor: pointer; + text-align: left; +} + +.sd-msel__opt:hover { + background: #f8fafc; +} + +.sd-msel__opt.is-on { + background: #eff6ff; +} + +.sd-msel__check { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid #cbd5e1; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + margin-top: 1px; + color: #fff; + background: #fff; +} + +.sd-msel__opt.is-on .sd-msel__check { + background: #2563eb; + border-color: #2563eb; +} + +.sd-msel__name { + font-size: 12px; + color: #334155; + line-height: 1.35; +} + +.sd-msel__empty { + padding: 16px; + text-align: center; + color: #94a3b8; + font-size: 12px; +} + +.sd-panel__head-row { + align-items: center; +} + +/* —— KPI 钻取弹层 —— */ +.sd-kpi-modal { + position: fixed; + inset: 0; + z-index: 80; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.sd-kpi-modal__mask { + position: absolute; + inset: 0; + border: none; + background: rgba(15, 23, 42, 0.42); + cursor: pointer; +} + +.sd-kpi-modal__panel { + position: relative; + z-index: 1; + width: min(720px, 100%); + max-height: min(80vh, 640px); + display: flex; + flex-direction: column; + background: #fff; + border: 1px solid var(--sd-line); + border-radius: 12px; + box-shadow: 0 24px 48px rgba(15, 23, 42, 0.18); + overflow: hidden; +} + +.sd-kpi-modal__head { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 12px 14px; + border-bottom: 1px solid var(--sd-line); +} + +.sd-kpi-modal__head h3 { + margin: 0; + font-size: 14px; + font-weight: 700; + color: #0f172a; +} + +.sd-kpi-modal__meta { + margin-right: auto; + font-size: 12px; + color: var(--sd-muted); + font-family: var(--sd-mono); +} + +.sd-kpi-modal .sd-table-scroll { + flex: 1; + min-height: 0; + padding: 0 14px 14px; +} + +/* PC station overview: use the width for comparison instead of stretching mobile cards. */ +@media (min-width: 1101px) { + .sd-embedded .sd-station-single-card { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + } + + .sd-embedded .sd-station-row { + min-height: 224px; + padding: 16px 18px; + border-radius: 12px; + } + + .sd-embedded .sd-station-row.is-solo { + max-width: none; + width: 100%; + grid-column: 1 / -1; + justify-self: stretch; + } + + .sd-embedded .sd-station-row__main { + grid-template-columns: 34px minmax(0, 1fr) 28px; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 12px 10px; + align-items: center; + } + + .sd-embedded .sd-station-row__id { + min-width: 0; + grid-column: 2; + grid-row: 1; + } + + .sd-embedded .sd-station-card__go { + width: 28px; + height: 28px; + grid-column: 3; + grid-row: 1; + } + + .sd-embedded .sd-station-row__metrics { + grid-column: 1 / -1; + grid-row: 2; + padding-top: 12px; + border-top: 1px solid #edf1f6; + } + + .sd-embedded .sd-station-row__metrics > div { + padding: 0 12px; + } + + .sd-embedded .sd-station-row__metrics > div:first-child { + padding-left: 0; + } + + .sd-embedded .sd-station-row__metrics > div:last-child { + padding-right: 0; + } + + .sd-embedded .sd-station-row__trend { + grid-column: 1 / -1; + grid-row: 3; + padding-top: 14px; + } + + .sd-embedded .sd-station-row__trend-bars { + height: 84px; + gap: 6px; + } + + /* 单站 PC 使用左右分栏:左侧站点与经营指标,右侧直接展示近 7 日趋势。 */ + .sd-embedded .sd-station-row.is-solo { + min-height: 168px; + } + + .sd-embedded .sd-station-row.is-solo .sd-station-row__main { + grid-template-columns: 34px minmax(220px, .8fr) minmax(480px, 1.35fr) 28px; + grid-template-rows: auto minmax(0, 1fr); + column-gap: 12px; + min-height: 134px; + } + + .sd-embedded .sd-station-row.is-solo .sd-station-row__metrics { + grid-column: 1 / 3; + grid-row: 2; + align-self: stretch; + align-items: center; + } + + .sd-embedded .sd-station-row.is-solo .sd-station-row__trend { + grid-column: 3 / 5; + grid-row: 1 / 3; + align-self: stretch; + padding: 2px 0 0 24px; + border-top: 0; + border-left: 1px solid #edf1f6; + } + + .sd-embedded .sd-station-row.is-solo .sd-station-row__trend-bars { + height: 104px; + } + + .sd-embedded .sd-station-row.is-solo .sd-station-card__go { + grid-column: 4; + grid-row: 1; + } +} + +@media (max-width: 1100px) { + .sd-report-row, + .sd-dual--cash { + grid-template-columns: minmax(0, 1fr); + } + .sd-station-row__main { + display: flex; + flex-wrap: wrap; + align-items: center; + } + .sd-station-row__metrics { + flex: 1 1 100%; + } + .sd-station-row__main > .sd-spark { + flex: 1 1 140px; + } +} + +@media (max-width: 1024px) { + .sd-hero-kpis, + .sd-station-grid { + grid-template-columns: 1fr 1fr; + } + .sd-dual, + .sd-dual--stack, + .sd-share-legend { + grid-template-columns: minmax(0, 1fr); + } + .sd-trend__date { + font-size: 9px; + } +} + +@media (max-width: 767px) { + .sd-trend__date--desktop { + display: none; + } + .sd-trend__date--mobile { + display: inline; + } + .ehb-shell--station-daily .ehb-body.sd-body { + padding: 16px 16px 28px; + } + .sd-hero-kpis, + .sd-station-grid { + grid-template-columns: 1fr; + } + .sd-hero-kpi__value { + font-size: 18px; + } + .sd-panel__head-row { + flex-direction: column; + align-items: stretch; + } + .sd-msel__trigger { + max-width: none; + width: 100%; + } + .sd-msel__panel { + left: 0; + right: 0; + width: auto; + } + .sd-updated { + width: 100%; + justify-content: center; + } + .sd-station-row__metrics { + grid-template-columns: 1fr 1fr; + } + .sd-kpi-modal { + padding: 12px; + align-items: flex-end; + } +} + +/* 嵌入经营看板的移动端单站日报:紧凑筛选 + 2×2核心指标。 */ +.sd-mobile-operating-overview { display: none; } + +@media (max-width: 767px) { + .sd-embedded { + margin-top: 0; + padding: 0 2px 8px; + } + .sd-embedded .sd-topbar--embedded { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 7px; + margin-bottom: 10px; + padding: 10px 12px; + border: 1px solid #dfe7f1; + border-radius: 14px; + background: #fff; + } + .sd-embedded .sd-topbar__lead, + .sd-embedded .sd-topbar__tools { width: 100%; } + .sd-embedded .sd-topbar__updated { + margin: 0; + color: #71819b; + font-size: 10px; + line-height: 1.3; + } + .sd-embedded .sd-topbar__tools { + display: grid; + grid-template-columns: minmax(0, 1fr) 38px; + align-items: center; + gap: 7px; + } + .sd-embedded .sd-date, + .sd-embedded .sd-date__trigger { width: 100%; } + .sd-embedded .sd-date__trigger { + min-height: 44px; + height: 44px; + justify-content: space-between; + padding: 0 10px; + } + .sd-embedded .sd-date__label { font-size: 10px; } + .sd-embedded .sd-date__value { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } + .sd-embedded .sd-date__popover--range { + position: fixed; + top: auto; + right: 12px; + bottom: 74px; + left: 12px; + width: auto; + max-height: calc(100dvh - 90px); + overflow-y: auto; + overscroll-behavior: contain; + z-index: 120; + } + .sd-embedded .sd-btn--ghost { + width: 44px; + min-width: 44px; + min-height: 44px; + padding: 0; + font-size: 0; + } + .sd-embedded .sd-btn--ghost svg { width: 16px; height: 16px; } + + .sd-embedded .sd-hero-kpis { display: none; } + .sd-mobile-operating-overview { + display: block; + margin-bottom: 14px; + padding: 14px; + border: 1px solid #dbe5f0; + border-radius: 16px; + background: #fff; + } + .sd-mobile-operating-overview__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + } + .sd-mobile-operating-overview__head > div { display: grid; gap: 3px; } + .sd-mobile-operating-overview__head span { color: #18263d; font-size: 15px; font-weight: 750; } + .sd-mobile-operating-overview__head small { color: #7a8ba4; font: 500 10px/1.4 var(--sd-mono); } + .sd-mobile-operating-overview__head > strong { + padding: 5px 8px; + border-radius: 999px; + background: #eef4ff; + color: #2f6bff; + font-size: 10px; + } + .sd-mobile-operating-overview__summary { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr); + overflow: hidden; + border-radius: 13px; + background: #f3f7ff; + } + .sd-mobile-operating-overview__primary { + display: grid; + width: 100%; + padding: 14px 16px; + border: 0; + border-radius: 0; + border-inline-end: 1px solid #dce6f4; + background: transparent; + color: #526987; + text-align: left; + } + .sd-mobile-operating-overview__primary > span { font-size: 11px; font-weight: 650; } + .sd-mobile-operating-overview__primary > strong { margin-top: 4px; color: #245fd4; font: 750 28px/1.15 var(--sd-mono); } + .sd-mobile-operating-overview__primary > strong small { font: 600 11px/1 var(--sd-font); } + .sd-mobile-operating-overview__primary > em { display: inline-flex; align-items: center; gap: 5px; margin-top: 8px; color: #71819b; font: 500 10px/1.3 var(--sd-font); } + .sd-mobile-operating-overview__financials { + display: grid; + grid-template-rows: 1fr 1fr; + padding: 10px 14px; + } + .sd-mobile-operating-overview__financials > * { + display: grid; + min-width: 0; + gap: 5px; + align-content: center; + padding: 8px 0; + border: 0; + border-bottom: 1px solid #dce6f4; + background: transparent; + text-align: left; + } + .sd-mobile-operating-overview__financials > *:last-child { border-bottom: 0; } + .sd-mobile-operating-overview__financials span { color: #7a8ba4; font-size: 10px; } + .sd-mobile-operating-overview__financials strong { overflow: hidden; color: #18263d; font: 700 13px/1.25 var(--sd-mono); text-overflow: ellipsis; white-space: nowrap; } + .sd-mobile-operating-overview__financials button strong { color: #e66f16; } + .sd-station-board__fullscreen { display: none; } + .sd-embedded .sd-station-board[data-mobile-fullscreen-list] > .sd-section-head { + min-height: 40px; + align-items: center; + margin-bottom: 10px; + padding-inline-end: 0 !important; + flex-wrap: nowrap; + } + .sd-embedded .sd-station-filter-select { + width: 132px; + max-width: none; + min-height: 44px; + flex: 0 0 132px; + margin-left: auto; + } + .sd-embedded .sd-station-single-card { gap: 12px; } + .sd-embedded .sd-station-row { padding: 14px; border-radius: 14px; } + .sd-embedded .sd-station-row__main { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) 24px; + align-items: center; + gap: 8px 10px; + } + .sd-embedded .sd-station-row__id { min-width: 0; } + .sd-embedded .sd-station-row__metrics { grid-column: 1 / -1; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 6px; } + .sd-embedded .sd-station-row__metrics > div { padding: 0 10px; text-align: center; } + .sd-embedded .sd-station-row__metrics > div:first-child { padding-left: 0; text-align: left; } + .sd-embedded .sd-station-row__metrics > div:last-child { padding-right: 0; text-align: right; } + .sd-embedded .sd-station-row__metrics strong { font-size: 14px; } + .sd-embedded .sd-station-row__trend { grid-column: 1 / -1; } + .sd-embedded .sd-station-row__trend-bars { height: 76px; gap: 5px; } + .sd-embedded .sd-station-row__trend-col i { width: min(28px, 68%); } + .sd-embedded .sd-station-card__go { grid-column: 3; grid-row: 1; width: 24px; height: 24px; } + .sd-embedded .sd-hero-kpi { + min-width: 0; + min-height: 104px; + padding: 12px; + border-radius: 14px; + } + .sd-embedded .sd-hero-kpi__label { margin-bottom: 5px; font-size: 11px; } + .sd-embedded .sd-hero-kpi__value { margin-bottom: 6px; font-size: 21px; } + .sd-embedded .sd-hero-kpi__sub { + overflow: hidden; + padding: 5px 7px; + font-size: 9px; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; + } + + .sd-embedded .sd-detail-top { + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + gap: 8px 10px; + margin-bottom: 10px; + padding: 10px 12px; + border: 1px solid #dfe7f1; + border-radius: 14px; + background: #fff; + } + .sd-embedded .sd-detail-top__lead { display: contents; } + .sd-embedded .sd-detail-top__lead > .sd-btn--ghost { + grid-column: 1; + grid-row: 1; + width: 44px; + min-width: 44px; + min-height: 44px; + padding: 0; + font-size: 0; + } + .sd-embedded .sd-detail-top__lead > div { + grid-column: 2; + grid-row: 1; + min-width: 0; + } + .sd-embedded .sd-detail-top__title { + overflow: hidden; + font-size: 16px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; + } + .sd-embedded .sd-detail-top__meta, + .sd-embedded .sd-detail-top__updated { + overflow: hidden; + margin-top: 3px; + font-size: 10px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; + } + .sd-embedded .sd-detail-top__tools { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(0, 1fr) 44px; + align-items: center; + gap: 7px; + width: 100%; + } + .sd-embedded .sd-detail-top__tools .sd-date, + .sd-embedded .sd-detail-top__tools .sd-date__trigger { width: 100%; } + .sd-embedded .sd-detail-top__tools > .sd-btn--ghost { + width: 44px; + min-width: 44px; + min-height: 44px; + padding: 0; + font-size: 0; + } + .sd-embedded .sd-detail-top__tools > .sd-btn--ghost svg { width: 16px; height: 16px; } + .sd-embedded .sd-hero-kpis--detail .sd-hero-kpi { min-height: 104px; } + .sd-mobile-customer-filter .sd-msel__panel { + right: 0; + left: auto; + width: min(340px, calc(100vw - 32px)); + } + .sd-mobile-customer-filter .sd-msel__search input { height: 44px; } + .sd-mobile-customer-filter .sd-msel__actions button { min-height: 44px; } + .sd-mobile-customer-filter .sd-msel__opt { min-height: 44px; box-sizing: border-box; } + .sd-mobile-customer-filter .sd-msel__opt { align-items: center; } + .sd-mobile-customer-filter .sd-msel__name { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} +.sd-spark__bar, +.sd-station-row__share-bar > i, +.sd-trend__bar, +.sd-trend-legend-dot { + opacity: 0.78; +} + +@media (max-width: 767px) { + .sd-customer-month-head { + width: 100%; + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + + .sd-customer-month-tabs { + width: 100%; + } + + .sd-customer-month-tabs button { + min-width: 0; + min-height: 44px; + } +} + +/* 单站下钻保持 8113 原型的信息顺序;窄屏仅滚动表格,不改造成另一套页面。 */ +@media (max-width: 767px) { + .sd-embedded .sd-detail .sd-hero-kpis--detail { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .sd-embedded .sd-detail .sd-mobile-detail-hub { + display: contents; + } + + .sd-embedded .sd-detail .sd-mobile-detail-tabs, + .sd-embedded .sd-detail .sd-mobile-record-list, + .sd-embedded .sd-detail .sd-mobile-combined-fullscreen-table { + display: none !important; + } + + .sd-embedded .sd-detail .sd-mobile-detail-panel, + .sd-embedded .sd-detail .sd-mobile-detail-panel:not(.is-active) { + display: block; + margin-top: 16px; + border: 1px solid var(--sd-line); + border-radius: 12px; + } + + .sd-embedded .sd-detail .sd-mobile-detail-panel .sd-panel__title { + display: block; + } + + .sd-embedded .sd-detail .sd-mobile-detail-panel .sd-panel__head-row { + display: flex; + } + + .sd-embedded .sd-detail .sd-mobile-detail-panel .sd-more-btn { + display: inline-flex; + } + + .sd-embedded .sd-detail .sd-mobile-detail-panel > .sd-table-scroll, + .sd-embedded .sd-detail .sd-desktop-matrix-table { + display: block; + } + + .sd-embedded .sd-detail .sd-mobile-trend-panel { + order: initial; + } + + .sd-embedded .sd-detail .sd-dual--ledger { + display: grid; + grid-template-columns: minmax(0, 1fr); + } +} +/* Mobile list pilot: compact reading first; original tables remain for reconciliation. */ +@media (max-width: 767px) { + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub { + display: block; + margin-top: 16px; + } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-tabs { + display: block !important; + border-radius: 12px; + padding: 10px; + } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-tabs__title { + font-size: 14px; + } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-tabs .mobile-list-fullscreen-trigger { + min-height: 44px; + } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel:not(.is-active), + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-trend-panel, + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel > .sd-table-scroll, + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel > .sd-panel__head-row, + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel > .sd-more-btn { + display: none; + } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel.is-active { + display: block; + margin-top: 10px; + padding: 10px; + } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-dual--ledger { display: contents; } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel > .sd-panel__title { + padding: 0 0 8px !important; + font-size: 13px; + } + /* Non-pilot settlement/intake panels keep their existing truthful tables. */ + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-dual--ledger .sd-table-scroll { + display: block; + } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub:not([data-mobile-fullscreen-active="true"]) .sd-external-receipts > .sd-table-scroll { + display: none; + } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub:not([data-mobile-fullscreen-active="true"]) .sd-external-receipts > .sd-mobile-record-list { + display: grid !important; + gap: 8px; + } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-external-receipts > .sd-more-btn { + display: inline-flex; + } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] { + display: flex; + flex-direction: column; + margin: 0; + } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-list-pilot { + display: none !important; + } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-detail-panel.is-active { + flex: 1; + min-height: 0; + overflow: auto; + } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-detail-panel.is-active > .sd-table-scroll, + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-detail-panel.is-active > .sd-panel__head-row, + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-detail-panel.is-active > .sd-more-btn { + display: block; + } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] .sd-mobile-combined-fullscreen-table { + display: none !important; + } +} + +.sd-live-loading-overlay { + position: fixed; + z-index: 480; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 8px; + background: rgb(244 247 251 / 96%); + color: #64748b; + font-size: 12px; + backdrop-filter: blur(3px); +} + +.sd-live-loading-overlay strong { + color: #1e293b; + font-size: 15px; +} + +.sd-live-loading-spinner { + width: 28px; + height: 28px; + border: 3px solid #dbeafe; + border-top-color: #2f6bff; + border-radius: 50%; + animation: sd-live-spin .75s linear infinite; +} + +@keyframes sd-live-spin { to { transform: rotate(360deg); } } + +@media (prefers-reduced-motion: reduce) { + .sd-live-loading-spinner { animation: none; } +} diff --git a/src/modules/energy/hydrogen/types.ts b/src/modules/energy/hydrogen/types.ts new file mode 100644 index 0000000..fd13c46 --- /dev/null +++ b/src/modules/energy/hydrogen/types.ts @@ -0,0 +1,297 @@ +export type H2BiScope = "global" | "station"; +export type H2BiView = "overview" | "daily"; +export type H2BiVehicleScope = "all" | "lingniu" | "external"; +export type H2BiVerifyScope = "all" | "verified" | "unverified"; +/** 账本 settlement_type 的承担口径;下钻、KPI 与趋势图必须使用同一口径。 */ +export type H2BiAmountScope = "all" | "customer" | "company" | "other"; +export type H2BiRegionGranularity = "province" | "city"; + +export interface H2BiQuery { + year: number; + startDate?: string; + endDate?: string; + date?: string; + month?: string; + stationId?: string | number | null; + customerId?: number | null; + customerName?: string | null; + plateNo?: string | null; + region?: string | null; + regionGranularity?: H2BiRegionGranularity; + vehicleScope: H2BiVehicleScope; + verifyScope: H2BiVerifyScope; +} + +export interface H2BiRange { + startDate: string | null; + endDate: string | null; +} + +export interface H2BiWatermark { + ledgerAt: string | null; + paymentAt: string | null; +} + +export interface H2BiStationOption { + id: string | number; + name: string; +} + +export interface H2BiMetaResponse { + years: Array<{ value: number; startDate: string; endDate: string }>; + stations: H2BiStationOption[]; + watermark: H2BiWatermark; +} + +export interface H2BiKpis { + totalKg: number; + totalCost: number; + /** 加氢量:账本 settlement_type=1(客户承担)。 */ + customerBearingKg: number; + /** 加氢量:账本 settlement_type=2(我司承担)。 */ + companyBearingKg: number; + /** 加氢量:其他承担方式(含客户自行结算、未知)。 */ + otherBearingKg: number; + /** 对客金额:账本 settlement_type=1 的 fee_total。 */ + customerRevenue: number; + /** 成本金额:账本 settlement_type=1 的 cost_total。 */ + customerCost: number; + /** 成本金额:账本 settlement_type=2 的 cost_total。 */ + companyCost: number; + /** 成本金额:其他承担方式的 cost_total。 */ + otherCost: number; + totalRevenue: number; + customerGrossProfit: number; + monthKg: number; + monthCost: number; + todayKg: number; + todayCost: number; + monthShareOfRange: number; + todayShareOfMonth: number; + recordCount: number; + stationCount: number; +} + +export interface H2BiMonthlyPoint { + month: string; + totalKg: number; + lingniuKg: number; + externalKg: number; + cost: number; + customerCost: number; + companyCost: number; + otherCost: number; + revenue: number; + customerRevenue: number; + customerGrossProfit: number; +} + +export interface H2BiRegionRow { + region: string; + kg: number; + share: number; +} + +export interface H2BiStationRow { + rank?: number; + id: string | number | null; + name: string; + province: string | null; + city: string | null; + kg: number; + lingniuKg: number; + externalKg: number; + cost: number; + revenue: number; + /** 对客金额:账本 settlement_type=1 的 fee_total。 */ + customerRevenue: number; + customerCost: number; + companyCost: number; + otherCost: number; + recordCount: number; + customerCount: number; + share: number; +} + +export interface H2BiCustomerRow { + rank?: number; + id: number | null; + name: string; + kg: number; + /** 加氢订单中账本 settlement_type=1(客户承担)的量。 */ + customerBearingKg: number; + /** 加氢订单中账本 settlement_type=2(我司承担)的量。 */ + companyBearingKg: number; + /** 其他承担方式(含客户自行结算、未知)的量。 */ + otherBearingKg: number; + bearer: "company" | "customer" | "both" | "other"; + cost: number; + revenue: number; + customerRevenue: number; + customerCost: number; + companyCost: number; + otherCost: number; + recordCount: number; +} + +export interface H2BiOverviewResponse { + range: H2BiRange; + watermark: H2BiWatermark; + filters: Partial; + kpis: H2BiKpis; + monthly: H2BiMonthlyPoint[]; + topStations: H2BiStationRow[]; + regions: H2BiRegionRow[]; + stations: H2BiStationRow[]; + customers: H2BiCustomerRow[]; +} + +export interface H2BiDailyPoint { + date: string; + kg: number; + lingniuKg: number; + externalKg: number; + cost: number; + recordCount: number; +} + +export interface H2BiDailyRow extends H2BiDailyPoint { + stationCount?: number; +} + +export interface H2BiDailyKpis { + totalKg: number; + totalCost: number; + averageDailyKg: number; + stationCount: number; + activeDays: number; +} + +export interface H2BiDailyResponse { + range: H2BiRange; + watermark: H2BiWatermark; + filters: Partial; + kpis: H2BiDailyKpis; + trend: H2BiDailyPoint[]; + days: H2BiDailyRow[]; +} + +export type H2BiDrillMetric = + | "totalKg" + | "totalCost" + | "totalRevenue" + | "customerGrossProfit" + | "monthKg" + | "todayKg" + | "station" + | "customer" + | "day"; +export type H2BiDrillGroupBy = + "station" | "customer" | "date" | "vehicle" | "record"; + +export interface H2BiDrillQuery extends H2BiQuery { + groupBy: H2BiDrillGroupBy; + amountScope?: H2BiAmountScope; + customerId?: number | null; + plateNo?: string | null; + page?: number; + pageSize?: number; +} + +export interface H2BiDrillSummary { + label?: string; + value?: number; + count?: number; + [key: string]: string | number | null | undefined; +} + +export type H2BiDrillRecord = Record< + string, + string | number | boolean | null | undefined +>; + +export interface H2BiDrillGroupRow { + /** Distinct ledger settlement types for this group; never the UI filter. */ + settlementTypes?: string | null; + [key: string]: string | number | boolean | null | undefined; + id: string; + name: string; + province: string | null; + city: string | null; + recordCount: number; + stationCount: number; + customerCount: number; + kg: number; + cost: number; + revenue: number; + lingniuKg: number; + externalKg: number; +} + +export interface H2BiDrillResponse { + groupBy: H2BiDrillGroupBy; + amountScope: H2BiAmountScope; + filters: Partial; + summary: H2BiDrillSummary; + groups: H2BiDrillGroupRow[]; + records: H2BiDrillRecord[]; + page: { + page: number; + pageSize: number; + /** Number of rows returned on this page (groups or records). */ + itemCount?: number; + hasMore: boolean; + }; +} + +/** Options shared by paged drill reads and explicit full exports. */ +export interface H2BiDrillReadOptions { + signal?: AbortSignal; +} + +/** + * A deliberately bounded, complete drill read. This is for exports and + * dedicated detail views only; normal drill tables should use one page. + */ +export interface H2BiFullDrillResponse extends H2BiDrillResponse { + page: H2BiDrillResponse["page"] & { + pagesRead: number; + complete: true; + }; +} + +export interface H2BiFullDrillOptions extends H2BiDrillReadOptions { + /** Protect the browser from an accidental unbounded export. Default: 250. */ + maxPages?: number; + /** Reject instead of retaining an unexpectedly huge complete result. Default: 50000. */ + maxRows?: number; + /** API page size, capped by the server at 200. Default: 200. */ + pageSize?: number; +} + +export interface H2BiDailyTreeCustomer { + id: number; + name: string; + kg: number; + cost: number; + recordCount: number; +} + +export interface H2BiDailyTreeStation { + id: string | number; + name: string; + kg: number; + cost: number; + recordCount: number; + customers: H2BiDailyTreeCustomer[]; +} + +export interface H2BiDailyTreeResponse { + date: string; + stations: H2BiDailyTreeStation[]; +} + +export type H2BiDailyTreeQuery = Omit< + Partial, + "date" | "startDate" | "endDate" | "month" +>; diff --git a/src/modules/energy/types.ts b/src/modules/energy/types.ts index a1c27c2..1f5095e 100644 --- a/src/modules/energy/types.ts +++ b/src/modules/energy/types.ts @@ -1,77 +1,79 @@ -export type CustomerType = 'external' | 'lingniu'; +/** 能源模块(电能 / ETC / 单站日报)共用的 DTO 与筛选枚举。 */ + +export type CustomerType = 'all' | 'external' | 'lingniu'; export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15'; -export interface HydrogenKpi { - yearKg: number; - yearFee: number; - yearRevenue: number; - yearProfit: number; - ourYearKg: number; - ourYearFee: number; - customerYearKg: number; - monthKg: number; - monthFee: number; - monthRevenue: number; - monthProfit: number; - todayKg: number; - todayFee: number; - todayRevenue: number; - todayProfit: number; - lingniuBornKg: number; - lingniuBornFee: number; -} - -export interface HydrogenStationTop { - rank: number; +export interface HydrogenStationBoardStation { + id: number; name: string; + province: string; + city: string; kg: number; fee: number; + recordCount: number; + paymentAmount: number; + paymentCount: number; share: number; + latestLedgerTime: string | null; + latestPaymentDate: string | null; + // 区间内逐日真实加氢量,仅供单站总览的迷你趋势柱使用。 + // 空日期由服务端补零,绝不使用前端模拟数据。 + dailyKg: Array<{ date: string; kg: number }>; } -export interface HydrogenRegionShare { - region: string; - kg: number; - share: number; -} - -export interface HydrogenMonthlyPoint { - month: string; // YYYY-MM - kg: number; - fee: number; - revenue: number; - profit: number; -} - -export interface HydrogenCustomerRow { - name: string; - payer: 'lingniu' | 'customer'; - kg: number; - cost: number; - revenue: number; -} - -export interface HydrogenStationFull { - name: string; - kg: number; - revenue: number; - share: number; // 加氢量占比 - revenueShare: number;// 收入占比 -} - -export interface HydrogenStationRow { - name: string; - pricePerKg: number; - kg: number; - chainPct: number; -} - -export interface HydrogenDailyRow { +export interface HydrogenStationBoardDailyRow { date: string; - totalKg: number; - chainPct: number; - customerType: CustomerType; - stations: HydrogenStationRow[]; + kg: number; + fee: number; + avgPrice: number; + recordCount: number; + changeKg: number; + paymentAmount: number; + paymentCount: number; +} + +export interface HydrogenStationBoardSummaryDailyRow { + date: string; + kg: number; + fee: number; + recordCount: number; + paymentAmount: number; + paymentCount: number; +} + +export interface HydrogenStationBoardCustomerMonth { + month: string; + customerName: string; + kg: number; + fee: number; + recordCount: number; +} + +export interface HydrogenStationBoardResponse { + range: { start: string; end: string }; + summary: { + stationCount: number; + activeStationCount: number; + totalKg: number; + totalFee: number; + recordCount: number; + paymentAmount: number; + paymentCount: number; + latestLedgerTime: string | null; + daily: HydrogenStationBoardSummaryDailyRow[]; + }; + stations: HydrogenStationBoardStation[]; + selected: { + daily: HydrogenStationBoardDailyRow[]; + customerMonths: HydrogenStationBoardCustomerMonth[]; + externalCustomerMonths?: HydrogenStationBoardCustomerMonth[]; + externalReceipts?: { + scope: 'customer'; + reason?: string; + rows: Array<{ id: string; date: string; customerName: string; amount: number; + payMethod: string; source: string; sourceRecordCount: number; updatedAt: string | null }>; + }; + } | null; } export interface ElectricKpi { diff --git a/src/modules/hydrogen-heatmap/HydrogenAmapCanvas.tsx b/src/modules/hydrogen-heatmap/HydrogenAmapCanvas.tsx index 3ac891f..e6b6cff 100644 --- a/src/modules/hydrogen-heatmap/HydrogenAmapCanvas.tsx +++ b/src/modules/hydrogen-heatmap/HydrogenAmapCanvas.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { AmapConfig, HydrogenHeatmapMetric, HydrogenHeatmapPoint } from './types'; +import { createHeatmapMap, loadAmap, type AmapInstance } from '../../shared/amap'; type Props = { config: AmapConfig; @@ -10,19 +11,6 @@ type Props = { onMapClick: (longitude: number, latitude: number) => void; }; -type AmapInstance = { - Map: new (container: HTMLElement, options: Record) => any; - HeatMap: new (map: any, options: Record) => any; - ToolBar: new (options?: Record) => any; - Scale: new (options?: Record) => any; - Bounds: new (southWest: [number, number], northEast: [number, number]) => any; -}; - -declare global { - interface Window { - _AMapSecurityConfig?: { securityJsCode: string }; - } -} function getBounds(points: HydrogenHeatmapPoint[]) { if (!points.length) return null; @@ -62,36 +50,9 @@ export default function HydrogenAmapCanvas({ config, points, max, metric, focusQ async function initialize() { try { - window._AMapSecurityConfig = { securityJsCode: config.securityCode }; - const loaderModule = await import('@amap/amap-jsapi-loader'); - const AMap = await loaderModule.default.load({ - key: config.key, - version: '2.0', - plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'], - }) as unknown as AmapInstance; + const AMap = await loadAmap(config); if (cancelled || !container) return; - const map = new AMap.Map(container, { - viewMode: '2D', - zoom: 5, - center: [105.4, 34.4], - mapStyle: 'amap://styles/whitesmoke', - resizeEnable: true, - showLabel: true, - }); - map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } })); - map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } })); - const heatmap = new AMap.HeatMap(map, { - radius: 34, - opacity: [0.14, 0.84], - gradient: { - 0.1: '#2563eb', - 0.3: '#0891b2', - 0.5: '#16a34a', - 0.68: '#eab308', - 0.84: '#f97316', - 1: '#dc2626', - }, - }); + const { map, heatmap } = createHeatmapMap(AMap, container, { radius: 34, opacity: [0.14, 0.84] }); map.on('click', (event: any) => clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat())); mapRef.current = map; heatmapRef.current = heatmap; diff --git a/src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx b/src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx index 83a2dbd..4ef5cee 100644 --- a/src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx +++ b/src/modules/hydrogen-heatmap/HydrogenHeatmapModule.tsx @@ -4,10 +4,12 @@ import HydrogenAmapCanvas from './HydrogenAmapCanvas'; import HydrogenHeatmapDetailPanel from './HydrogenHeatmapDetailPanel'; import HydrogenHeatmapFilters from './HydrogenHeatmapFilters'; import { fetchAmapConfig, fetchHydrogenHeatmapMeta, fetchHydrogenHeatmapPoints, fetchNearbyHydrogenStations } from './api'; +import { recentDayRange } from '../../shared/date-range'; +import { HEATMAP_LEGEND_GRADIENT } from '../../shared/amap'; import type { AmapConfig, HydrogenHeatmapMeta, HydrogenHeatmapMetric, HydrogenHeatmapResponse, HydrogenNearbyResponse, HydrogenPayer } from './types'; -const DEFAULT_START = '2026-01-01'; -const DEFAULT_END = '2026-07-13'; +// 接口返回数据水位前的兜底范围:近 30 天滚动窗口(不再写死历史区间)。 +const FALLBACK_RANGE = recentDayRange(30); const integerFormat = new Intl.NumberFormat('zh-CN'); const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }); @@ -37,8 +39,8 @@ export default function HydrogenHeatmapModule() { const [config, setConfig] = useState(null); const [data, setData] = useState(null); const [nearby, setNearby] = useState(null); - const [startDate, setStartDate] = useState(DEFAULT_START); - const [endDate, setEndDate] = useState(DEFAULT_END); + const [startDate, setStartDate] = useState(FALLBACK_RANGE.start); + const [endDate, setEndDate] = useState(FALLBACK_RANGE.end); const [query, setQuery] = useState(''); const [payer, setPayer] = useState('all'); const [metric, setMetric] = useState('kg'); @@ -112,8 +114,8 @@ export default function HydrogenHeatmapModule() { }, [deferredQuery, endDate, metric, payer, startDate]); const reset = () => { - setStartDate(meta?.startDate || DEFAULT_START); - setEndDate(meta?.endDate || DEFAULT_END); + setStartDate(meta?.startDate || FALLBACK_RANGE.start); + setEndDate(meta?.endDate || FALLBACK_RANGE.end); setQuery(''); setPayer('all'); setMetric('kg'); @@ -164,7 +166,7 @@ export default function HydrogenHeatmapModule() {
-

数据更新至 {meta?.endDate || DEFAULT_END}

+

数据更新至 {meta?.endDate || FALLBACK_RANGE.end}

@@ -180,8 +182,8 @@ export default function HydrogenHeatmapModule() {
{metricName(metric)}{loading ? : null}

{metricDescription(metric)}

-
+
相对较低相对较高
diff --git a/src/modules/mileage/VehicleDetailModal.tsx b/src/modules/mileage/VehicleDetailModal.tsx index 5ef2a0a..8ce82f1 100644 --- a/src/modules/mileage/VehicleDetailModal.tsx +++ b/src/modules/mileage/VehicleDetailModal.tsx @@ -6,7 +6,6 @@ import { } from 'recharts'; import type { MileageSourceGroup, MonitoringVehicle } from './types'; import { fetchVehicleRecent, type VehicleRecentDay } from './api'; -import Blur from '../../components/Blur'; interface Props { vehicle: MonitoringVehicle | null; @@ -160,7 +159,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
- {vehicle.plate} + {vehicle.plate} {vehicle.isOnline ? '在线' : '离线'} @@ -169,7 +168,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: {vehicle.rentStatus || ''} {vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''} {vehicle.customer ? ` · ` : ''} - {vehicle.customer && {vehicle.customer}} + {vehicle.customer}
diff --git a/src/modules/mileage/daily-report/VehicleTable.tsx b/src/modules/mileage/daily-report/VehicleTable.tsx index 6c3aa35..5a71d3c 100644 --- a/src/modules/mileage/daily-report/VehicleTable.tsx +++ b/src/modules/mileage/daily-report/VehicleTable.tsx @@ -21,7 +21,6 @@ import { XAxis, YAxis, } from 'recharts'; -import Blur from '../../../components/Blur'; import type { MileageReportGroup } from '../api'; import { filterAndSortVehicles, @@ -167,7 +166,7 @@ export default function VehicleTable({ group }: { group: MileageReportGroup }) {
- {trendVehicle.plate} + {trendVehicle.plate} 近7日里程
@@ -249,12 +248,12 @@ export default function VehicleTable({ group }: { group: MileageReportGroup }) { className={`grid cursor-pointer grid-cols-[112px_88px_minmax(140px,1fr)_82px_90px_80px_28px] items-center gap-3 px-3 py-2.5 text-[11px] font-bold outline-none transition-colors hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 md:grid-cols-[110px_72px_100px_minmax(160px,1fr)_90px_100px_88px_32px] ${selected ? 'bg-blue-50/60' : ''}`} > - {vehicle.plate} + {vehicle.plate} {vehicle.status} {vehicle.department || vehicle.inventoryRegion || '未标注'} - {vehicle.customer || '未绑定客户'} + {vehicle.customer || '未绑定客户'} 0 ? 'text-rose-600' : 'text-slate-700'}`}> {fmtKm(vehicle.dailyMileage)} km diff --git a/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx b/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx index 38ea06e..50af1f6 100644 --- a/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx +++ b/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx @@ -1,7 +1,6 @@ import type { Dispatch, SetStateAction } from 'react'; import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react'; import { motion } from 'motion/react'; -import Blur from '../../../../components/Blur'; import type { MileageSourceGroup, MonitoringFilters, MonitoringStats, MonitoringVehicle } from '../../types'; import { vehicleStatisticTime } from '../oneos-time'; import { MILEAGE_SOURCE_META, vehicleSourceDisplay } from '../source-display'; @@ -292,7 +291,7 @@ export default function FullscreenMonitoring({
-
{v.plate}
+
{v.plate}
- {v.customer || '-'} + {v.customer || '-'} {v.brand || '-'} {v.rentStatus || '-'} {v.department || '-'} diff --git a/src/modules/mileage/monitoring/components/VehicleList.tsx b/src/modules/mileage/monitoring/components/VehicleList.tsx index 922523c..a6015c7 100644 --- a/src/modules/mileage/monitoring/components/VehicleList.tsx +++ b/src/modules/mileage/monitoring/components/VehicleList.tsx @@ -1,7 +1,6 @@ import type { RefObject } from 'react'; import { Truck } from 'lucide-react'; import { motion } from 'motion/react'; -import Blur from '../../../../components/Blur'; import type { MonitoringVehicle } from '../../types'; import { vehicleStatisticTime } from '../oneos-time'; import { vehicleSourceDisplay } from '../source-display'; @@ -73,7 +72,7 @@ export default function VehicleList({
- {v.plate} + {v.plate} {v.isOnline ? '在线' : '离线'} @@ -89,12 +88,12 @@ export default function VehicleList({
{v.rentStatus || ''}{v.department ? ` · ${v.department.replace('业务', '')}` : ''} - {v.customer || '-'} + {v.customer || '-'}
-
{v.customer || '-'}
+
{v.customer || '-'}
{[v.rentStatus, v.department?.replace('业务', ''), v.project].filter(Boolean).join(' · ') || '暂无归属信息'}
diff --git a/src/modules/mileage/statistics/AllVehiclesPanel.tsx b/src/modules/mileage/statistics/AllVehiclesPanel.tsx index ff356d0..dfb0995 100644 --- a/src/modules/mileage/statistics/AllVehiclesPanel.tsx +++ b/src/modules/mileage/statistics/AllVehiclesPanel.tsx @@ -1,7 +1,6 @@ import { useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import { ArrowUpDown, Calendar, Search, Truck, X } from 'lucide-react'; -import Blur from '../../../components/Blur'; import type { TargetVehicle } from '../types'; import { filterAndSortTargetVehicles, @@ -116,7 +115,7 @@ export default function AllVehiclesPanel({
- {vehicle.plateNumber} + {vehicle.plateNumber} {vehicle.isOnline ? '在线' : '离线'} diff --git a/src/modules/mileage/statistics/TargetDetailPanel.tsx b/src/modules/mileage/statistics/TargetDetailPanel.tsx index 0f4ac5b..1bfbecd 100644 --- a/src/modules/mileage/statistics/TargetDetailPanel.tsx +++ b/src/modules/mileage/statistics/TargetDetailPanel.tsx @@ -1,6 +1,5 @@ import { AnimatePresence, motion } from 'motion/react'; import { ChevronDown, Maximize2, Truck } from 'lucide-react'; -import Blur from '../../../components/Blur'; import type { TargetSummary, TargetVehicle } from '../types'; import { fmtDateLabel, fmtKm, fmtPercent, getTargetAssessment } from './model'; @@ -212,7 +211,7 @@ export default function TargetDetailPanel({ {vehicles.slice(0, 5).map(vehicle => (
- {vehicle.plateNumber} + {vehicle.plateNumber} 在线 diff --git a/src/modules/mileage/xlsx-export.ts b/src/modules/mileage/xlsx-export.ts index c77ffd7..eb489f6 100644 --- a/src/modules/mileage/xlsx-export.ts +++ b/src/modules/mileage/xlsx-export.ts @@ -1,4 +1,5 @@ import * as XLSX from 'xlsx'; +import { buildAoaSheet, writeWorkbook } from '../../shared/xlsx'; import type { MonitoringVehicle } from './types'; interface ExportContext { @@ -74,7 +75,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont }), ]; - const ws = XLSX.utils.aoa_to_sheet(summaryData); + const ws = buildAoaSheet(summaryData); const numFixedCols = BASE_HEADERS.length; const wsCols: { wch: number }[] = [ @@ -114,10 +115,8 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont } } - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, '车辆汇总'); - // 每日明细 sheet:保留原有格式 + const sheets: Array<{ name: string; sheet: XLSX.WorkSheet }> = [{ name: '车辆汇总', sheet: ws }]; if (dayKeys.length > 0) { const detailHeaders = [ '车牌号', '数据来源', '客户', '业务部门', '项目', '租赁状态', '运营区域', @@ -140,7 +139,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont v.totalKm != null ? v.totalKm : '', ]), ]; - const detailWs = XLSX.utils.aoa_to_sheet(detailData); + const detailWs = buildAoaSheet(detailData); detailWs['!cols'] = [ { wch: 12 }, { wch: 16 }, @@ -160,7 +159,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont if (detailWs[ref]?.t === 'n') detailWs[ref].z = '0.##########'; } } - XLSX.utils.book_append_sheet(wb, detailWs, '每日明细'); + sheets.push({ name: '每日明细', sheet: detailWs }); } const now = new Date(); @@ -178,5 +177,5 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont ? '统计时间' : isRange ? '区间' : '今日'; const filename = `里程看板_${dateTag}_${hh}${mm}_${sortLabel}.xlsx`; - XLSX.writeFile(wb, filename); + writeWorkbook(sheets, filename); } diff --git a/src/modules/scheduling/NotificationHistory.tsx b/src/modules/scheduling/NotificationHistory.tsx index 6f665f8..2821249 100644 --- a/src/modules/scheduling/NotificationHistory.tsx +++ b/src/modules/scheduling/NotificationHistory.tsx @@ -3,7 +3,6 @@ import { X, RotateCcw, Clock, CheckCircle2, XCircle, Send, Loader2, ChevronRight import { motion, AnimatePresence } from 'motion/react'; import { fetchNotifications, updateNotification } from './api'; import type { NotificationRecord, NotificationStatus, SchedulingSuggestion, CandidateVehicle } from './types'; -import Blur from '../../components/Blur'; import SwapPreview from './SwapPreview'; interface Props { @@ -201,9 +200,9 @@ export default function NotificationHistory({ onClose, onChange, recentOnly = fa >
- {rec.currentPlate} + {rec.currentPlate} - {rec.candidatePlate} + {rec.candidatePlate}
@@ -216,7 +215,7 @@ export default function NotificationHistory({ onClose, onChange, recentOnly = fa
{v.department && {shortDept(v.department)}} {v.manager && {v.manager}} - {v.customer || '-'} + {v.customer || '-'}
)}
@@ -281,9 +280,9 @@ export default function NotificationHistory({ onClose, onChange, recentOnly = fa
- {executeTarget.currentPlate} + {executeTarget.currentPlate} - {executeTarget.candidatePlate} + {executeTarget.candidatePlate}
diff --git a/src/modules/scheduling/SuggestionDetail.tsx b/src/modules/scheduling/SuggestionDetail.tsx index 1ced05d..dd1dc0c 100644 --- a/src/modules/scheduling/SuggestionDetail.tsx +++ b/src/modules/scheduling/SuggestionDetail.tsx @@ -4,7 +4,6 @@ import { } from 'lucide-react'; import { motion } from 'motion/react'; import type { SchedulingSuggestion, CandidateVehicle } from './types'; -import Blur from '../../components/Blur'; import SwapPreview from './SwapPreview'; type SortKey = 'predicted' | 'current'; @@ -83,7 +82,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
- {c.plateNumber} + {c.plateNumber} {c.region}{!c.isSameRegion && ' · 跨区'} @@ -174,7 +173,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce {/* Header — same style as candidate header */}
- {v.plateNumber} + {v.plateNumber} {v.region} {v.vehicleType} {v.targetName} @@ -189,7 +188,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce {v.department && {v.department}} {v.manager && {v.manager}} {(v.department || v.manager) && |} - 客户 {v.customer || '-'} + 客户 {v.customer || '-'} 30日均 {Math.round(v.customerAvgDaily)} km @@ -264,7 +263,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
- 此车已干预替换为 {activeIntervention.plateNumber}。如需更换方案,请先在该候选车处解除干预。 + 此车已干预替换为 {activeIntervention.plateNumber}。如需更换方案,请先在该候选车处解除干预。
)} diff --git a/src/modules/scheduling/SuggestionList.tsx b/src/modules/scheduling/SuggestionList.tsx index 4e6d478..ec9ba5d 100644 --- a/src/modules/scheduling/SuggestionList.tsx +++ b/src/modules/scheduling/SuggestionList.tsx @@ -2,7 +2,6 @@ import { useState, useMemo } from 'react'; import { ArrowRightLeft, ChevronRight, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, Check } from 'lucide-react'; import { motion } from 'motion/react'; import type { SchedulingSuggestion } from './types'; -import Blur from '../../components/Blur'; interface Props { suggestions: SchedulingSuggestion[]; @@ -126,7 +125,7 @@ export default function SuggestionList({ suggestions, onSelect, selectMode = fal
- {v.plateNumber} + {v.plateNumber} {v.vehicleType} · @@ -146,7 +145,7 @@ export default function SuggestionList({ suggestions, onSelect, selectMode = fal
{v.department && {v.department.replace('业务', '')}} {v.manager && {v.manager}} - {v.customer || '-'} + {v.customer || '-'}
diff --git a/src/modules/scheduling/SwapPreview.tsx b/src/modules/scheduling/SwapPreview.tsx index 5871e95..299e51c 100644 --- a/src/modules/scheduling/SwapPreview.tsx +++ b/src/modules/scheduling/SwapPreview.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { ArrowDownUp, CheckCircle, Send, X, Ban } from 'lucide-react'; import { sendNotify, updateNotification } from './api'; import type { SchedulingSuggestion, CandidateVehicle } from './types'; -import Blur from '../../components/Blur'; interface Props { suggestion: SchedulingSuggestion; @@ -72,7 +71,7 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
-
{v.plateNumber}
+
{v.plateNumber}
{v.vehicleType} · {v.targetName}
@@ -82,7 +81,7 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
- {v.customer || '-'} + {v.customer || '-'} 日均 {Math.round(v.customerAvgDaily)} 完成 = 1 ? 'text-emerald-600' : 'text-rose-500'}>{fmtRate(v.completionRate)}
@@ -99,7 +98,7 @@ export default function SwapPreview({ suggestion: s, candidate: c, onClose, onSu
-
{c.plateNumber}
+
{c.plateNumber}
{c.vehicleType} · {c.targetName || '库存'} · {c.region}
diff --git a/src/modules/scheduling/scheduling-module/BatchConfirmModal.tsx b/src/modules/scheduling/scheduling-module/BatchConfirmModal.tsx index 0045efc..ee2c16c 100644 --- a/src/modules/scheduling/scheduling-module/BatchConfirmModal.tsx +++ b/src/modules/scheduling/scheduling-module/BatchConfirmModal.tsx @@ -1,6 +1,5 @@ import { X } from 'lucide-react'; import { motion } from 'motion/react'; -import Blur from '../../../components/Blur'; import type { BatchItem } from './model'; interface BatchConfirmModalProps { @@ -44,9 +43,9 @@ export default function BatchConfirmModal({ {batchItems.map(({ suggestion, candidate }) => (
- {suggestion.currentVehicle.plateNumber} + {suggestion.currentVehicle.plateNumber} - {candidate.plateNumber} + {candidate.plateNumber}
{candidate.canQualifyAfterSwap ? ( 可达标 diff --git a/src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx b/src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx index 222f3e0..0fa7891 100644 --- a/src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx +++ b/src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { AmapConfig, HeatmapMetric, HeatmapPoint } from './types'; +import { createHeatmapMap, loadAmap, type AmapInstance } from '../../shared/amap'; type Props = { config: AmapConfig; @@ -10,19 +11,6 @@ type Props = { onMapClick: (longitude: number, latitude: number) => void; }; -type AmapInstance = { - Map: new (container: HTMLElement, options: Record) => any; - HeatMap: new (map: any, options: Record) => any; - ToolBar: new (options?: Record) => any; - Scale: new (options?: Record) => any; - Bounds: new (southWest: [number, number], northEast: [number, number]) => any; -}; - -declare global { - interface Window { - _AMapSecurityConfig?: { securityJsCode: string }; - } -} function getBounds(points: HeatmapPoint[]) { if (points.length === 0) return null; @@ -68,37 +56,9 @@ export default function AmapHeatmapCanvas({ config, points, max, metric, focusQu async function initialize() { try { - window._AMapSecurityConfig = { securityJsCode: config.securityCode }; - const loaderModule = await import('@amap/amap-jsapi-loader'); - const AMap = await loaderModule.default.load({ - key: config.key, - version: '2.0', - plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'], - }) as unknown as AmapInstance; + const AMap = await loadAmap(config); if (cancelled || !container) return; - - const map = new AMap.Map(container, { - viewMode: '2D', - zoom: 5, - center: [105.4, 34.4], - mapStyle: 'amap://styles/whitesmoke', - resizeEnable: true, - showLabel: true, - }); - map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } })); - map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } })); - const heatmap = new AMap.HeatMap(map, { - radius: 25, - opacity: [0.12, 0.82], - gradient: { - 0.1: '#2563eb', - 0.3: '#0891b2', - 0.5: '#16a34a', - 0.68: '#eab308', - 0.84: '#f97316', - 1: '#dc2626', - }, - }); + const { map, heatmap } = createHeatmapMap(AMap, container, { radius: 25, opacity: [0.12, 0.82] }); map.on('click', (event: any) => { clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat()); }); diff --git a/src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx b/src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx index b417aac..1b19006 100644 --- a/src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx +++ b/src/modules/vehicle-heatmap/VehicleHeatmapModule.tsx @@ -4,10 +4,12 @@ import AmapHeatmapCanvas from './AmapHeatmapCanvas'; import HeatmapDetailPanel from './HeatmapDetailPanel'; import HeatmapFilters from './HeatmapFilters'; import { fetchAmapConfig, fetchHeatmapMeta, fetchHeatmapPoints, fetchNearbyVehicles } from './api'; +import { recentDayRange } from '../../shared/date-range'; +import { HEATMAP_LEGEND_GRADIENT } from '../../shared/amap'; import type { AmapConfig, HeatmapMeta, HeatmapMetric, HeatmapResponse, NearbyResponse } from './types'; -const DEFAULT_START = '2026-01-01'; -const DEFAULT_END = '2026-07-13'; +// 接口返回数据水位前的兜底范围:近 30 天滚动窗口(不再写死历史区间)。 +const FALLBACK_RANGE = recentDayRange(30); const numberFormat = new Intl.NumberFormat('zh-CN'); function Metric({ value, label }: { value: number; label: string }) { @@ -24,8 +26,8 @@ export default function VehicleHeatmapModule() { const [config, setConfig] = useState(null); const [data, setData] = useState(null); const [nearby, setNearby] = useState(null); - const [startDate, setStartDate] = useState(DEFAULT_START); - const [endDate, setEndDate] = useState(DEFAULT_END); + const [startDate, setStartDate] = useState(FALLBACK_RANGE.start); + const [endDate, setEndDate] = useState(FALLBACK_RANGE.end); const [query, setQuery] = useState(''); const [batchModel, setBatchModel] = useState(''); const [metric, setMetric] = useState('locations'); @@ -103,8 +105,8 @@ export default function VehicleHeatmapModule() { }, [batchModel, deferredQuery, endDate, startDate]); const reset = () => { - setStartDate(meta?.startDate || DEFAULT_START); - setEndDate(meta?.endDate || DEFAULT_END); + setStartDate(meta?.startDate || FALLBACK_RANGE.start); + setEndDate(meta?.endDate || FALLBACK_RANGE.end); setQuery(''); setBatchModel(''); setMetric('locations'); @@ -147,7 +149,7 @@ export default function VehicleHeatmapModule() {
-

数据更新至 {meta?.endDate || DEFAULT_END}

+

数据更新至 {meta?.endDate || FALLBACK_RANGE.end}