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

This commit is contained in:
shishengliang
2026-09-16 09:29:30 +08:00
parent c49a02e3d9
commit 6ea1b3d5dd
210 changed files with 4448 additions and 36179 deletions
+1 -19
View File
@@ -3,7 +3,6 @@ import { Shell } from "./components/Shell";
import AuthProvider from "./auth/AuthProvider";
import { useAuth } from "./auth/useAuth";
import UnauthorizedPage from "./auth/UnauthorizedPage";
import PasswordLogin from "./auth/PasswordLogin";
import { canAccessEnergy } from "./shared/auth/roles";
import { LoadingState, SkeletonBlock, SurfaceCard } from "./components/ui/surface";
import { buildModules } from "./app/modules";
@@ -14,16 +13,10 @@ import {
const EleImportPage = lazy(() => import("./modules/ele/EleImportPage"));
const FeedbackAdminPage = lazy(() => import("./modules/admin/FeedbackAdminPage"));
const HydrogenPrototypeBoard = lazy(
() =>
import("./modules/energy/hydrogen/board/EnergyBiBoardApp").then(
({ EnergyBiBoardApp }) => ({ default: EnergyBiBoardApp }),
),
);
normalizeBrowserPath();
function AuthGate() {
const { isLoading, isAuthenticated, error, user, mode } = useAuth();
const { isLoading, isAuthenticated, error, user } = useAuth();
const [route, setRoute] = useState(readBrowserRoute);
const { routeKey, pathSet } = route;
@@ -72,7 +65,6 @@ function AuthGate() {
}
if (!isAuthenticated) {
if (mode === 'password') return <PasswordLogin />;
return <UnauthorizedPage message={error || undefined} />;
}
@@ -91,16 +83,6 @@ function AuthGate() {
</Suspense>
);
}
if (routeKey === "energy/hydrogen-board") {
if (!canAccessEnergy(user?.roles)) {
return <UnauthorizedPage message="无能源管理模块访问权限" />;
}
return (
<Suspense fallback={<LoadingState label="正在加载氢能经营看板" />}>
<HydrogenPrototypeBoard />
</Suspense>
);
}
// /energy 整组按能源权限控制
if (pathSet === "energy" && !canAccessEnergy(user?.roles)) {
-1
View File
@@ -35,5 +35,4 @@ test('keeps energy heatmap visibility and the independent energy navigation', ()
buildModules('energy', []).map(module => module.id),
['hydrogen', 'electric', 'etc'],
);
assert.equal(buildModules('energy', [])[0]?.label, '氢费BI');
});
+6 -6
View File
@@ -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/hydrogen/index'));
const HydrogenModule = lazy(() => import('../modules/energy/HydrogenModule'));
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: '氢费BI', icon: Fuel, component: HydrogenModule },
{ id: 'electric', label: '电能', icon: Zap, component: ElectricModule },
{ id: 'etc', label: 'ETC', icon: Wallet, component: EtcModule },
{ id: 'hydrogen', label: '氢能', icon: Fuel, component: HydrogenModule },
{ id: 'electric', label: '电能', icon: BatteryCharging, component: ElectricModule },
{ id: 'etc', label: 'ETC', icon: Receipt, component: EtcModule },
];
/** 根据主路径和角色生成导航;返回新数组,避免调用方修改静态注册表。 */
-3
View File
@@ -9,14 +9,11 @@ test('normalizes legacy asset paths without losing search parameters', () => {
test('keeps canonical and hidden administration paths unchanged', () => {
assert.equal(resolveCanonicalUrl({ pathname: '/asset', search: '', hash: '#assets' }), null);
assert.equal(resolveCanonicalUrl({ pathname: '/admin/feedback', search: '', hash: '' }), null);
assert.equal(resolveCanonicalUrl({ pathname: '/energy/hydrogen-board', search: '', hash: '' }), null);
});
test('derives path groups and hidden routes from path or hash', () => {
assert.equal(getPathSet('/energy'), 'energy');
assert.equal(getPathSet('/energy/hydrogen-board'), 'energy');
assert.equal(getPathSet('/asset'), 'asset');
assert.equal(getRouteKey('/energy/hydrogen-board', ''), 'energy/hydrogen-board');
assert.equal(getRouteKey('/asset', '#/ele/import'), 'ele/import');
assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback');
assert.equal(getRouteKey('/asset', '#mileage'), '');
+2 -11
View File
@@ -5,13 +5,7 @@ interface BrowserLocation {
search: string;
hash: string;
}
const ROOT_PATHS = new Set([
'/asset',
'/energy',
'/energy/hydrogen-board',
'/ele/import',
'/admin/feedback',
]);
const ROOT_PATHS = new Set(['/asset', '/energy', '/ele/import', '/admin/feedback']);
const LEGACY_PATHS: Record<string, { path: string; hash?: string }> = {
'/': { path: '/asset' },
@@ -37,13 +31,10 @@ export function normalizeBrowserPath(): void {
}
export function getPathSet(pathname: string): PathSet {
return pathname === '/energy' || pathname === '/energy/hydrogen-board' ? 'energy' : 'asset';
return pathname === '/energy' ? 'energy' : 'asset';
}
export function getRouteKey(pathname: string, hash: string): string {
if (pathname === '/energy/hydrogen-board') {
return 'energy/hydrogen-board';
}
if (pathname === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') {
return 'ele/import';
}
-206
View File
@@ -1,206 +0,0 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import test from "node:test";
/**
* 架构守护测试。
*
* 这些不是代码风格偏好,而是这个仓库赖以保持"能读懂"的结构约束。
* 一旦被打破(尤其是 client/server 互相引用、shared 反向依赖上层),
* 依赖图会重新变成无法追踪的网,所以用断言固化。
*/
const srcDir = path.dirname(fileURLToPath(import.meta.url));
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (/\.(ts|tsx|css)$/.test(entry.name)) out.push(full);
}
return out;
}
const allFiles = walk(srcDir);
const rel = (file: string) => path.relative(srcDir, file);
/** 取出文件里所有 import 说明符(含副作用 import / 动态 import / @import)。 */
function specifiers(file: string): string[] {
const source = readFileSync(file, "utf8");
const found: string[] = [];
const patterns = [
/(?:^|[^\w$])(?:import|export)\s+[\s\S]*?\sfrom\s*['"]([^'"]+)['"]/g,
/(?:^|[^\w$])import\s*['"]([^'"]+)['"]/g,
/(?:^|[^\w$])import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
/@import\s+['"]([^'"]+)['"]/g,
];
for (const re of patterns) {
for (const match of source.matchAll(re)) found.push(match[1]);
}
return found;
}
/** 把相对 import 解析为 src 下的路径(用于判断它指向哪个分区)。 */
function targetPath(file: string, spec: string): string | null {
if (!spec.startsWith(".")) return null;
return path.relative(srcDir, path.resolve(path.dirname(file), spec));
}
/** 去掉注释后再做 SQL 关键字匹配:注释里的 "update status" 之类不应触发守卫。 */
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:])\/\/[^\n]*/g, "$1 ");
}
function filesUnder(prefix: string): string[] {
return allFiles.filter((f) => rel(f).startsWith(prefix));
}
function fileContains(file: string, re: RegExp): boolean {
return re.test(readFileSync(file, "utf8"));
}
test("分层铁律:server 不得依赖前端 modules", () => {
const violations: string[] = [];
for (const file of filesUnder("server/")) {
for (const spec of specifiers(file)) {
const target = targetPath(file, spec);
if (target && target.startsWith("modules/")) violations.push(`${rel(file)} -> ${spec}`);
}
}
assert.deepEqual(violations, [], "server 必须独立于前端:请把共享内容下沉到 src/shared");
});
test("分层铁律:前端不得依赖 server", () => {
const violations: string[] = [];
const clientDirs = ["modules/", "components/", "auth/", "app/"];
for (const dir of clientDirs) {
for (const file of filesUnder(dir)) {
for (const spec of specifiers(file)) {
const target = targetPath(file, spec);
if (target && target.startsWith("server/")) violations.push(`${rel(file)} -> ${spec}`);
}
}
}
assert.deepEqual(violations, [], "前端不得直接引用服务端模块");
});
test("分层铁律:shared 是最底层,不得反向依赖上层", () => {
const violations: string[] = [];
for (const file of filesUnder("shared/")) {
for (const spec of specifiers(file)) {
const target = targetPath(file, spec);
if (target && (target.startsWith("modules/") || target.startsWith("server/") || target.startsWith("components/"))) {
violations.push(`${rel(file)} -> ${spec}`);
}
}
}
assert.deepEqual(violations, [], "src/shared 必须保持为可被前后端共同依赖的叶子层");
});
test("原型代码不再散落在 vendor 目录", () => {
const violations: string[] = [];
for (const file of allFiles) {
for (const spec of specifiers(file)) {
if (spec.includes("vendor/")) violations.push(`${rel(file)} -> ${spec}`);
}
}
assert.deepEqual(violations, [], "生产代码应位于 src/modules 下的业务域目录,而不是 src/vendor");
});
test("纯模型文件不得依赖 React(保证可被 node:test 直接测)", () => {
const violations: string[] = [];
for (const file of allFiles.filter((f) => /(^|\/)model\.tsx?$/.test(rel(f)))) {
for (const spec of specifiers(file)) {
if (spec === "react" || spec.startsWith("react/")) violations.push(`${rel(file)} -> ${spec}`);
}
}
assert.deepEqual(violations, [], "model.ts 应只包含可在 Node 中直接执行的纯函数");
});
test("高德 SDK 只在 shared/amap.ts 接入", () => {
// 两个热力图曾各自写一份 load + 底图 + 控件 + 色带,导致同类地图长得不一样。
const importers = allFiles
.filter((f) => rel(f) !== "shared/amap.ts")
.filter((f) => specifiers(f).some((s) => s === "@amap/amap-jsapi-loader"))
.map(rel);
assert.deepEqual(importers, [], "请通过 src/shared/amap.ts 使用高德地图,不要各自加载 SDK");
});
test("Excel 导出只在 shared/xlsx.ts 组装工作簿", () => {
// 允许 server 侧直接读 xlsx(导入解析),但不允许前端再次自行拼装 workbook。
const offenders = allFiles
.filter((f) => rel(f).startsWith("modules/"))
.filter((f) => {
const source = readFileSync(f, "utf8");
return /book_new|book_append_sheet|writeFile\(/.test(source);
})
.map(rel);
assert.deepEqual(offenders, [], "请改用 src/shared/xlsx.ts 的 writeWorkbook / export*Sheet");
});
test("类型豁免清单只减不增:@ts-nocheck 仅限已登记的 8113 原型快照", () => {
// 这三个文件是逐字节保留的验收原型(UI/CSS 冻结),显式登记为唯一豁免。
const allowed = [
"modules/energy/hydrogen/board/EnergyBiBoardApp.tsx",
"modules/energy/hydrogen/station-daily/StationDailyApp.tsx",
"modules/energy/hydrogen/station-daily/StationDailyDetailView.tsx",
];
const actual = allFiles.filter((f) => fileContains(f, /^\/\/\s*@ts-nocheck/m)).map(rel).sort();
assert.deepEqual(actual, [...allowed].sort(), "新增 @ts-nocheck 会让类型门禁失效,请改为修正类型");
});
test("运行时 DDL 只允许出现在 server/db/schema", () => {
// 建表原先散落在路由与 store 里,且由 GET 触发,使"只读预览"仍可能写库。
const offenders = allFiles
.filter((f) => rel(f).startsWith("server/") && !rel(f).startsWith("server/db/schema/"))
.filter((f) => !rel(f).endsWith(".test.ts"))
.filter((f) => /CREATE TABLE|ALTER TABLE/.test(readFileSync(f, "utf8")))
.map(rel);
assert.deepEqual(offenders, [], "请把建表/改表语句集中到 src/server/db/schema/");
});
test("运行时建表必须尊重只读模式", () => {
const schemaFiles = allFiles.filter((f) => rel(f).startsWith("server/db/schema/") && rel(f) !== "server/db/schema/guard.ts");
const offenders = schemaFiles
.filter((f) => !readFileSync(f, "utf8").includes("ddlAllowed()"))
.map(rel);
assert.deepEqual(offenders, [], "每个 schema 模块都要在 DB_READ_ONLY=1 时跳过 DDL");
});
test("已完整分层的域:repository.ts 存在,且 SQL 只允许出现在那里", () => {
// 这些域已完成 routes / repository / model 拆分;其余域仍把 SQL 放在路由里
// (见 docs/ARCHITECTURE.md 的"后端业务域形状"表)。
const layered = [
"server/routes/vehicles",
"server/routes/ele",
"server/routes/feedback",
"server/routes/vehicle-heatmap",
"server/routes/hydrogen-heatmap",
"server/routes/scheduling",
"server/routes/energy",
"server/routes/mileage",
];
// 用"语句形状"而不是裸关键字:UpdateNotification 或 "update error" 这类标识符/日志
// 不应误判,而小写 SQL 也依然能被抓到。
const SQL = /\bselect\b[\s\S]{0,400}?\bfrom\b|\binsert\s+(into|ignore)\b|\bupdate\s+[\w.`]+\s+set\b|\bdelete\s+from\b|\bcreate\s+table\b|\balter\s+table\b/i;
const offenders: string[] = [];
for (const dir of layered) {
const abs = path.join(srcDir, dir);
if (!existsSync(path.join(abs, "repository.ts"))) {
offenders.push(`${dir}/repository.ts 缺失`);
continue;
}
for (const file of readdirSync(abs)) {
if (!file.endsWith(".ts")) continue;
// repository.ts 是 SQL 的归属地;constants.ts 允许存放被多条查询共享的
// SQL 片段(如公共 WHERE / CTE),否则这些片段会被迫复制到每个查询里。
if (file === "repository.ts" || file === "constants.ts" || file.endsWith(".test.ts")) continue;
if (SQL.test(stripComments(readFileSync(path.join(abs, file), "utf8")))) {
offenders.push(`${dir}/${file} 仍含 SQL`);
}
}
}
assert.deepEqual(offenders, [], "SQL 必须留在各域的 repository.ts");
});
+8 -34
View File
@@ -5,7 +5,6 @@ import { setTokenGetter } from './api-client';
const AUTH_API = '/api/auth';
export default function AuthProvider({ children }: { children: ReactNode }) {
const [mode, setMode] = useState<'sso' | 'password'>('sso');
const [state, setState] = useState<AuthState>({
isLoading: true,
isAuthenticated: false,
@@ -20,6 +19,8 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
setTokenGetter(() => tokenRef.current);
// 防止 StrictMode 双重调用(jumpToken 一次性使用)
if (authStarted.current) return;
authStarted.current = true;
// 监听 401 事件
const onUnauthorized = () => {
@@ -29,25 +30,14 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
};
window.addEventListener('auth:unauthorized', onUnauthorized);
if (!authStarted.current) { authStarted.current = true; authenticate(); }
authenticate();
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
}, []);
async function authenticate() {
let currentMode: 'sso' | 'password';
try {
const response = await fetch(`${AUTH_API}/config`, { cache: 'no-store' });
const config = await response.json();
if (!response.ok || !['sso', 'password'].includes(config.mode)) throw new Error();
currentMode = config.mode;
setMode(currentMode);
} catch {
setState({ isLoading: false, isAuthenticated: false, user: null, error: '无法获取验证配置,请刷新重试' });
return;
}
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
if (currentMode === 'sso' && import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
setState({
isLoading: false,
isAuthenticated: true,
@@ -69,15 +59,15 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
tokenRef.current = savedToken;
// 验证 token 是否仍然有效(尝试请求 health)
try {
const res = await fetch(`${AUTH_API}/me`, {
const res = await fetch('/api/health', {
headers: { Authorization: `Bearer ${savedToken}` },
});
if (res.ok) {
const savedUser = await res.json();
const savedUser = sessionStorage.getItem('bi_user');
setState({
isLoading: false,
isAuthenticated: true,
user: savedUser,
user: savedUser ? JSON.parse(savedUser) : null,
error: null,
});
return;
@@ -85,12 +75,6 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
} catch { /* token 无效,继续流程 */ }
sessionStorage.removeItem('bi_jwt');
sessionStorage.removeItem('bi_user');
tokenRef.current = null;
}
if (currentMode === 'password') {
setState({ isLoading: false, isAuthenticated: false, user: null, error: null });
return;
}
// 2. 从 URL 提取 jumpToken
@@ -135,18 +119,8 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
}
}
async function loginWithPassword(password: string) {
const response = await fetch(`${AUTH_API}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
const data = await response.json();
if (!response.ok || !data.token) throw new Error(data.message || '登录失败');
tokenRef.current = data.token;
sessionStorage.setItem('bi_jwt', data.token);
sessionStorage.setItem('bi_user', JSON.stringify(data.user));
setState({ isLoading: false, isAuthenticated: true, user: data.user, error: null });
}
return (
<AuthContext.Provider value={{ ...state, mode, loginWithPassword }}>
<AuthContext.Provider value={state}>
{children}
</AuthContext.Provider>
);
-25
View File
@@ -1,25 +0,0 @@
import { useState } from 'react';
import { useAuth } from './useAuth';
export default function PasswordLogin() {
const { loginWithPassword, error: sessionError } = useAuth();
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
return <main className="min-h-screen flex items-center justify-center bg-slate-950 p-6">
<form className="w-full max-w-sm rounded-2xl bg-white p-7 shadow-xl" onSubmit={async event => {
event.preventDefault(); if (busy || !loginWithPassword) return;
setBusy(true); setError('');
try { await loginWithPassword(password); } catch (e) { setError(e instanceof Error ? e.message : '登录失败,请重试'); }
finally { setBusy(false); setPassword(''); }
}}>
<p className="text-xs font-semibold text-blue-600">羚牛 · 能源 BI</p>
<h1 className="mt-2 text-2xl font-bold text-slate-900">访问看板</h1>
<p className="mt-2 mb-6 text-sm text-slate-500">请输入管理员提供的访问密码。此入口仅提供只读访问。</p>
<label htmlFor="bi-password" className="text-sm font-medium text-slate-700">访问密码</label>
<input id="bi-password" type="password" autoComplete="current-password" required maxLength={1024} value={password} onChange={e => setPassword(e.target.value)} className="mt-2 w-full rounded-lg border border-slate-300 px-3 py-3 text-base focus:outline-blue-600" />
{error || sessionError ? <p role="alert" className="mt-3 text-sm text-red-600">{error || sessionError}</p> : null}
<button disabled={busy || !password} className="mt-5 min-h-11 w-full rounded-lg bg-blue-600 px-4 py-3 font-semibold text-white disabled:opacity-50">{busy ? '正在验证…' : '进入看板'}</button>
</form>
</main>;
}
-2
View File
@@ -1,8 +1,6 @@
import { createContext, useContext } from 'react';
export interface AuthState {
mode?: 'sso' | 'password';
loginWithPassword?: (password: string) => Promise<void>;
isLoading: boolean;
isAuthenticated: boolean;
user: {
+17
View File
@@ -0,0 +1,17 @@
import { createContext, useContext, type ReactNode } from 'react';
const DemoModeContext = createContext(false);
export function DemoModeProvider({ enabled, children }: { enabled: boolean; children: ReactNode }) {
return <DemoModeContext.Provider value={enabled}>{children}</DemoModeContext.Provider>;
}
export function useDemoMode() {
return useContext(DemoModeContext);
}
export default function Blur({ children }: { children: ReactNode }) {
const demo = useContext(DemoModeContext);
if (!demo) return <>{children}</>;
return <span className="blur-[5px] select-none">{children}</span>;
}
+20 -27
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react';
import { motion } from 'motion/react';
import { Building2, ChevronRight } from 'lucide-react';
import { Building2, ChevronRight, ShieldCheck } from 'lucide-react';
import { useAuth } from '../auth/useAuth';
import { DemoModeProvider } from './Blur';
import FeedbackFab from './FeedbackFab';
import { cn } from '../lib/cn';
import { LoadingState } from './ui/surface';
@@ -82,9 +83,6 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
const { user } = useAuth();
const activeLabel = flatModules.find((module) => module.id === activeModule)?.label ?? '业务看板';
// 氢能经营看板已在模块内提供原型一致的标题与导航,避免桌面端重复显示全局面包屑。
const showDesktopBreadcrumb = activeModule !== 'hydrogen';
const isHydrogenPrototype = activeModule === 'hydrogen';
const watermarkText = useMemo(() => {
const name = user?.userName || '未登录';
const time = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-');
@@ -92,17 +90,16 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
}, [user]);
return (
<DemoModeProvider enabled={false}>
<div className="enterprise-grid-bg flex min-h-screen">
{/* 氢费看板按原型交付;其他模块继续保留全局水印。 */}
{!isHydrogenPrototype ? (
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' width='320' height='200'><text x='50%' y='50%' text-anchor='middle' dominant-baseline='middle' font-size='14' font-family='sans-serif' fill='%23000' transform='rotate(-25 160 100)'>${watermarkText}</text></svg>`)}")`,
backgroundRepeat: 'repeat',
}} />
</div>
) : null}
{/* Web 侧边栏 (md 及以上):能源各模块使用同一套规格,以电能为基准。 */}
{/* 全局水印 */}
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' width='320' height='200'><text x='50%' y='50%' text-anchor='middle' dominant-baseline='middle' font-size='14' font-family='sans-serif' fill='%23000' transform='rotate(-25 160 100)'>${watermarkText}</text></svg>`)}")`,
backgroundRepeat: 'repeat',
}} />
</div>
{/* Web 侧边栏 (md 及以上) */}
<nav className="fixed left-0 top-0 z-50 hidden h-full w-20 flex-col items-center border-r border-white/10 bg-slate-950 px-2 py-4 text-white shadow-[12px_0_40px_rgba(15,23,42,0.16)] md:flex">
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-blue-600 shadow-lg shadow-blue-950/20">
<Building2 size={22} />
@@ -121,8 +118,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
aria-expanded={isGroup ? isOpen : undefined}
aria-haspopup={isGroup ? 'menu' : undefined}
className={cn(
'group relative flex flex-col items-center justify-center transition-all',
'h-16 w-16 rounded-2xl text-[10px] font-black',
'group relative flex h-16 w-16 flex-col items-center justify-center rounded-2xl text-[10px] font-black transition-all',
isActive ? 'text-white' : 'text-slate-400 hover:bg-white/8 hover:text-white',
)}
title={m.label}
@@ -153,9 +149,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
</div>
<div className="flex w-full flex-col items-center gap-2 border-t border-white/10 pt-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-400/10 text-emerald-300 ring-1 ring-emerald-300/20" title={user?.userName || '当前用户'}>
<svg width="28" height="22" viewBox="4 3 44 30" role="img" aria-label="羚牛 Logo">
<image href="/lingniu-logo-light.svg" width="150" height="36" style={{ filter: 'brightness(0) invert(1)' }} />
</svg>
<ShieldCheck size={18} />
</div>
<div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div>
</div>
@@ -163,17 +157,15 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
{/* 内容区 */}
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
{showDesktopBreadcrumb ? (
<div className={cn('pointer-events-none fixed right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex', isHydrogenPrototype ? 'left-[72px]' : 'left-20')}>
<span className="text-slate-600">羚牛氢能 BI</span>
<span className="mx-2 text-slate-300">/</span>
<span>{activeLabel}</span>
</div>
) : null}
<div className="pointer-events-none fixed left-20 right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex">
<span className="text-slate-600">羚牛氢能 BI</span>
<span className="mx-2 text-slate-300">/</span>
<span>{activeLabel}</span>
</div>
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
{ActiveComponent && <ActiveComponent />}
</Suspense>
{!isHydrogenPrototype ? <FeedbackFab module={activeModule} /> : null}
<FeedbackFab module={activeModule} />
</main>
{/* 移动端底部导航 (md 以下) */}
@@ -218,5 +210,6 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
</div>
</nav>
</div>
</DemoModeProvider>
);
}
+17 -14
View File
@@ -9,7 +9,7 @@ import {
MapPin,
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { buildJsonSheet, writeWorkbook } from '../../shared/xlsx';
import * as XLSX from 'xlsx';
import {
BarChart,
Bar,
@@ -40,6 +40,7 @@ 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';
@@ -392,7 +393,7 @@ export default function AssetsModule() {
业务负责人: item.manager || '',
客户: item.customerName || '',
}));
const ws = buildJsonSheet(table);
const ws = XLSX.utils.json_to_sheet(table);
ws['!cols'] = [
{ wch: 14 },
{ wch: 8 },
@@ -403,7 +404,9 @@ export default function AssetsModule() {
{ wch: 14 },
{ wch: 24 },
];
writeWorkbook([{ name: '明细', sheet: ws }], `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '明细');
XLSX.writeFile(wb, `${title}-${flowRange.start}-${flowRange.end}.xlsx`);
}, [flowRange.end, flowRange.start, flowStats]);
const [customerProvinceData, setCustomerProvinceData] = useState<{ name: string; value: number }[]>([]);
@@ -702,7 +705,7 @@ export default function AssetsModule() {
>
<div className="flex items-center gap-2">
{isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
<span className="font-bold text-gray-700 text-xs">{m.manager}</span>
<span className="font-bold text-gray-700 text-xs"><Blur>{m.manager}</Blur></span>
</div>
<button
onClick={(e) => {
@@ -784,7 +787,7 @@ export default function AssetsModule() {
>
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-1">
{isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />}
{m.manager}
<Blur>{m.manager}</Blur>
</td>
<td className="p-2 border-r border-gray-100 text-gray-600">{m.department}</td>
<td
@@ -904,7 +907,7 @@ export default function AssetsModule() {
>
<div className="flex items-center gap-1">
{isManagerExpanded ? <ChevronDown size={12} className="text-blue-400" /> : <ChevronRight size={12} className="text-gray-300" />}
<span className="text-[11px] font-bold text-gray-700">{m.manager}</span>
<span className="text-[11px] font-bold text-gray-700"><Blur>{m.manager}</Blur></span>
</div>
<button
onClick={(e) => {
@@ -983,7 +986,7 @@ export default function AssetsModule() {
<div className="flex items-center gap-2 flex-1 min-w-0">
{isManagerExpanded ? <ChevronDown size={14} className="text-blue-500" /> : <ChevronRight size={14} className="text-gray-300" />}
<div className="flex items-center gap-3 min-w-0">
<h3 className="text-sm font-bold text-gray-800 shrink-0">{m.manager}</h3>
<h3 className="text-sm font-bold text-gray-800 shrink-0"><Blur>{m.manager}</Blur></h3>
<span className="text-[11px] text-gray-500 shrink-0">{m.department}</span>
<div
className="text-[11px] font-bold text-blue-600 whitespace-nowrap"
@@ -1507,12 +1510,12 @@ export default function AssetsModule() {
>
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 flex items-center gap-2">
{isExpanded ? <ChevronDown size={14} className="text-emerald-600" /> : <ChevronRight size={14} className="text-gray-400" />}
{cust.customer}
<Blur>{cust.customer}</Blur>
</td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">
<span className="bg-gray-100 px-2 py-0.5 rounded text-[10px] font-medium">{cust.region}</span>
</td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{cust.manager}</td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{cust.manager}</Blur></td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: false, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T` }); }}>{cust.t4_5}</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '4.5T', isColdChain: true, source: 'customer', title: `客户运营统计 - ${cust.customer} - 4.5T冷链` }); }}>{cust.t4_5c}</td>
<td className="p-2 border-r border-gray-100 text-center text-gray-500 cursor-pointer hover:bg-emerald-50 transition-colors" onClick={(e) => { e.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', customer: cust.customer, type: '18T', source: 'customer', title: `客户运营统计 - ${cust.customer} - 18T` }); }}>{cust.t18}</td>
@@ -1527,7 +1530,7 @@ export default function AssetsModule() {
<div className="grid grid-cols-4 gap-2">
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1">客户详情</div>
<div className="text-sm font-bold text-gray-700">{cust.customer}</div>
<div className="text-sm font-bold text-gray-700"><Blur>{cust.customer}</Blur></div>
</div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1">主要车型</div>
@@ -1537,7 +1540,7 @@ export default function AssetsModule() {
</div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1">业务经理</div>
<div className="text-sm font-bold text-gray-700">{cust.manager}</div>
<div className="text-sm font-bold text-gray-700"><Blur>{cust.manager}</Blur></div>
</div>
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
<div className="text-[10px] text-gray-400 uppercase mb-1">资产占比</div>
@@ -1571,7 +1574,7 @@ export default function AssetsModule() {
<div className="flex items-center gap-2">
{isExpanded ? <ChevronDown size={16} className="text-emerald-600" /> : <ChevronRight size={16} className="text-gray-400" />}
<div className="flex flex-col">
<span className="font-bold text-gray-800 text-sm">{cust.customer}</span>
<span className="font-bold text-gray-800 text-sm"><Blur>{cust.customer}</Blur></span>
<span className="text-[10px] text-emerald-600 font-medium">{cust.region}区域</span>
</div>
</div>
@@ -1589,7 +1592,7 @@ export default function AssetsModule() {
<div className="grid grid-cols-2 gap-2">
<div className="bg-gray-50 p-2 rounded border border-gray-100">
<div className="text-[8px] text-gray-400 uppercase mb-1">客户详情</div>
<div className="text-[10px] font-bold text-gray-700">{cust.customer}</div>
<div className="text-[10px] font-bold text-gray-700"><Blur>{cust.customer}</Blur></div>
</div>
<div className="bg-gray-50 p-2 rounded border border-gray-100">
<div className="text-[8px] text-gray-400 uppercase mb-1">主要车型</div>
@@ -1599,7 +1602,7 @@ export default function AssetsModule() {
</div>
<div className="bg-gray-50 p-2 rounded border border-gray-100">
<div className="text-[8px] text-gray-400 uppercase mb-1">业务经理</div>
<div className="text-xs font-bold text-gray-700">{cust.manager}</div>
<div className="text-xs font-bold text-gray-700"><Blur>{cust.manager}</Blur></div>
</div>
<div className="bg-gray-50 p-2 rounded border border-gray-100">
<div className="text-[8px] text-gray-400 uppercase mb-1">资产占比</div>
@@ -1,6 +1,7 @@
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';
@@ -166,8 +167,8 @@ export function VehicleDetailModal({
<tbody className="text-[11px]">
{filteredModalWeeklyDetail.map((v, i) => (
<tr key={`${v.truck_id}-${i}`} className={`border-b border-gray-100 hover:bg-blue-50/50 transition-colors ${i % 2 === 0 ? 'bg-white' : 'bg-gray-50/30'}`}>
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center">{v.plate_number}</td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.customer_name || '—'}</td>
<td className="p-2 border-r border-gray-100 font-mono font-bold text-blue-700 text-center"><Blur>{v.plate_number}</Blur></td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center"><Blur>{v.customer_name || '—'}</Blur></td>
<td className="p-2 text-gray-500 text-center">{v.handover_date ? v.handover_date.slice(0, 10) : '—'}</td>
</tr>
))}
@@ -214,12 +215,12 @@ export function VehicleDetailModal({
{showPlateNumbers.source === 'customer' ? (
<>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.departmentName || '—'}</td>
<td className="p-2 border-r border-gray-100 font-medium text-gray-700">{v.customerManager || '—'}</td>
<td className="p-2 border-r border-gray-100 font-medium text-gray-700"><Blur>{v.customerManager || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.brandLabel || '—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.type}</td>
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]">{v.subjectOrg || '—'}</td>
<td className="p-2 border-r border-gray-100 font-bold text-gray-800">{v.customerName || '—'}</td>
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}>{v.plateNumber || v.vin || '—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-500 text-[10px]"><Blur>{v.subjectOrg || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 font-bold text-gray-800"><Blur>{v.customerName || '—'}</Blur></td>
<td className={`p-2 border-r border-gray-100 font-mono font-bold ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
<td className="p-2 border-r border-gray-100 text-center">
<span className={`px-1.5 py-0.5 rounded-full text-[9px] font-bold ${
v.status === 'Operating' ? 'bg-green-100 text-green-700' :
@@ -232,13 +233,13 @@ export function VehicleDetailModal({
<td className="p-2 border-r border-gray-100 text-center text-gray-500">{'—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600">{v.location === '其他' ? '对接中' : v.location}</td>
<td className="p-2 border-r border-gray-100 text-center font-bold text-orange-600">{'—'}</td>
<td className="p-2 text-gray-500 text-[10px]">{v.orgName || '—'}</td>
<td className="p-2 text-gray-500 text-[10px]"><Blur>{v.orgName || '—'}</Blur></td>
</>
) : (
<>
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}>{v.plateNumber || v.vin || '—'}</td>
<td className={`p-2 border-r border-gray-100 font-mono font-bold text-center ${v.plateNumber ? 'text-blue-700' : 'text-orange-500'}`}><Blur>{v.plateNumber || v.vin || '—'}</Blur></td>
{showPlateNumbers.source !== 'asset' && showPlateNumbers.category !== 'Inventory' && (
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center">{v.customerName || '—'}</td>
<td className="p-2 border-r border-gray-100 font-bold text-gray-800 text-center"><Blur>{v.customerName || '—'}</Blur></td>
)}
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.brandLabel || '—'}</td>
<td className="p-2 border-r border-gray-100 text-gray-600 text-center">{v.type}</td>
+70 -43
View File
@@ -1,52 +1,79 @@
import { useEffect, useState } from 'react';
import { BadgeCheck, CarFront, FileText, ReceiptText, WalletCards } from 'lucide-react';
import { fetchEtcOverview, type EtcOverviewResponse } from './api';
import { motion } from 'motion/react';
import { Construction, Hammer } from 'lucide-react';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
function formatYuan(value: number) {
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`;
}
const ETC_HINTS = [
'ETC 通行费数据正在与发卡方系统打通…',
'工人 GG 正在搭脚手架,敬请期待 ~',
'马上能看到每月通行费明细啦',
'想看哪个维度的 ETC?反馈一下嘛',
'上线时机:等数据接通的那一天',
];
export default function ETCView() {
const [data, setData] = useState<EtcOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchEtcOverview()
.then(result => { if (!cancelled) setData(result); })
.catch(reason => { if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason)); });
return () => { cancelled = true; };
}, []);
if (error) return <ErrorState message="ETC 数据源暂时不可用,请稍后刷新。" />;
if (!data) return <LoadingState label="正在核对 ETC 台账" />;
if (!data.hasData) {
return (
<div className="flex flex-col gap-3">
<EmptyState title="暂无 ETC 通行台账" description="当前账单和通行明细均未入库,接入后会自动展示统计数据。" />
<RotatingFooterHint hints={['ETC 台账状态已按数据库实时核对', '待通行明细入库后自动生成统计']} />
</div>
);
}
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={ReceiptText} label="通行明细" value={data.tollRecordCount.toLocaleString('zh-CN')} unit="笔" helper={data.latestTollTime ? `最新记录 ${data.latestTollTime}` : '暂无通行时间'} />
<MetricTile icon={CarFront} label="涉及车辆" value={data.vehicleCount.toLocaleString('zh-CN')} unit="台" helper="按通行明细车牌去重" tone="emerald" />
<MetricTile icon={WalletCards} label="通行费金额" value={formatYuan(data.totalAmount)} helper="通行费与服务费合计" tone="amber" />
<MetricTile icon={FileText} label="ETC 账单" value={data.billCount.toLocaleString('zh-CN')} unit="张" helper={`应收 ${formatYuan(data.receivableAmount)} · 已收 ${formatYuan(data.paidAmount)}`} tone="slate" />
</div>
<SurfaceCard className="p-4">
<div className="flex items-start gap-2 text-xs font-bold text-slate-500">
<BadgeCheck size={17} className="mt-0.5 shrink-0 text-emerald-500" />
<div>当前统计直接读取 ETC 通行明细与账单台账;金额、应收和已收均不做推算。</div>
<div className="flex flex-col gap-4">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="relative w-20 h-20 mb-4">
<motion.div
animate={{ rotate: [0, -8, 8, -4, 4, 0] }}
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
className="absolute inset-0 rounded-3xl bg-gradient-to-br from-amber-50 to-orange-50 flex items-center justify-center"
>
<Construction size={36} className="text-amber-500" strokeWidth={2.2} />
</motion.div>
<motion.div
animate={{ rotate: [0, 18, -10, 0], y: [0, -2, 1, 0] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
className="absolute -top-1 -right-1 w-9 h-9 rounded-2xl bg-white border border-amber-100 shadow-sm flex items-center justify-center"
>
<Hammer size={16} className="text-amber-500" strokeWidth={2.2} />
</motion.div>
</div>
</SurfaceCard>
<RotatingFooterHint />
<div className="text-base font-black text-slate-800 mb-1.5">ETC 模块建设中</div>
<div className="text-[12px] text-slate-500 font-bold leading-relaxed max-w-[280px]">
通行费明细、按车按月统计、运营成本拆分
<br />
这些数据都在路上啦
</div>
{/* 简单的里程碑进度感 */}
<div className="mt-6 w-full max-w-xs space-y-2">
{[
{ label: '需求评审', done: true },
{ label: '数据对接', done: true },
{ label: '页面开发', done: false, current: true },
{ label: '正式上线', done: false },
].map((m, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + i * 0.08, duration: 0.3 }}
className="flex items-center gap-2.5 text-[11px]"
>
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${
m.done ? 'bg-emerald-400'
: m.current ? 'bg-amber-400 ring-4 ring-amber-100 animate-pulse'
: 'bg-slate-200'
}`} />
<span className={`font-bold ${m.done ? 'text-slate-500' : m.current ? 'text-amber-600' : 'text-slate-300'}`}>
{m.label}
</span>
{m.done && <span className="text-[10px] text-emerald-500 font-bold ml-auto">已完成</span>}
{m.current && <span className="text-[10px] text-amber-500 font-bold ml-auto">进行中</span>}
</motion.div>
))}
</div>
</motion.div>
<RotatingFooterHint hints={ETC_HINTS} />
</div>
);
}
+29 -27
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { BatteryCharging, CalendarDays, ChevronRight, TrendingUp, Wallet } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Wallet } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge';
import { fetchElectricMonthly } from './api';
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
@@ -28,7 +28,6 @@ export default function ElectricDaily() {
useEffect(() => {
let cancelled = false;
setError(null);
setMonths(null);
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
@@ -39,11 +38,7 @@ export default function ElectricDaily() {
// 默认展开最新一个月
if (m.length > 0) setOpenMonths(new Set([m[0].month]));
})
.catch(e => {
if (cancelled) return;
setMonths(null);
setError(e instanceof Error ? e.message : String(e));
});
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
@@ -64,6 +59,7 @@ export default function ElectricDaily() {
} = useMemo(() => summarizeElectricMonths(months), [months]);
const scopeLabel = getRangeModeLabel(pick);
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
const applyQuickPick = (nextPick: DateQuickPick) => {
setPick(nextPick);
@@ -76,23 +72,6 @@ export default function ElectricDaily() {
setDateRange(prev => ({ ...prev, [field]: value }));
};
if (error) {
return (
<div className="flex flex-col gap-3">
<DailyRangeControls
pick={pick}
dateRange={dateRange}
customer={customer}
onQuickPick={applyQuickPick}
onCustomPick={() => setPick('custom')}
onDateRangeChange={updateDateRange}
onCustomerChange={setCustomer}
/>
<ErrorState message={error} />
</div>
);
}
return (
<div className="flex flex-col gap-3">
<DailyRangeControls
@@ -110,15 +89,37 @@ export default function ElectricDaily() {
<MetricTile
icon={Wallet}
label="充电费用"
value={`¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前范围未记录费用'}
value={hasFeeDetail ? `¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '待同步'}
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前明细仅返回充电量'}
tone={hasFeeDetail ? 'emerald' : 'slate'}
/>
<MetricTile icon={CalendarDays} label="有效天数" value={`${activeDays}`} unit="天" helper={`日均 ${avgKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} tone="amber" />
<MetricTile icon={TrendingUp} label="波动提醒" value={abnormalDays} unit="天" helper="环比超过 30% 标记" tone={abnormalDays > 0 ? 'rose' : 'slate'} />
</div>
{/* 外部车辆 数据未就绪 */}
{showExternalEmpty && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
<Plug size={22} className="text-blue-500" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
</div>
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
新系统的外部车辆充电数据还在准备中
<br />
上线后此处将展示完整明细
</div>
</motion.div>
)}
{/* 月份分组表 */}
{!showExternalEmpty && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
<span>月份 / 日期</span>
@@ -184,6 +185,7 @@ export default function ElectricDaily() {
);
})}
</div>
)}
<RotatingFooterHint />
</div>
);
+4 -4
View File
@@ -6,7 +6,7 @@ import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface';
const SUB_TABS = [
{ id: 'daily', label: '按日', icon: CalendarDays },
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
] as const satisfies readonly { id: ElectricSubTab; label: string; icon: typeof CalendarDays }[];
@@ -16,11 +16,11 @@ export default function ElectricModule() {
const [sub, setSub] = useHashSubTab<ElectricSubTab>('electric', SUB_IDS);
return (
<PageFrame
title="电能经营看板"
subtitle="充电量、费用、日趋势与车辆归属"
title="电能成本看板"
subtitle="围绕充电量、费用、日趋势和车辆归属展示电能支出结构,辅助识别费用波动。"
icon={CalendarDays}
eyebrow="ELECTRIC BI"
meta="按日 · 总览"
meta="时间单位清晰标注 · 支持日/总览切换"
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait">
+12 -16
View File
@@ -34,32 +34,28 @@ export default function ElectricOverview() {
const trendData = data.trend;
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
const trendMonthLabel = trendData[0]?.date.slice(0, 7);
const now = new Date();
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const dataIsCurrentMonth = trendMonthLabel === currentMonth;
const chartTitle = trendMonthLabel ? `${trendMonthLabel} 每日充电` : '每日充电';
const currentMonth = new Date().toISOString().slice(0, 7);
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
? `${trendMonthLabel} 每日充电`
: '本月每日充电';
const activeDays = trendData.filter(item => item.kwh > 0).length;
const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0;
const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 0;
const peakDay = trendData.reduce<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null);
const latestDay = trendData.at(-1) ?? null;
const displayedMonthKwh = dataIsCurrentMonth ? k.monthKwh : trendData.reduce((sum, item) => sum + item.kwh, 0);
const displayedMonthFee = dataIsCurrentMonth ? k.monthFee : trendData.reduce((sum, item) => sum + item.fee, 0);
const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0;
const monthPrice = displayedMonthKwh > 0 ? displayedMonthFee / displayedMonthKwh : 0;
const dataAsOf = data.latestChargeTime ?? '暂无充电记录';
const monthPrice = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 0;
return (
<div className="flex flex-col gap-3">
<div className="rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[11px] font-bold text-slate-400">
充电账本最新记录:{dataAsOf} · {dataIsCurrentMonth ? '本月数据' : `${trendMonthLabel ?? '暂无'} 数据`}
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
</div>
{/* 横向 mini KPI 头 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} />
<MetricTile icon={CalendarClock} label={dataIsCurrentMonth ? '本月充电费' : '数据最新月费用'} value={fmtYuan(displayedMonthFee)} helper={fmtKwh(displayedMonthKwh)} tone="emerald" />
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`${dataIsCurrentMonth ? '本月' : '最新月'} ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
<MetricTile icon={BatteryCharging} label={dataIsCurrentMonth ? '今日充电' : '最新日充电'} value={(dataIsCurrentMonth ? k.todayKwh : latestDay?.kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={dataIsCurrentMonth ? `${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%` : `${latestDay?.date ?? '暂无记录'} · ${fmtYuan(latestDay?.fee ?? 0)}`} tone={dataIsCurrentMonth && Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
<MetricTile icon={CalendarClock} label="本月充电费" value={fmtYuan(k.monthFee)} helper={fmtKwh(k.monthKwh)} tone="emerald" />
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
<MetricTile icon={BatteryCharging} label="今日充电" value={k.todayKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={`${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
@@ -75,8 +71,8 @@ export default function ElectricOverview() {
</SurfaceCard>
<SurfaceCard className="p-3">
<div className="text-[11px] font-black text-slate-400">月度占比</div>
<div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (displayedMonthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
<div className="mt-1 text-[11px] font-bold text-slate-500">{dataIsCurrentMonth ? '本月' : '最新月'}费用 / 累计费用</div>
<div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (k.monthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
<div className="mt-1 text-[11px] font-bold text-slate-500">本月费用 / 累计费用</div>
</SurfaceCard>
</div>
+2 -2
View File
@@ -6,10 +6,10 @@ export default function EtcModule() {
return (
<PageFrame
title="ETC 通行费看板"
subtitle="通行明细、账单与回款状态"
subtitle="规划按车、按月、按线路拆分通行费,让车辆运营成本口径逐步完整。"
icon={Receipt}
eyebrow="ETC BI"
meta="通行明细 · 账单"
meta="数据对接中 · 页面能力预留"
>
<ETCView />
</PageFrame>
+278
View File
@@ -0,0 +1,278 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
import TrendBadge from './TrendBadge';
import { fetchHydrogenDaily } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
import {
getQuickRange,
getRangeModeLabel,
normalizeRange,
summarizeHydrogenRows,
type RangeMode,
} from './hydrogen-daily/model';
import DailyRangeControls from './daily-range/DailyRangeControls';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
export default function HydrogenDaily() {
const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
useEffect(() => {
let cancelled = false;
setError(null);
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
fetchHydrogenDaily(query, customer)
.then(r => { if (!cancelled) setRows(r); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
const {
trendData,
totalKg,
activeDays,
stationCount,
avgKg,
peakDay,
lowDay,
zeroDays,
} = useMemo(() => summarizeHydrogenRows(rows), [rows]);
const scopeLabel = getRangeModeLabel(pick);
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
const toggle = (date: string) => setExpanded(prev => {
const next = new Set(prev);
next.has(date) ? next.delete(date) : next.add(date);
return next;
});
const applyQuickPick = (nextPick: DateQuickPick) => {
setPick(nextPick);
setDateRange(getQuickRange(nextPick));
};
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
setPick('custom');
setDateRange(prev => ({ ...prev, [field]: value }));
};
return (
<div className="flex flex-col gap-3">
<DailyRangeControls
pick={pick}
dateRange={dateRange}
customer={customer}
onQuickPick={applyQuickPick}
onCustomPick={() => setPick('custom')}
onDateRangeChange={updateDateRange}
onCustomerChange={setCustomer}
/>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
</div>
{/* 外部车辆:新系统数据还没准备好 */}
{customer === 'external' && rows !== null && totalKg === 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
<Plug size={22} className="text-blue-500" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
</div>
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
新系统的外部车辆加氢数据还在准备中
<br />
上线后此处将展示完整明细
</div>
</motion.div>
)}
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
<SurfaceCard>
<div className="flex items-center justify-between px-4 pt-4 mb-2">
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
<span className="text-[11px] text-slate-400 font-bold">时间单位:日 · 单位 Kg</span>
</div>
<div className="mx-4 mb-2 grid grid-cols-3 gap-2 rounded-xl bg-slate-50 p-2">
<div>
<div className="text-[10px] font-black text-slate-400">峰值日</div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{peakDay ? `${peakDay.date.slice(5)} · ${peakDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400">低谷日</div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{lowDay ? `${lowDay.date.slice(5)} · ${lowDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400">零数据日</div>
<div className={`mt-0.5 text-[11px] font-black ${zeroDays > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
{zeroDays} 天
</div>
</div>
</div>
<div className="h-[180px] min-w-0 px-2 pb-2">
<ResponsiveContainer width="100%" height={180} minWidth={0}>
<BarChart data={trendData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<XAxis
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={8}
/>
<YAxis
width={42}
axisLine={false}
tickLine={false}
tick={{ fontSize: 9, fill: '#94a3b8' }}
tickFormatter={(v: number) => v >= 1000 ? `${Math.round(v / 1000)}k` : `${Math.round(v)}`}
/>
<Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/>
{avgKg > 0 && (
<ReferenceLine
y={avgKg}
stroke="#f59e0b"
strokeDasharray="4 4"
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/>
)}
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => (
<Cell key={i} fill="url(#hydrogenBarGrad)" />
))}
</Bar>
<defs>
<linearGradient id="hydrogenBarGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#22d3ee" />
<stop offset="100%" stopColor="#3b82f6" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
</SurfaceCard>
)}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
{!(customer === 'external' && rows !== null && totalKg === 0) && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 表头 */}
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
<span>日期 / 加氢站</span>
<span className="hidden md:block text-right">单价 (元/Kg)</span>
<span className="text-right">加氢量 (Kg)</span>
<span className="text-right">环比</span>
</div>
{/* 合计行 */}
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
<span>合计</span>
<span className="hidden md:block" />
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
<span />
</div>
{/* 主行 + 子行 */}
{error ? (
<div className="p-3"><ErrorState message={error} /></div>
) : rows === null ? (
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
) : rows.length === 0 ? (
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
) : rows.map(r => {
const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
const abnormalBg = isAbnormal
? r.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
: '';
return (
<div key={r.date} className={`border-t border-slate-100 ${abnormalBg}`}>
<button
onClick={() => toggle(r.date)}
className="w-full grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2.5 text-left hover:bg-slate-50/60 transition-colors"
>
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
{r.date}
</span>
<span className="hidden md:block text-right text-[12px] text-slate-300">—</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={r.chainPct} /></span>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden bg-slate-50/50"
>
{r.stations.map(s => (
<div
key={s.name}
className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 pl-6 md:pl-9 border-t border-slate-100 first:border-t-0 items-start"
>
<div className="min-w-0">
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
{s.name}
</div>
{s.pricePerKg > 0 && (
<div className="md:hidden mt-1">
<span className="inline-flex items-center text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded font-bold whitespace-nowrap">
单价 {s.pricePerKg} 元/Kg
</span>
</div>
)}
</div>
<span className="hidden md:block text-right text-[12px] text-slate-500 font-bold tabular-nums">{s.pricePerKg > 0 ? s.pricePerKg : '—'}</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{s.kg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={s.chainPct} /></span>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
)}
<RotatingFooterHint />
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import { AnimatePresence } from 'motion/react';
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface';
const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
] as const satisfies readonly { id: HydrogenSubTab; label: string; icon: typeof CalendarDays }[];
const SUB_IDS: readonly HydrogenSubTab[] = ['daily', 'overview'];
export default function HydrogenModule() {
const [sub, setSub] = useHashSubTab<HydrogenSubTab>('hydrogen', SUB_IDS);
return (
<PageFrame
title="氢能经营看板"
subtitle="按时间、车辆归属、加氢站和区域统一展示加氢量、费用、收入与异常波动。"
icon={CalendarDays}
eyebrow="ENERGY BI"
meta="数据单位清晰标注 · 支持日/总览切换"
>
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait">
<FadeIn key={sub}>
<HydrogenView sub={sub} />
</FadeIn>
</AnimatePresence>
</PageFrame>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
import { DistributionCharts } from './hydrogen-overview/components/DistributionCharts';
import { HydrogenOverviewSkeleton } from './hydrogen-overview/components/HydrogenOverviewSkeleton';
import { InsightCards } from './hydrogen-overview/components/InsightCards';
import { KpiSection } from './hydrogen-overview/components/KpiSection';
import { MonthlyCharts } from './hydrogen-overview/components/MonthlyCharts';
import { OverviewHeader } from './hydrogen-overview/components/OverviewHeader';
import { RefreshOverlay } from './hydrogen-overview/components/RefreshOverlay';
import { CustomerSummaryTable, StationSummaryTable } from './hydrogen-overview/components/SummaryTables';
import { deriveOverviewMetrics, formatYuan as fmtYuan } from './hydrogen-overview/model';
export default function HydrogenOverview() {
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [year, setYear] = useState<number | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [lastRefreshAt, setLastRefreshAt] = useState<number>(0);
const refreshSeq = useRef(0);
const load = useCallback(async (selectedYear: number | null, force: boolean) => {
const seq = ++refreshSeq.current;
setRefreshing(true);
try {
const d = await fetchHydrogenOverview(selectedYear ?? undefined, force);
if (seq !== refreshSeq.current) return; // outdated
setData(d);
setError(null);
setLastRefreshAt(Date.now());
} catch (e) {
if (seq !== refreshSeq.current) return;
setError(e instanceof Error ? e.message : String(e));
} finally {
if (seq === refreshSeq.current) setRefreshing(false);
}
}, []);
// 初始加载 + 年份切换:用 force=false 命中热缓存
useEffect(() => { void load(year, false); }, [year, load]);
// 客户端兜底自动刷新:每 60s 静默拉一次(命中后端热缓存,几乎零成本)
useEffect(() => {
const t = setInterval(() => { void load(year, false); }, 60_000);
return () => clearInterval(t);
}, [year, load]);
if (error && !data) {
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
}
if (!data) {
return <HydrogenOverviewSkeleton />;
}
const { kpi, top5, regions, monthly, customers, stations, availableYears, year: activeYear } = data;
const {
monthAvgKg,
bestMonth,
latestMonth,
monthMomentum,
top5Share,
profitYield,
stationAvgKg,
monthlyDual,
} = deriveOverviewMetrics(data);
const yearProfitFmt = fmtYuan(kpi.yearProfit);
const yearRevenueFmt = fmtYuan(kpi.yearRevenue);
return (
<div className="flex flex-col gap-3 relative">
<OverviewHeader
activeYear={activeYear}
availableYears={availableYears}
lastRefreshAt={lastRefreshAt}
refreshing={refreshing}
onSelectYear={setYear}
onRefresh={() => void load(year, true)}
/>
<KpiSection kpi={kpi} />
<InsightCards
monthAvgKg={monthAvgKg}
bestMonth={bestMonth}
latestMonth={latestMonth}
monthMomentum={monthMomentum}
top5Share={top5Share}
profitYield={profitYield}
stationAvgKg={stationAvgKg}
stationCount={stations.length}
yearProfitValue={yearProfitFmt.value}
yearProfitUnit={yearProfitFmt.unit}
yearRevenueValue={yearRevenueFmt.value}
yearRevenueUnit={yearRevenueFmt.unit}
/>
<MonthlyCharts activeYear={activeYear} monthly={monthly} monthlyDual={monthlyDual} />
<DistributionCharts top5={top5} regions={regions} yearKg={kpi.yearKg} />
<StationSummaryTable stations={stations} />
<CustomerSummaryTable customers={customers} />
<RotatingFooterHint />
<RefreshOverlay refreshing={refreshing} hasData={Boolean(data)} />
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import HydrogenOverview from './HydrogenOverview';
import HydrogenDaily from './HydrogenDaily';
export type HydrogenSubTab = 'daily' | 'overview';
interface Props {
sub: HydrogenSubTab;
}
export default function HydrogenView({ sub }: Props) {
return sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily />;
}
+32 -43
View File
@@ -1,70 +1,59 @@
import { fetchJson } from '../../auth/api-client';
import type {
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
HydrogenCustomerRow, HydrogenStationFull,
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
CustomerType, DateQuickPick,
HydrogenStationBoardResponse,
} from './types';
const BASE = '/api/energy';
/**
* 氢能单站日报数据源。
* 注意:该接口路径仍带 `/hydrogen` 前缀(历史命名),但为当前单站日报页面在用,
* 与已下线的 `/hydrogen/overview|daily|settlement` 等旧接口无关。
*/
export interface HydrogenStationBoardQuery {
startDate: string;
endDate: string;
stationId?: number | null;
force?: boolean;
export interface HydrogenOverviewResponse {
kpi: HydrogenKpi;
top5: HydrogenStationTop[];
regions: HydrogenRegionShare[];
monthly: HydrogenMonthlyPoint[];
customers: HydrogenCustomerRow[];
stations: HydrogenStationFull[];
availableYears: number[];
year: number;
}
export function fetchHydrogenStationBoard(query: HydrogenStationBoardQuery): Promise<HydrogenStationBoardResponse> {
const params = new URLSearchParams({ startDate: query.startDate, endDate: query.endDate });
if (query.stationId) params.set('stationId', String(query.stationId));
if (query.force) params.set('force', '1');
return fetchJson<HydrogenStationBoardResponse>(`${BASE}/hydrogen/station-board?${params.toString()}`);
export function fetchHydrogenOverview(year?: number, force = false): Promise<HydrogenOverviewResponse> {
const params = new URLSearchParams();
if (year) params.set('year', String(year));
if (force) params.set('force', '1');
const q = params.toString();
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
}
/** 电能与 ETC 共用的自然日区间查询参数。 */
export interface EnergyRangeQuery {
export interface HydrogenDailyQuery {
range?: DateQuickPick;
startDate?: string;
endDate?: string;
}
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
const q = new URLSearchParams({ customer });
if (query.range) q.set('range', query.range);
if (query.startDate) q.set('startDate', query.startDate);
if (query.endDate) q.set('endDate', query.endDate);
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
}
export interface ElectricOverviewResponse {
kpi: ElectricKpi;
trend: ElectricDailyRow[];
latestChargeTime: string | null;
}
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
}
export function fetchElectricMonthly(
customer: CustomerType,
query: EnergyRangeQuery = { range: 'last15' },
): Promise<ElectricMonthGroup[]> {
const params = new URLSearchParams({ customer });
if (query.range) params.set('range', query.range);
if (query.startDate) params.set('startDate', query.startDate);
if (query.endDate) params.set('endDate', query.endDate);
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${params.toString()}`);
}
export interface EtcOverviewResponse {
tollRecordCount: number;
vehicleCount: number;
totalAmount: number;
latestTollTime: string | null;
billCount: number;
receivableAmount: number;
paidAmount: number;
hasData: boolean;
}
export function fetchEtcOverview(force = false): Promise<EtcOverviewResponse> {
return fetchJson<EtcOverviewResponse>(`${BASE}/etc/overview${force ? '?force=1' : ''}`);
export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
const q = new URLSearchParams({ customer });
if (query.range) q.set('range', query.range);
if (query.startDate) q.set('startDate', query.startDate);
if (query.endDate) q.set('endDate', query.endDate);
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
}
@@ -1,4 +1,5 @@
import { Calendar, Fuel, RefreshCw, Truck } from 'lucide-react';
import { Truck } from 'lucide-react';
import { SurfaceCard } from '../../../components/ui/surface';
import type { CustomerType, DateQuickPick } from '../types';
import { QUICK_PICK_OPTIONS, type RangeMode } from './model';
@@ -6,138 +7,86 @@ 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 (
<section className="rounded-[14px] border border-slate-200/70 bg-white px-3 py-3 shadow-sm md:px-4">
<div className="flex flex-col gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div className="flex rounded-md bg-slate-100 p-0.5">
{QUICK_PICK_OPTIONS.map(option => (
<button
key={option.id}
type="button"
onClick={() => onQuickPick(option.id)}
className={`h-7 rounded px-3 text-[12px] font-medium transition-colors ${
pick === option.id
? 'bg-white font-semibold text-blue-600 shadow-sm'
: 'text-slate-500 hover:text-slate-800'
}`}
>
{option.label}
</button>
))}
<button
type="button"
onClick={onCustomPick}
className={`h-7 rounded px-3 text-[12px] font-medium transition-colors ${
pick === 'custom'
? 'bg-white font-semibold text-blue-600 shadow-sm'
: 'text-slate-500 hover:text-slate-800'
}`}
>
自定义
</button>
</div>
<DateField label="开始日期" value={dateRange.start} onChange={value => onDateRangeChange('start', value)} />
<DateField label="结束日期" value={dateRange.end} onChange={value => onDateRangeChange('end', value)} />
{onStationChange ? (
<label className="inline-flex h-8 min-w-[190px] max-w-full items-center gap-2 rounded-md border border-slate-300 bg-white px-2.5 text-slate-600 transition-colors focus-within:border-sky-600 focus-within:ring-2 focus-within:ring-sky-100">
<Fuel size={13} className="shrink-0 text-slate-400" />
<select
aria-label="选择加氢站"
value={selectedStationId ?? ''}
onChange={event => onStationChange(event.target.value === '' ? null : Number(event.target.value))}
className="min-w-0 flex-1 bg-transparent text-[12px] font-semibold text-slate-700 outline-none"
>
<option value="">请选择加氢站</option>
{stations.map((station, index) => (
<option key={`${station.id}-${station.name}-${index}`} value={station.id}>{station.name}</option>
))}
</select>
</label>
) : null}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex rounded-md bg-slate-100 p-0.5">
{(['lingniu', 'external'] as const).map(option => (
<button
key={option}
type="button"
onClick={() => onCustomerChange(option)}
className={`inline-flex h-8 items-center gap-1.5 rounded px-3 text-[12px] font-medium transition-colors ${
customer === option
? 'bg-white font-semibold text-blue-600 shadow-sm'
: 'text-slate-500 hover:text-slate-800'
}`}
>
<Truck size={13} />
{option === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
<div className="ml-auto flex items-center gap-3">
{updatedAt ? <span className="font-mono text-[11px] text-slate-400">{updatedAt}</span> : null}
{onRefresh ? (
<button
type="button"
onClick={onRefresh}
disabled={loading}
title="刷新数据"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-slate-200 bg-white px-3 text-[12px] font-semibold text-slate-600 transition-colors hover:border-blue-200 hover:text-blue-600 disabled:cursor-wait disabled:opacity-60"
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
刷新
</button>
) : null}
</div>
</div>
<SurfaceCard className="p-2 md:p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => onQuickPick(opt.id)}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === opt.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
<button
onClick={onCustomPick}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
自定义
</button>
</div>
</section>
);
}
function DateField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return (
<label className="inline-flex h-8 items-center gap-2 rounded-md border border-slate-300 bg-white px-2.5 transition-colors focus-within:border-sky-600 focus-within:ring-2 focus-within:ring-sky-100">
<Calendar size={13} className="shrink-0 text-sky-600" />
<span className="hidden text-[11px] font-medium text-slate-400 sm:inline">{label}</span>
<input
type="date"
aria-label={label}
value={value}
onChange={event => onChange(event.target.value)}
className="h-7 min-w-[112px] bg-transparent font-mono text-[11px] font-semibold text-slate-700 outline-none"
/>
</label>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
<input
type="date"
value={dateRange.start}
onChange={event => onDateRangeChange('start', event.target.value)}
onInput={event => onDateRangeChange('start', event.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400">结束日期</span>
<input
type="date"
value={dateRange.end}
onChange={event => onDateRangeChange('end', event.target.value)}
onInput={event => onDateRangeChange('end', event.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(option => (
<button
key={option}
onClick={() => onCustomerChange(option)}
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
customer === option ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
<Truck size={14} />
{option === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
</SurfaceCard>
);
}
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { HydrogenDailyRow } from '../types.js';
import {
getQuickRange,
getRangeModeLabel,
normalizeRange,
summarizeHydrogenRows,
} from './model.js';
test('快捷日期继续使用本地自然日并覆盖本周、本月和近15天', () => {
const now = new Date(2026, 7, 12, 23, 30);
assert.deepEqual(getQuickRange('thisWeek', now), {
start: '2026-08-10',
end: '2026-08-12',
});
assert.deepEqual(getQuickRange('thisMonth', now), {
start: '2026-08-01',
end: '2026-08-12',
});
assert.deepEqual(getQuickRange('last15', now), {
start: '2026-07-29',
end: '2026-08-12',
});
});
test('自定义日期倒置时仅交换查询边界', () => {
assert.deepEqual(normalizeRange('2026-08-12', '2026-08-01'), {
start: '2026-08-01',
end: '2026-08-12',
});
assert.equal(getRangeModeLabel('custom'), '自定义区间');
assert.equal(getRangeModeLabel('last15'), '近 15 天');
});
test('每日加氢统计保持排序、有效天、站点去重和峰谷口径', () => {
const rows: HydrogenDailyRow[] = [
{
date: '2026-08-12',
totalKg: 0,
chainPct: -1,
customerType: 'lingniu',
stations: [{ name: 'A站', kg: 0, pricePerKg: 0, chainPct: -1 }],
},
{
date: '2026-08-10',
totalKg: 100,
chainPct: 0,
customerType: 'lingniu',
stations: [{ name: 'A站', kg: 100, pricePerKg: 20, chainPct: 0 }],
},
{
date: '2026-08-11',
totalKg: 300,
chainPct: 2,
customerType: 'lingniu',
stations: [{ name: 'B站', kg: 300, pricePerKg: 25, chainPct: 2 }],
},
];
const summary = summarizeHydrogenRows(rows);
assert.deepEqual(rows.map(row => row.date), ['2026-08-12', '2026-08-10', '2026-08-11']);
assert.deepEqual(summary.trendData.map(row => row.date), ['2026-08-10', '2026-08-11', '2026-08-12']);
assert.equal(summary.totalKg, 400);
assert.equal(summary.activeDays, 2);
assert.equal(summary.avgKg, 200);
assert.equal(summary.stationCount, 2);
assert.equal(summary.peakDay?.date, '2026-08-11');
assert.equal(summary.lowDay?.date, '2026-08-10');
assert.equal(summary.zeroDays, 1);
});
test('空数据保持零值且没有峰谷日', () => {
assert.deepEqual(summarizeHydrogenRows(null), {
trendData: [],
totalKg: 0,
activeDays: 0,
stationCount: 0,
avgKg: 0,
peakDay: null,
lowDay: null,
zeroDays: 0,
});
});
@@ -0,0 +1,41 @@
import type { HydrogenDailyRow } from '../types';
export {
formatYmd,
getQuickRange,
getRangeModeLabel,
normalizeRange,
QUICK_PICK_OPTIONS,
type RangeMode,
} from '../daily-range/model';
export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
const source = rows ?? [];
// 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。
const trendData = [...source].sort((left, right) => left.date.localeCompare(right.date));
const totalKg = source.reduce((total, row) => total + row.totalKg, 0);
const activeDays = source.filter(row => row.totalKg > 0).length;
const stationNames = new Set<string>();
source.forEach(row => row.stations.forEach(station => stationNames.add(station.name)));
const peakDay = trendData.reduce<HydrogenDailyRow | null>(
(best, item) => (!best || item.totalKg > best.totalKg ? item : best),
null,
);
const lowDay = trendData
.filter(item => item.totalKg > 0)
.reduce<HydrogenDailyRow | null>(
(low, item) => (!low || item.totalKg < low.totalKg ? item : low),
null,
);
return {
trendData,
totalKg,
activeDays,
stationCount: stationNames.size,
avgKg: activeDays > 0 ? totalKg / activeDays : 0,
peakDay,
lowDay,
zeroDays: source.filter(row => row.totalKg === 0).length,
};
}
@@ -0,0 +1,133 @@
import {
Bar,
BarChart,
Cell,
LabelList,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { HydrogenRegionShare, HydrogenStationTop } from '../../types';
const REGION_COLORS = [
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
'#94a3b8',
];
interface YAxisTickProps {
x?: number;
y?: number;
index?: number;
payload?: { value: string };
}
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
return (
<g transform={`translate(${x},${y})`}>
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
{index + 1}
</text>
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
{payload?.value}
</text>
</g>
);
}
interface DistributionChartsProps {
top5: HydrogenStationTop[];
regions: HydrogenRegionShare[];
yearKg: number;
}
export function DistributionCharts({ top5, regions, yearKg }: DistributionChartsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-bold text-slate-700">加氢站加注量 Top5</span>
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
</div>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
width={188}
tick={<RankYAxisTick />}
tickLine={false}
axisLine={false}
/>
<Tooltip
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
/>
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
{top5.map((_, i) => (
<Cell key={i} fill="url(#topBarGrad)" />
))}
<LabelList
dataKey="kg"
position="right"
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
fill="#475569"
fontSize={11}
fontWeight={700}
/>
</Bar>
<defs>
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#22d3ee" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
<span className="text-sm font-bold text-slate-700">各区域加氢占比</span>
<div className="flex items-center gap-2">
<div className="relative w-1/2 h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={regions}
dataKey="kg"
nameKey="region"
innerRadius={48}
outerRadius={80}
paddingAngle={1}
>
{regions.map((_, i) => (
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
<div className="text-base font-bold text-slate-700 leading-tight">{(yearKg / 1000).toFixed(2)}T</div>
</div>
</div>
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
{regions.map((r, i) => (
<div key={r.region} className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
<span className="text-slate-600 truncate">{r.region}</span>
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
export function HydrogenOverviewSkeleton() {
return (
<div className="flex flex-col gap-3 animate-pulse">
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
<div className="h-3 w-44 bg-slate-100 rounded" />
</div>
{/* 5 卡占位 */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-xl bg-slate-100" />
<div className="h-3 w-16 bg-slate-100 rounded" />
</div>
<div className="h-7 w-24 bg-slate-200 rounded" />
<div className="space-y-1.5 pt-1 border-t border-slate-50">
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
</div>
</div>
))}
</div>
{/* 月度柱图占位 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
<div className="flex items-end gap-2 h-[120px]">
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
<div className="space-y-3">
{[100, 78, 56, 40, 28].map((w, i) => (
<div key={i} className="flex items-center gap-3">
<div className="w-5 h-5 rounded-full bg-slate-200" />
<div className="h-3 w-32 bg-slate-100 rounded" />
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
))}
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
<div className="h-4 w-28 bg-slate-100 rounded" />
<div className="flex items-center gap-3">
<div className="w-1/2 h-[200px] flex items-center justify-center">
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
</div>
<div className="flex-1 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-slate-200" />
<div className="h-3 w-16 bg-slate-100 rounded" />
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
</div>
))}
</div>
</div>
</div>
</div>
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
正在加载氢能总览…
</div>
</div>
);
}
@@ -0,0 +1,88 @@
import { AlertTriangle, Building2, Gauge } from 'lucide-react';
import type { HydrogenMonthlyPoint } from '../../types';
import { formatKg as fmtKg } from '../model';
interface InsightCardsProps {
monthAvgKg: number;
bestMonth: HydrogenMonthlyPoint | null;
latestMonth: HydrogenMonthlyPoint | undefined;
monthMomentum: number | null;
top5Share: number;
profitYield: number;
stationAvgKg: number;
stationCount: number;
yearProfitValue: string;
yearProfitUnit: string;
yearRevenueValue: string;
yearRevenueUnit: string;
}
export function InsightCards({
monthAvgKg,
bestMonth,
latestMonth,
monthMomentum,
top5Share,
profitYield,
stationAvgKg,
stationCount,
yearProfitValue,
yearProfitUnit,
yearRevenueValue,
yearRevenueUnit,
}: InsightCardsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 md:gap-3">
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400">月度动能</div>
<div className="mt-1 text-lg font-black text-slate-900">
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
</div>
</div>
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<Gauge size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400">站点集中度</div>
<div className="mt-1 text-lg font-black text-slate-900">Top5 {top5Share.toFixed(1)}%</div>
</div>
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-cyan-50 text-cyan-600 ring-1 ring-cyan-100">
<Building2 size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
共 {stationCount} 站 · 单站年均 {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit}
{top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'}
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400">收支健康度</div>
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{profitYield.toFixed(1)}%
</div>
</div>
<span className={`flex h-9 w-9 items-center justify-center rounded-xl ring-1 ${profitYield >= 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}>
<AlertTriangle size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
时享获利 {yearProfitValue}{yearProfitUnit} · 客户收入 {yearRevenueValue}{yearRevenueUnit}
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
</div>
</div>
</div>
);
}
@@ -0,0 +1,114 @@
import type { ReactNode } from 'react';
import { CalendarDays, Fuel, Sparkles, TrendingUp, Wallet } from 'lucide-react';
import type { HydrogenKpi } from '../../types';
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
interface KpiCardProps {
icon: ReactNode;
label: string;
hero: { value: string; unit: string };
rows: { label: string; value: string; valueClass?: string }[];
accentClass: string;
iconBg: string;
}
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
{icon}
</div>
<span className="text-[11px] font-bold text-slate-500">{label}</span>
</div>
<div className="flex items-baseline gap-1">
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
</div>
<div className="space-y-0.5 pt-1 border-t border-slate-50">
{rows.map((r, i) => (
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
<span className="text-slate-400">{r.label}</span>
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
</div>
))}
</div>
</div>
);
}
export function KpiSection({ kpi: k }: { kpi: HydrogenKpi }) {
const yearKgFmt = fmtKg(k.yearKg);
const yearFeeFmt = fmtYuan(k.yearFee);
const yearProfitFmt = fmtYuan(k.yearProfit);
const ourYearKgFmt = fmtKg(k.ourYearKg);
const customerYearKgFmt = fmtKg(k.customerYearKg);
const monthKgFmt = fmtKg(k.monthKg);
const monthFeeFmt = fmtYuan(k.monthFee);
const todayKgFmt = fmtKg(k.todayKg);
const todayFeeFmt = fmtYuan(k.todayFee);
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
const customerYearFeeFmt = fmtYuan(customerYearFee);
const yearRevenueFmt = fmtYuan(k.yearRevenue);
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
<KpiCard
icon={<Fuel size={14} className="text-cyan-600" strokeWidth={2.4} />}
iconBg="bg-cyan-50"
accentClass="text-slate-800"
label="累计加氢量"
hero={yearKgFmt}
rows={[
{ label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` },
{ label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` },
]}
/>
<KpiCard
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
iconBg="bg-blue-50"
accentClass="text-slate-800"
label="累计加氢费"
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
rows={[
{ label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
{ label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
]}
/>
<KpiCard
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
iconBg="bg-emerald-50"
accentClass={profitColor}
label="时享加氢获利"
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
rows={[
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
]}
/>
<KpiCard
icon={<CalendarDays size={14} className="text-amber-600" strokeWidth={2.4} />}
iconBg="bg-amber-50"
accentClass="text-amber-600"
label="本月加氢"
hero={monthKgFmt}
rows={[
{ label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` },
{ label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` },
]}
/>
<KpiCard
icon={<Sparkles size={14} className="text-violet-600" strokeWidth={2.4} />}
iconBg="bg-violet-50"
accentClass="text-violet-600"
label="本日加氢"
hero={todayKgFmt}
rows={[
{ label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` },
{ label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` },
]}
/>
</div>
);
}
@@ -0,0 +1,101 @@
import {
Bar,
BarChart,
Cell,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { HydrogenMonthlyPoint } from '../../types';
import { formatYuan as fmtYuan } from '../model';
type MonthlyChartPoint = HydrogenMonthlyPoint & { monthLabel: string };
interface MonthlyChartsProps {
activeYear: number;
monthly: HydrogenMonthlyPoint[];
monthlyDual: MonthlyChartPoint[];
}
export function MonthlyCharts({ activeYear, monthly, monthlyDual }: MonthlyChartsProps) {
return (
<>
{monthly.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度加氢量</span>
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
</div>
<ResponsiveContainer width="100%" height={140}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval={0}
/>
<YAxis hide />
<Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']}
labelFormatter={(d) => `${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/>
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
{monthlyDual.map((_, i) => (
<Cell key={i} fill="url(#monthlyBarGrad)" />
))}
</Bar>
<defs>
<linearGradient id="monthlyBarGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#22d3ee" />
<stop offset="100%" stopColor="#3b82f6" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
)}
{monthly.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度收支对比</span>
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
</div>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval={0}
/>
<YAxis hide />
<Legend
verticalAlign="top"
height={20}
iconSize={8}
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
/>
<Tooltip
formatter={(v, name) => {
const f = fmtYuan(Number(v ?? 0));
return [`¥${f.value} ${f.unit}`, name];
}}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
/>
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</>
);
}
@@ -0,0 +1,53 @@
import { RefreshCw } from 'lucide-react';
import { formatRefreshTime } from '../model';
interface OverviewHeaderProps {
activeYear: number;
availableYears: number[];
lastRefreshAt: number;
refreshing: boolean;
onSelectYear: (year: number) => void;
onRefresh: () => void;
}
export function OverviewHeader({
activeYear,
availableYears,
lastRefreshAt,
refreshing,
onSelectYear,
onRefresh,
}: OverviewHeaderProps) {
return (
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRefreshTime(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
{availableYears.map(y => {
const active = y === activeYear;
return (
<button
key={y}
onClick={() => onSelectYear(y)}
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
}`}
>
{y}
</button>
);
})}
</div>
<button
onClick={onRefresh}
disabled={refreshing}
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
title="手动刷新(绕过缓存)"
>
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
<span className="text-[11px] font-bold">刷新</span>
</button>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
import { AnimatePresence, motion } from 'motion/react';
interface RefreshOverlayProps {
refreshing: boolean;
hasData: boolean;
}
export function RefreshOverlay({ refreshing, hasData }: RefreshOverlayProps) {
return (
<AnimatePresence>
{refreshing && hasData && (
<motion.div
key="refresh-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
>
<motion.div
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
initial={{ x: '-100%' }}
animate={{ x: '100%' }}
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
style={{ width: '40%' }}
/>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,123 @@
import type { HydrogenCustomerRow, HydrogenStationFull } from '../../types';
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
export function StationSummaryTable({ stations }: { stations: HydrogenStationFull[] }) {
return (
<>
{stations.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">加氢站加氢汇总</span>
<span className="text-[11px] text-slate-400 font-bold">共 {stations.length} 站</span>
</div>
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full text-[11px]">
<thead>
<tr className="text-slate-400 font-bold border-b border-slate-100">
<th className="text-left py-1.5 pl-1 w-8">#</th>
<th className="text-left py-1.5">加氢站</th>
<th className="text-right py-1.5 w-20">加氢量</th>
<th className="text-right py-1.5 pl-2 hidden sm:table-cell">占比</th>
<th className="text-right py-1.5 pl-2 w-24">氢费收入</th>
<th className="text-right py-1.5 pr-1 hidden md:table-cell">收入占比</th>
</tr>
</thead>
<tbody>
{stations.map((s, i) => {
const kgFmt = fmtKg(s.kg);
const revFmt = fmtYuan(s.revenue);
return (
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
</td>
<td className="py-1.5 pl-2 text-right hidden sm:table-cell">
<div className="inline-flex items-center gap-1.5">
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-cyan-400 to-blue-500" style={{ width: `${Math.min(100, s.share * 100)}%` }} />
</div>
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
</div>
</td>
<td className="py-1.5 pl-2 text-right tabular-nums font-bold text-emerald-600">
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
</td>
<td className="py-1.5 pr-1 text-right hidden md:table-cell">
<div className="inline-flex items-center gap-1.5">
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: `${Math.min(100, s.revenueShare * 100)}%` }} />
</div>
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</>
);
}
export function CustomerSummaryTable({ customers }: { customers: HydrogenCustomerRow[] }) {
return (
<>
{customers.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">客户账单汇总</span>
<span className="text-[11px] text-slate-400 font-bold">Top {customers.length}</span>
</div>
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full text-[11px]">
<thead>
<tr className="text-slate-400 font-bold border-b border-slate-100">
<th className="text-left py-1.5 pl-1 w-8">#</th>
<th className="text-left py-1.5">客户</th>
<th className="text-center py-1.5 w-14 hidden sm:table-cell">承担方</th>
<th className="text-right py-1.5 w-20">加氢量</th>
<th className="text-right py-1.5 pl-2 w-24">成本支出</th>
<th className="text-right py-1.5 pr-1 w-24 hidden md:table-cell">应收</th>
</tr>
</thead>
<tbody>
{customers.map((c2, i) => {
const kgFmt = fmtKg(c2.kg);
const costFmt = fmtYuan(c2.cost);
const revFmt = fmtYuan(c2.revenue);
return (
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
<td className="py-1.5 text-center hidden sm:table-cell">
{c2.payer === 'lingniu' ? (
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
) : (
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold">客户</span>
)}
</td>
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
</td>
<td className="py-1.5 pl-2 text-right tabular-nums text-amber-600 font-bold">
¥{costFmt.value}<span className="text-slate-400 font-normal ml-0.5">{costFmt.unit}</span>
</td>
<td className="py-1.5 pr-1 text-right tabular-nums text-emerald-600 font-bold hidden md:table-cell">
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { HydrogenOverviewResponse } from '../api.js';
import {
deriveOverviewMetrics,
formatKg,
formatRelative,
formatYuan,
} from './model.js';
test('重量和金额单位保持现有阈值及负数格式', () => {
assert.deepEqual(formatKg(999.5), { value: '999.50', unit: 'Kg' });
assert.deepEqual(formatKg(1000), { value: '1.00', unit: 'T' });
assert.deepEqual(formatYuan(9999), { value: '9,999', unit: '元' });
assert.deepEqual(formatYuan(12345), { value: '1.23', unit: '万元' });
assert.deepEqual(formatYuan(-100_000_000), { value: '-1.00', unit: '亿元' });
});
test('相对更新时间保持秒、分钟、小时和未来时间边界', () => {
const now = new Date(2026, 7, 13, 12, 0, 0).getTime();
assert.equal(formatRelative(now + 60_000, now), '刚刚');
assert.equal(formatRelative(now - 30_000, now), '30 秒前');
assert.equal(formatRelative(now - 5 * 60_000, now), '5 分钟前');
assert.equal(formatRelative(now - 3 * 60 * 60_000, now), '3 小时前');
});
test('总览派生指标保持月份顺序、动能、集中度和收益率口径', () => {
const data = {
kpi: {
yearKg: 1000,
yearFee: 0,
yearProfit: -50,
ourYearKg: 0,
customerYearKg: 0,
ourYearFee: 0,
yearRevenue: 500,
monthKg: 0,
monthFee: 0,
monthRevenue: 0,
monthProfit: 0,
todayKg: 0,
todayFee: 0,
todayRevenue: 0,
todayProfit: 0,
lingniuBornKg: 0,
lingniuBornFee: 0,
},
top5: [{ rank: 1, name: 'A', kg: 600, fee: 100, share: 0.6 }],
regions: [],
monthly: [
{ month: '2026-01', kg: 100, fee: 20, revenue: 30, profit: 10 },
{ month: '2026-02', kg: 150, fee: 30, revenue: 40, profit: 10 },
],
customers: [],
stations: [
{ name: 'A', kg: 600, share: 0.6, revenue: 300, revenueShare: 0.6 },
{ name: 'B', kg: 400, share: 0.4, revenue: 200, revenueShare: 0.4 },
],
availableYears: [2026],
year: 2026,
} satisfies HydrogenOverviewResponse;
const metrics = deriveOverviewMetrics(data);
assert.equal(metrics.monthAvgKg, 125);
assert.equal(metrics.bestMonth?.month, '2026-02');
assert.equal(metrics.latestMonth?.month, '2026-02');
assert.equal(metrics.monthMomentum, 50);
assert.equal(metrics.top5Share, 60);
assert.equal(metrics.profitYield, -10);
assert.equal(metrics.stationAvgKg, 500);
assert.deepEqual(metrics.monthlyDual.map(item => item.monthLabel), ['1月', '2月']);
});
@@ -0,0 +1,75 @@
import type { HydrogenOverviewResponse } from '../api';
export function formatKg(kg: number): { value: string; unit: string } {
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
return { value: kg.toFixed(2), unit: 'Kg' };
}
export function formatYuan(yuan: number): { value: string; unit: string } {
const absolute = Math.abs(yuan);
if (absolute >= 100_000_000) {
return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
}
if (absolute >= 10_000) {
return {
value: (yuan / 10_000).toLocaleString('zh-CN', { maximumFractionDigits: 2 }),
unit: '万元',
};
}
return {
value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }),
unit: '元',
};
}
export function formatRelative(timestamp: number, now = Date.now()): string {
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
if (seconds < 5) return '刚刚';
if (seconds < 60) return `${seconds} 秒前`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
return new Date(timestamp).toLocaleString('zh-CN', { hour12: false });
}
export function formatRefreshTime(timestamp: number, now = Date.now()): string {
const exactTime = new Date(timestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
return `${formatRelative(timestamp, now)} · ${exactTime.replace(/\//g, '-')}`;
}
export function deriveOverviewMetrics(data: HydrogenOverviewResponse) {
const { kpi, monthly, stations, top5 } = data;
const monthAvgKg = monthly.length > 0
? monthly.reduce((sum, month) => sum + month.kg, 0) / monthly.length
: 0;
const bestMonth = monthly.reduce<typeof monthly[number] | null>(
(best, item) => (!best || item.kg > best.kg ? item : best),
null,
);
const latestMonth = monthly[monthly.length - 1];
const previousMonth = monthly[monthly.length - 2];
return {
monthAvgKg,
bestMonth,
latestMonth,
monthMomentum: latestMonth && previousMonth && previousMonth.kg > 0
? ((latestMonth.kg - previousMonth.kg) / previousMonth.kg) * 100
: null,
top5Share: (top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, kpi.yearKg)) * 100,
profitYield: kpi.yearRevenue > 0 ? (kpi.yearProfit / kpi.yearRevenue) * 100 : 0,
stationAvgKg: stations.length > 0 ? kpi.yearKg / stations.length : 0,
monthlyDual: monthly.map(month => ({
...month,
monthLabel: `${month.month.slice(5).replace(/^0/, '')}月`,
})),
};
}
-182
View File
@@ -1,182 +0,0 @@
import { fetchJson } from '../../../auth/api-client';
import type {
H2BiDailyResponse,
H2BiDailyTreeQuery,
H2BiDailyTreeResponse,
H2BiDrillReadOptions,
H2BiFullDrillOptions,
H2BiFullDrillResponse,
H2BiDrillQuery,
H2BiDrillResponse,
H2BiMetaResponse,
H2BiOverviewResponse,
H2BiQuery,
} from './types';
const BASE = '/api/energy/h2/v2';
function queryString(query: Record<string, unknown>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== '') params.set(key, String(value));
}
return params.toString();
}
function request<T>(path: string, query: object = {}, options?: RequestInit) {
const qs = queryString(query as Record<string, unknown>);
return fetchJson<T>(`${BASE}/${path}${qs ? `?${qs}` : ''}`, options);
}
export function fetchH2BiMeta() {
return request<H2BiMetaResponse>('meta');
}
export function fetchH2BiOverview(query: H2BiQuery) {
return request<H2BiOverviewResponse>('overview', query);
}
export function fetchH2BiDaily(query: H2BiQuery) {
return request<H2BiDailyResponse>('daily', query).catch(async () => {
// The date-group drill is backed by the same read-only ledger and has a
// simpler query plan. Keep the date view usable when the aggregate daily
// endpoint times out/fails, without substituting mock data.
const drill = await request<H2BiDrillResponse>('drill', {
...query,
groupBy: 'date',
pageSize: 400,
});
const startDate = query.startDate ?? `${query.year}-01-01`;
const endDate = query.endDate ?? new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
const byDate = new Map(drill.groups.map((row) => [row.name, row]));
const days = [] as H2BiDailyResponse['trend'];
for (let cursor = new Date(`${startDate}T00:00:00Z`); cursor <= new Date(`${endDate}T00:00:00Z`); cursor.setUTCDate(cursor.getUTCDate() + 1)) {
const date = cursor.toISOString().slice(0, 10);
const row = byDate.get(date);
days.push({
date,
kg: Number(row?.kg ?? 0),
lingniuKg: Number(row?.lingniuKg ?? 0),
externalKg: Number(row?.externalKg ?? 0),
cost: Number(row?.cost ?? 0),
recordCount: Number(row?.recordCount ?? 0),
});
}
const totalKg = days.reduce((sum, row) => sum + row.kg, 0);
const totalCost = days.reduce((sum, row) => sum + row.cost, 0);
return {
range: { startDate, endDate },
watermark: { ledgerAt: null, paymentAt: null },
filters: query,
kpis: {
totalKg,
totalCost,
averageDailyKg: totalKg / Math.max(1, days.length),
stationCount: Number(drill.summary.stationCount ?? 0),
activeDays: days.filter((row) => row.kg > 0).length,
},
trend: days,
days: [...days].reverse(),
};
});
}
export function fetchH2BiDailyTree(date: string, query: H2BiDailyTreeQuery) {
return request<H2BiDailyTreeResponse>('daily-tree', { date, ...query });
}
export async function fetchH2BiDrill(
query: H2BiDrillQuery,
options?: H2BiDrillReadOptions,
) {
const page = query.page ?? 1;
const pageSize = Math.min(Math.max(1, query.pageSize ?? 100), 200);
const response = await request<H2BiDrillResponse>(
'drill',
{ ...query, page, pageSize },
options,
);
const itemCount = query.groupBy === 'record'
? response.records.length
: response.groups.length;
// Older servers only set hasMore for record pages. A full page is therefore
// also treated as potentially incomplete; the final empty/short page proves
// completion without silently dropping grouped rows.
return {
...response,
page: {
...response.page,
page,
pageSize,
itemCount,
hasMore: Boolean(response.page?.hasMore) || itemCount === pageSize,
},
} satisfies H2BiDrillResponse;
}
function abortError() {
const error = new Error('已取消全量读取');
error.name = 'AbortError';
return error;
}
/**
* Reads every page only after a caller explicitly asks for a complete result.
* It never returns a partial collection: server errors, cancellation, and the
* page guard all reject before an export can be created.
*/
export async function fetchAllH2BiDrill(
query: Omit<H2BiDrillQuery, 'page' | 'pageSize'>,
options: H2BiFullDrillOptions = {},
): Promise<H2BiFullDrillResponse> {
const pageSize = Math.min(Math.max(1, options.pageSize ?? 200), 200);
const maxPages = Math.max(1, options.maxPages ?? 250);
const maxRows = Math.max(1, options.maxRows ?? 50_000);
const groups: H2BiDrillResponse['groups'] = [];
const records: H2BiDrillResponse['records'] = [];
let first: H2BiDrillResponse | null = null;
for (let page = 1; page <= maxPages; page += 1) {
if (options.signal?.aborted) throw abortError();
const result = await fetchH2BiDrill(
{ ...query, page, pageSize },
{ signal: options.signal },
);
if (!first) first = result;
groups.push(...result.groups);
records.push(...result.records);
const itemCount = query.groupBy === 'record' ? records.length : groups.length;
if (itemCount > maxRows)
throw new Error(`全量读取超过 ${maxRows} 条保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
if (!result.page.hasMore) {
return {
...first,
groups,
records,
page: {
...result.page,
page: 1,
pageSize,
itemCount,
hasMore: false,
pagesRead: page,
complete: true,
},
};
}
}
throw new Error(`全量读取超过 ${maxPages} 页保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
}
/** Reusable contract for station-detail consumers that need every raw record. */
export function fetchAllH2BiDrillRecords(
query: Omit<H2BiDrillQuery, 'groupBy' | 'page' | 'pageSize'>,
options?: H2BiFullDrillOptions,
) {
return fetchAllH2BiDrill({ ...query, groupBy: 'record' }, options);
}
@@ -1,41 +0,0 @@
# 移动端经营总览布局决策
## 问题
- 顶部筛选区域占用首屏过多。
- 累计加氢量与累计加氢费使用两个大卡片并排,数字和承担结构拥挤。
- 利润卡片与本月、本日卡片高度不一致,形成大面积无效留白。
- 移动端需要先回答经营结果,再提供费用结构与近期指标。
## 用户选择
- 仅优化移动端布局,保留现有数据、筛选和下钻交互。
- 使用克制、专业、低饱和的视觉方向。
- 以管理层快速查看经营结果、费用结构和近期表现为核心任务。
## 最终设计决策
1. 新增单个移动端「经营总览」容器,集中展示累计加氢量、累计加氢费及我司承担、客户承担、待核准三行对照数据。
2. 桌面端继续使用原有 KPI 栅格,移动端隐藏原累计量费双卡,避免重复信息。
3. 加氢利润改为全宽紧凑卡,本月加氢量与今日加氢量并排呈现。
4. 压缩移动端页头、筛选容器和范围提示的垂直空间,不改变筛选状态与即时生效逻辑。
5. 保持 44px 最小触控热区、等宽数字及 375px/390px 视口无横向溢出。
## 参考稿细化确认
用户追加确认以参考截图优化移动端首屏:
1. 顶部只保留年份、视图、车辆范围和筛选四项快捷入口,订单范围收进展开筛选。
2. 累计经营概览增加我司、客户、待核准三段加氢量构成条,并展示吨数与占比。
3. 增加「查看构成」入口,继续复用累计加氢量明细。
4. 利润卡改为左侧利润、右侧收入与成本的横向结构。
5. 本月与今日指标使用等宽双卡,经营诊断延后至趋势内容之后。
## 单站页与累计明细补充确认
1. 单站页把站点数、统计加氢总量、车次、统计金额和现结金额收进一张经营概览,不再把桌面端四卡压成手机两列。
2. 日期范围与更新时间保留在同一块紧凑查询区;各站概况改为纵向卡片,竖屏不展示无必要的横屏入口。
3. 累计明细顶部改为双主指标:数据归集总量、数据总金额;覆盖站点数和来源完整度降为一行辅助信息。
4. 累计明细筛选默认收起为范围摘要,点击后展开完整筛选;竖屏只保留左上返回,不再重复提供关闭和横屏入口。
5. 层级数据优先保证站点、客户、车辆与订单摘要在首列可读;详细字段继续在表格内部横向查看,不允许撑宽整页。
6. 移动端竖屏明细页头保留左侧返回;站点与客户宽表明细同时保留右侧横屏图标,但不显示重复关闭按钮。业务标题按内容增高并换行,任何入口不得覆盖站点名、客户名或统计时间。
@@ -1,151 +0,0 @@
# 能源氢费经营看板 · 产品需求说明(PRD)
> 原型路径:`src/prototypes/energy-h2-bi-board`
> 宿主嵌入:`https://bi-next.lnh2e.com/energy#hydrogen/overview`
> 宿主视觉单源:`src/resources/prd/energy-bi-host-ref/`
> 方案底稿:`src/resources/prd/energy-board-plan-20260806.md`
> 口令:`lingniu`(轻门禁 · 本会话记住)
> 拍板:嵌入总览 · 我司成本三维度 · 站月/客户汇总表 · **禁 OneOS V2** · D1 无自动解读
> **2026-08-12**:顶栏白卡右上角 **全局 / 单站**;单站嵌加氢站日报(起止查询日期自管);单站模式**不显示**看板标题旁时间芯片;现结登记仍独立 OneOS 模块
> **2026-08-13**:全局视图**去掉**「加氢站日报 / 站日现结登记」关联入口卡;站日报仅经顶栏「单站」;现结登记走独立模块,本页不再挂跳转摘要
> 二期:双视角全路径 · 充耗存侧卡 · **预充值能源账户扣款(体系 B)**
> 作者:OneOS
---
## 0. 嵌入与设计约定
| 项 | 口径 |
|---|---|
| 口令 | `lingniu`(轻门禁;本会话 `sessionStorage` 记住) |
| 宿主页 | `#hydrogen/overview` |
| 功能 | 独立嵌入块「我司成本」:三维度 + 两张汇总表 + 订单明细;**单站**嵌加氢站日报 |
| 设计 | 只跟 bi-next 能源 BI;禁止 `V2*`;字体对齐 V2 §2.2 |
### 视图决策
| 决策 | 结论 | 理由 |
|---|---|---|
| 顶栏范围 | **全局 / 单站**(白卡右上角) | 单站回嵌经营看板 |
| 全局内视图 | `按日` / `总览` | 原看板能力保留 |
| 总览筛选 | 默认**全部车辆**;可切**仅羚牛车辆** / **仅外部车辆**;与年份、全量订单/仅已核对联动 | 外层筛选与 KPI / 图表 / 钻取同一口径 |
| 承担方式 | **我司承担 / 客户承担 / 待核准**三类;「自行」并入「客户承担」 | 首页拆分、穿透筛选、明细列和导出口径一致 |
| 核心 KPI | PC 展示全部 5 项;移动端前 2 项突出,其余 3 项紧凑保留且均可点击穿透 | 不隐藏旧版指标能力 |
| 单站形态 | 加氢站日报:起止查询日期 + KPI 钻取 + 各站单卡(行内加氢量占比 · 按量降序) | 一眼可查 |
| 单站时间 | **不展示**看板标题旁「统计时间范围」芯片 | 与全局统计维度不同 |
| 单站明细 | 日汇总、区间趋势、客户月量/费、收支、现结;导出取证 `.xlsx` | 查看与取证 |
| 现结登记 | **独立** `energy-spot-cash-intake`;本页不办理 | 1C 手维入口唯一 |
| 预充值能源账户 | **二期 · 体系 B** | 与现结完全独立 |
### 关联独立模块
| 模块 | 路径 | 本看板角色 |
|---|---|---|
| 加氢站日报 | `energy-h2-station-daily` | 顶栏「单站」嵌入;可独立外链;**不**再挂全局关联卡 |
| 站日现结登记 | `energy-spot-cash-intake` | 独立模块办理;本页**不**挂跳转入口与近窗摘要 |
| 加氢客户(外部) | `energy-h2-external-customer` | 外部客户主数据;与租赁客户隔离 |
| 外部车辆明细 | `energy-h2-external-vehicle` | 外部车牌主数据 + 导入;API 分流「仅外部」数据源 |
> **2026-08-13 分流口径:** 南海站 API 流水命中外部车辆台账 → `fleetCategory=external`,**只进本看板/站日报**,不进车辆氢费明细;命中自有/租赁车队 → 进氢费明细并可核对。主数据种子车牌与 `mockDaily` 外部牌对齐(见 `src/common/energy-h2-external-fleet`)。
---
## 0.1 现结 ≠ 预充值(双体系硬隔离)
| | **体系 A · 现结** | **体系 B · 预充值(以后)** |
|---|---|---|
| 一句话 | 到站加完氢 → 当场在线支付 | 预充进账户 → 加氢扣款 |
| 本期 | 独立模块手维;看板/站日报只读 | **不做** |
| 禁止 | 「账户充值」指现结;订单金额冒充现结 | 用现结台账冒充账户流水 |
---
## 1. 模块定位
| 项 | 说明 |
|---|---|
| 名称 | 能源氢费经营看板(嵌入:我司成本) |
| 核心任务 | 全局:三维度结构 → 站月/客户汇总 → 订单解释;单站:站日经营读数与取证 |
| 不做 | 现结登记表单;体系 B |
---
## 2. 我司成本三维度(仅全局)
| 维度 | 二级 |
|---|---|
| 租赁成本 | 我司承担 · 包氢项目 |
| 物流成本 | — |
| 运维成本 | 异动 · 调拨 |
客户承担不进三维度。核对 ≠ 对账。
---
## 3. 用户故事
### 经营看板 · 全局
- **起点:** 打开看板(口令后)→ 顶栏「全局」→ 按日 / 总览。
- **怎么运作:** 看我司成本与汇总;站日报经顶栏「单站」进入。
- **闭环:** 本页解释成本结构。
### 经营看板 · 单站
- **起点:** 顶栏切「单站」→ 驾驶舱各站卡片。
- **怎么运作:** 点站点进入汇报口径明细(日汇总/趋势/客户月量费/收支/车辆/现结);可导出取证 Excel。
- **闭环:** 数字可查、明细可追到车与现结笔;登记仍去现结模块。
---
## 4. KPI 穿透表 · 类型标签口径
穿透钻取表(加氢站 → 客户 → 车辆):
表头支持「**按加氢站**」与「**按客户**」两种组织方式;切换只改变层级顺序,均可下钻到车辆和单笔订单。
| 层级 | 类型 / 归属列 |
|---|---|
| 加氢站 | **不展示**自用消费 / 对外销售(站侧不再分自用与对外) |
| 客户 | **不展示**内部客户 / 外部客户标签 |
| 车辆 | 无法识别车牌 → 名称统一「无车牌」(与有牌车辆同级),标签「外部车辆」;能识别且为羚牛车队 → 标签「羚牛车辆」;能识别的外部车 → 标签「外部车辆」 |
| 车辆下订单行 | 展示标签「订单编号」+ 单号;字段口径为**加氢订单编号**(导出列亦用「订单编号」) |
| 加氢站核对状态 | 按下属羚牛车辆汇总:**已核对**(全部已核)/ **部分核对**(有已核也有未核,或任一带部分)/ **未核对**(一条未核);无参与核对车辆时显示 `-` |
| 穿透筛 | **加氢站 / 客户 / 车辆**可搜索选择器(级联)+ **车辆归属**单选按钮组(全部 / 仅羚牛 / 仅外部)+ **承担方式**(全部 / 我司承担 / 客户承担 / 待核准);禁 V2 |
| 加氢利润穿透 | 关键列展示**收入 / 成本 / 利润**;顶栏汇总亦为收入·成本·利润 |
| 本月加氢穿透 | 关键列展示**本月加氢量 / 本月加氢费 / 加氢费占年比** |
| 本日加氢穿透 | 关键列展示**本日加氢量 / 本日加氢费 / 加氢费占月比** |
| 月度柱钻取 | 点月度加氢量柱 → 该月**各站**内部客户加氢总量 / 外部客户加氢总量 / 合计 |
| 月度收支柱钻取 | 点**客户收入**柱 → 该月各站客户收入;点**成本支出**柱 → 该月各站成本支出 |
| Top5 站横条 | 点条 → 该站**内部/外部客户加氢总量**(表可竖滚) |
| 区域环图/图例 | 点市或省 → 该区域**各站加氢总量与占比**(表可竖滚) |
| 站汇总表钻取 | 默认按日展示:**日期 / 车辆数与加氢笔数 / 加氢量 / 较前日增减 / 氢费收入 / 平均单价**;展开后展示客户、车牌、车辆归属、加氢时间和单笔明细 |
| 客户汇总表钻取 | 关键字段:**承担方 / 加氢量 / 成本支出 / 应收 / 已收 / 未收**(按日展开) |
| 头部加氢站占比 | 点击展开**加氢量排名**下拉(高→低、可滚动);点站可进穿透 |
| 按日经营指标 | 默认展示**区间加氢量 / 日均加氢量 / 较上一周期 / 活跃加氢站**;活跃站点按“当前有记录站点 / 全网络站点”展示并给出覆盖率;车辆构成降级为筛选区间说明,不占主指标卡 |
| 全局时间范围 | 每个页面、列表、全屏明细与钻取弹窗必须展示明确的开始与结束时间;**经营汇总到天、当日实时到分钟、订单与加氢流水到秒**;“本日 / 本月 / 年度”等业务名称不可替代具体时间范围 |
| 穿透标签提示 | 车辆归属 / 数据来源 / 核对状态标签均有悬浮说明 |
---
## 5. 验收
1. 顶栏白卡右上角有 **全局 / 单站**;全局下仍有按日/总览。
2. 单站:站点卡片 → 明细含汇报 Excel 对应区块;导出 `.xlsx`。
3. 空态不填 0;现结 ≠ 预充值。
4. 口令 `lingniu`;标注壳在门外。
5. 禁 OneOS V2 控件。
6. KPI 穿透:站/客户无自用·内外标签;无车牌归「无车牌」+「外部车辆」。
7. 总览顶栏:默认全部车辆;切仅羚牛/仅外部/年份/核对后,KPI、图表、钻取数同步变化。
8. 承担方式仅有我司承担、客户承担、待核准三类;不再单列「自行」,且首页、筛选、明细、导出一致。
9. KPI 穿透可按加氢站或客户组织,两种方式都可追到车辆和单笔订单。
10. PC 可见 5 项核心 KPI;移动端 5 项均可见、可点击,前 2 项保持主视觉层级。
---
## 6. 明确不做(本期)
- 体系 B 预充值账户
- 现结在本页办理
- 云效建单(默认不上)
@@ -1,30 +0,0 @@
# 氢能经营看板开发交付说明
## 页面入口
- 本地地址:`http://127.0.0.1:51720/prototypes/energy-h2-bi-board`
- 访问口令:`lingniu`
- 启动方式:在项目目录执行 `npm ci`,再执行 `npm run dev`
## 本次交付范围
- PC 与移动端全局经营总览、日期视图、单站视角。
- 年份、日期区间、车辆归属、订单范围、站点、客户等筛选交互。
- KPI 下钻、表格逐级展开、Tab 切换、更多数据、横屏查看与退出。
- 单站经营明细(日加氢、客户月度、收支、进账)与日期查询。
- Excel 导出入口及前端下载逻辑。
## 数据与接口边界
- 当前为可运行原型,经营数据来自 `data/mockBoard.ts`、`data/mockDaily.ts` 和单站模块的 `data/mockStationDaily.ts`。
- 正式开发需将模拟数据替换为接口返回值,但应保留现有筛选、空状态、下钻层级、双端响应式布局与横屏交互。
- 查询条件为即时生效;日期弹层点击“确定”后更新页面统计范围。
## 验证命令
```bash
npx vitest run tests/energy-h2-bi-board.ui.test.ts tests/energy-h2-bearer-model.test.ts tests/energy-h2-dual-end-interaction.test.ts tests/energy-h2-mobile-kpi-tone.test.ts tests/energy-h2-mobile-layout-polish.test.ts tests/energy-bi-board.visual.test.ts tests/common/EnergyBiBoardMobileLayout.test.ts tests/common/MobileListFullscreenButton.test.tsx
ENTRY_KEY=prototypes/energy-h2-bi-board npx vite build
```
交付前结果:8 个测试文件、97 项检查全部通过,目标页面生产构建通过。
File diff suppressed because it is too large Load Diff
@@ -1,287 +0,0 @@
import type {
CostDim,
FleetScope,
H2OrderRow,
LeaseKind,
OpsKind,
} from '../types';
import { BORNE_BY_LABEL, BORNE_BY_ORDER, COST_DIM_LABEL, LEASE_KIND_LABEL, OPS_KIND_LABEL } from '../types';
export function filterOrders(
rows: H2OrderRow[],
year: number,
verifyScope: 'all' | 'verified',
fleetScope: FleetScope,
): H2OrderRow[] {
return rows.filter((r) => {
if (!r.occurredAt.startsWith(String(year))) return false;
if (verifyScope === 'verified' && r.verifyStatus !== 'verified') return false;
if (fleetScope === 'own' && r.fleet !== 'own') return false;
if (fleetScope === 'external' && r.fleet !== 'external') return false;
return true;
});
}
export function sumAmount(rows: H2OrderRow[]): number {
return rows.reduce((s, r) => s + r.amount, 0);
}
export function sumKg(rows: H2OrderRow[]): number {
return rows.reduce((s, r) => s + r.quantityKg, 0);
}
export function companyCostRows(rows: H2OrderRow[]): H2OrderRow[] {
return rows.filter((r) => r.borneBy === 'company');
}
export function dimAmount(rows: H2OrderRow[], dim: CostDim): number {
return sumAmount(companyCostRows(rows).filter((r) => r.costDim === dim));
}
export function leaseSubAmount(rows: H2OrderRow[], kind: LeaseKind): number {
return sumAmount(
companyCostRows(rows).filter((r) => r.costDim === 'lease' && r.leaseKind === kind),
);
}
export function opsSubAmount(rows: H2OrderRow[], kind: OpsKind): number {
return sumAmount(
companyCostRows(rows).filter((r) => r.costDim === 'ops' && r.opsKind === kind),
);
}
export interface DimCard {
key: CostDim;
label: string;
amount: number;
subs: { key: string; label: string; amount: number }[];
}
export function costDimCards(rows: H2OrderRow[]): DimCard[] {
return [
{
key: 'lease',
label: COST_DIM_LABEL.lease,
amount: dimAmount(rows, 'lease'),
subs: [
{ key: 'company_borne', label: LEASE_KIND_LABEL.company_borne, amount: leaseSubAmount(rows, 'company_borne') },
{ key: 'package_h2', label: LEASE_KIND_LABEL.package_h2, amount: leaseSubAmount(rows, 'package_h2') },
],
},
{
key: 'logistics',
label: COST_DIM_LABEL.logistics,
amount: dimAmount(rows, 'logistics'),
subs: [],
},
{
key: 'ops',
label: COST_DIM_LABEL.ops,
amount: dimAmount(rows, 'ops'),
subs: [
{ key: 'abnormal', label: OPS_KIND_LABEL.abnormal, amount: opsSubAmount(rows, 'abnormal') },
{ key: 'transfer', label: OPS_KIND_LABEL.transfer, amount: opsSubAmount(rows, 'transfer') },
],
},
];
}
export function pendingAmount(rows: H2OrderRow[]): number {
return dimAmount(rows, 'pending');
}
export function unverified(rows: H2OrderRow[]): { amount: number; count: number } {
const list = rows.filter((r) => r.verifyStatus === 'unverified');
return { amount: sumAmount(list), count: list.length };
}
export function formatYuan(n: number): string {
return `¥${n.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`;
}
export function formatKg(n: number): string {
return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} kg`;
}
export function costDimLabel(row: H2OrderRow): string {
if (row.costDim === 'lease' && row.leaseKind) {
return `${COST_DIM_LABEL.lease} · ${LEASE_KIND_LABEL[row.leaseKind]}`;
}
if (row.costDim === 'ops' && row.opsKind) {
return `${COST_DIM_LABEL.ops} · ${OPS_KIND_LABEL[row.opsKind]}`;
}
return COST_DIM_LABEL[row.costDim];
}
export type DimFilter =
| { dim: CostDim; sub?: string }
| null;
export function applyDimFilter(rows: H2OrderRow[], filter: DimFilter): H2OrderRow[] {
if (!filter) return rows;
return rows.filter((r) => {
if (r.borneBy !== 'company') return false;
if (r.costDim !== filter.dim) return false;
if (!filter.sub) return true;
if (filter.dim === 'lease') return r.leaseKind === filter.sub;
if (filter.dim === 'ops') return r.opsKind === filter.sub;
return true;
});
}
/** 统计/明细共用:维度筛后的我司成本行;无维度筛则全部我司行 */
export function companyRowsForStats(rows: H2OrderRow[], filter: DimFilter): H2OrderRow[] {
if (!filter) return companyCostRows(rows);
return applyDimFilter(rows, filter);
}
export interface StationMonthRow {
stationId: string;
stationName: string;
month: string;
amount: number;
quantityKg: number;
unverifiedAmount: number;
}
export function stationMonthAgg(rows: H2OrderRow[]): StationMonthRow[] {
const map = new Map<string, H2OrderRow[]>();
rows.forEach((r) => {
const month = r.occurredAt.slice(0, 7);
const key = `${r.stationId}|${month}`;
const list = map.get(key) ?? [];
list.push(r);
map.set(key, list);
});
return Array.from(map.entries())
.map(([key, list]) => {
const [stationId, month] = key.split('|');
return {
stationId,
stationName: list[0].stationName,
month,
amount: sumAmount(list),
quantityKg: sumKg(list),
unverifiedAmount: sumAmount(list.filter((x) => x.verifyStatus === 'unverified')),
};
})
.sort((a, b) => b.amount - a.amount);
}
export interface CustomerAttrRow {
customerId: string;
customerName: string;
borneLabel: string;
quantityKg: number;
companyCost: number;
unverifiedAmount: number;
}
export function customerAttrAgg(rows: H2OrderRow[]): CustomerAttrRow[] {
const map = new Map<string, H2OrderRow[]>();
rows.forEach((r) => {
const list = map.get(r.customerId) ?? [];
list.push(r);
map.set(r.customerId, list);
});
return Array.from(map.entries())
.map(([customerId, list]) => {
const company = list.filter((x) => x.borneBy === 'company');
const activeBorneTypes = BORNE_BY_ORDER.filter((borneBy) => list.some((x) => x.borneBy === borneBy));
const borneLabel = activeBorneTypes.length === 1 ? BORNE_BY_LABEL[activeBorneTypes[0]] : '混合';
return {
customerId,
customerName: list[0].customerName,
borneLabel,
quantityKg: sumKg(list),
companyCost: sumAmount(company),
unverifiedAmount: sumAmount(list.filter((x) => x.verifyStatus === 'unverified')),
};
})
.sort((a, b) => b.companyCost - a.companyCost || b.quantityKg - a.quantityKg);
}
export const SOURCE_LABEL: Record<H2OrderRow['source'], string> = {
api: 'API',
manual: '补录',
fence: '围栏',
};
/** 总览 KPI:在宿主示意量级上按当前筛选(年/车辆/核对)等比缩放,保证卡片跟随顶栏筛选 */
export function computeHostKpi(
filtered: H2OrderRow[],
year: number,
allOrders: H2OrderRow[],
base: {
totalKgT: number;
companyKgT: number;
customerKgT: number;
pendingKgT: number;
totalFeeWan: number;
companyFeeWan: number;
customerFeeWan: number;
pendingFeeWan: number;
profitWan: number;
incomeWan: number;
costWan: number;
monthKgT: number;
monthFeeWan: number;
monthYearPct: number;
dayKg: number;
dayFee: number;
dayMonthPct: number;
},
) {
const round2 = (n: number) => Math.round(n * 100) / 100;
const baseline = filterOrders(allOrders, year, 'all', 'all');
const baseKg = sumKg(baseline) || 1;
const fKg = sumKg(filtered);
const ratio = fKg / baseKg;
const companyKg = sumKg(filtered.filter((r) => r.borneBy === 'company'));
const customerKg = sumKg(filtered.filter((r) => r.borneBy === 'customer'));
const pendingKg = sumKg(filtered.filter((r) => r.borneBy === 'pending'));
const split = companyKg + customerKg + pendingKg || 1;
const companyShare = companyKg / split;
const customerShare = customerKg / split;
const pendingShare = pendingKg / split;
const monthRows = filtered.filter((r) => r.occurredAt.startsWith(`${year}-08`));
const dayRows = filtered.filter((r) => r.occurredAt.startsWith(`${year}-08-08`));
const monthKg = sumKg(monthRows);
const dayKgVal = sumKg(dayRows);
const monthAmt = sumAmount(monthRows);
const dayAmt = sumAmount(dayRows);
const yearKg = fKg || 1;
const monthKgShare = monthKg / yearKg;
const dayMonthShare = monthKg > 0 ? dayKgVal / monthKg : 0;
const totalKgT = round2(base.totalKgT * ratio);
const totalFeeWan = round2(base.totalFeeWan * ratio);
const incomeWan = round2(base.incomeWan * ratio);
const costWan = round2(base.costWan * ratio);
const profitWan = round2(base.profitWan * ratio);
const monthKgT = round2(totalKgT * monthKgShare);
const monthFeeWan = round2(totalFeeWan * monthKgShare);
return {
totalKgT,
companyKgT: round2(totalKgT * companyShare),
customerKgT: round2(totalKgT * customerShare),
pendingKgT: round2(totalKgT * pendingShare),
totalFeeWan,
companyFeeWan: round2(totalFeeWan * companyShare),
customerFeeWan: round2(totalFeeWan * customerShare),
pendingFeeWan: round2(totalFeeWan * pendingShare),
profitWan,
incomeWan,
costWan,
monthKgT,
monthFeeWan,
monthYearPct: round2(monthKgShare * 100),
dayKg: round2(dayKgVal > 0 ? dayKgVal : base.dayKg * ratio * Math.max(dayMonthShare, 0.01)),
dayFee: Math.round(dayAmt > 0 ? dayAmt : base.dayFee * ratio * Math.max(dayMonthShare, 0.01)),
dayMonthPct: round2((dayMonthShare || base.dayMonthPct / 100) * 100),
profitRatePct: incomeWan > 0 ? round2((profitWan / incomeWan) * 100) : 0,
};
}
@@ -1,191 +0,0 @@
import type { H2OrderRow, StationPrepaid } from '../types';
/** 辅助生成 200 条逼真高质量订单明细假数据 */
function generate200MockOrders(): H2OrderRow[] {
const stations = [
{ id: 'st-ln', name: '佛山南海羚牛加氢站' },
{ id: 'st-dp', name: '东鹏大道甲醇制氢一体站' },
{ id: 'st-jx', name: '嘉兴中石化滨海加氢站' },
{ id: 'st-jj', name: '嘉兴嘉锦加氢站' },
{ id: 'st-cd', name: '成都中石化天府机场高速北站加氢站' },
{ id: 'st-gz', name: '广州黄埔高新区氢能示范加氢站' },
{ id: 'st-sh', name: '上海安亭加氢站' },
];
const ownPlates = [
'粤A99887', '粤B12001', '浙A52088', '浙F77881', '浙A88888F',
'浙A66666', '浙F11223', '浙F33445', '川A77889', '川B99001',
'沪A33219', '粤B88102', '浙F99812', '浙F66521', '粤A11029',
];
const extPlates = [
'粤A77661', '浙F33211', '川A55432', '沪B98765', '粤B66554',
'浙A22334', '粤A99102', '川B88761', '无车牌(散车)',
];
const customers = [
{ id: 'c-ln', name: '羚牛自营', type: 'internal', deptId: 'd-ops', deptName: '运维中心' },
{ id: 'c-bao', name: '包氢专线项目', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' },
{ id: 'c-lease-a', name: '嘉兴智奇供应链', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' },
{ id: 'c-log', name: '嘉兴益顺冷链', type: 'internal', deptId: 'd-log', deptName: '物流中心' },
{ id: 'c-log2', name: '四川群彬物流', type: 'internal', deptId: 'd-log', deptName: '物流中心' },
{ id: 'c-lease-b', name: '无锡铭康物流', type: 'internal', deptId: 'd-lease', deptName: '租赁业务二部' },
{ id: 'c-ops', name: '运维调拨车辆', type: 'internal', deptId: 'd-ops', deptName: '运维中心' },
{ id: 'c-pend', name: '待归属样本', type: 'internal', deptId: 'd-lease', deptName: '租赁业务一部' },
{ id: 'c-ext-a', name: '广东氢动力', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
{ id: 'c-ext-b', name: '广东清运专线', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
{ id: 'c-ext-c', name: '东展供应链', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
{ id: 'c-ext-d', name: '顺丰冷运专线', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
{ id: 'c-ext-e', name: '极兔速递冷链', type: 'external', deptId: 'd-sales', deptName: '能源销售' },
];
const list: H2OrderRow[] = [];
// 生成 200 条记录
for (let i = 1; i <= 200; i++) {
const padId = String(i).padStart(3, '0');
// 年份分配: 1~145 (2026年), 146~185 (2025年), 186~200 (2024年)
let year = 2026;
let month = Math.floor((i % 8)) + 1; // 1~8月
if (i > 145 && i <= 185) {
year = 2025;
month = Math.floor((i % 12)) + 1;
} else if (i > 185) {
year = 2024;
month = Math.floor((i % 12)) + 1;
}
const day = (i * 7 % 28) + 1;
const hour = (i * 3 % 14) + 7;
const minute = (i * 11 % 50) + 5;
const mm = month < 10 ? `0${month}` : `${month}`;
const dd = day < 10 ? `0${day}` : `${day}`;
const hh = hour < 10 ? `0${hour}` : `${hour}`;
const min = minute < 10 ? `0${minute}` : `${minute}`;
const occurredAt = `${year}-${mm}-${dd} ${hh}:${min}`;
const station = stations[i % stations.length];
const customer = customers[i % customers.length];
const isOwn = customer.type === 'internal';
const fleet = isOwn ? 'own' : 'external';
const plateNo = isOwn
? ownPlates[i % ownPlates.length]
: extPlates[i % extPlates.length];
// 单价 & 加氢量
const unitPrice = [28, 30, 32, 35][i % 4];
const quantityKg = Math.round((18 + (i * 3.7 % 85)) * 100) / 100;
const amount = Math.round(quantityKg * unitPrice);
// 成本维度与费用承担(三类:自行结算并入客户承担)
let borneBy: 'company' | 'customer' | 'pending' = 'company';
let costDim: 'lease' | 'logistics' | 'ops' | 'pending' = 'lease';
let leaseKind: 'company_borne' | 'package_h2' | undefined = undefined;
let opsKind: 'abnormal' | 'transfer' | undefined = undefined;
const borneSlot = i % 10;
if (customer.id === 'c-ext-a' || customer.id === 'c-ext-d' || borneSlot === 7 || borneSlot === 8) {
borneBy = 'customer';
} else if (borneSlot === 9) {
borneBy = 'pending';
}
const dimType = i % 5;
if (borneBy !== 'company') {
costDim = 'pending';
leaseKind = undefined;
opsKind = undefined;
} else if (dimType === 0) {
costDim = 'lease';
leaseKind = 'company_borne';
} else if (dimType === 1) {
costDim = 'lease';
leaseKind = 'package_h2';
} else if (dimType === 2) {
costDim = 'logistics';
} else if (dimType === 3) {
costDim = 'ops';
opsKind = i % 2 === 0 ? 'abnormal' : 'transfer';
} else {
costDim = 'pending';
}
// 核对状态与数据来源
const verifyStatus = isOwn ? (i % 4 === 0 ? 'unverified' : 'verified') : 'unverified';
const source = isOwn
? (i % 3 === 0 ? 'manual' : i % 3 === 1 ? 'fence' : 'api')
: (i % 2 === 0 ? 'api' : 'manual');
list.push({
id: `HO-${String(year).slice(2)}${mm}-${padId}`,
occurredAt,
stationId: station.id,
stationName: station.name,
plateNo,
customerId: customer.id,
customerName: customer.name,
deptId: customer.deptId,
deptName: customer.deptName,
amount,
quantityKg,
unitPrice,
borneBy,
costDim,
leaseKind,
opsKind,
verifyStatus,
source,
fleet,
});
}
return list;
}
/** 假数:200 条订单明细(三维度成本拆分) */
export const MOCK_ORDERS: H2OrderRow[] = generate200MockOrders();
export const MOCK_PREPAID: StationPrepaid[] = [
{
stationId: 'st-jx',
stationName: '嘉兴中石化滨海加氢站',
openingBalance: 120000,
openingAnchorLabel: '2025 年末财务期末',
recharge: 80000,
consume: 95000,
},
{
stationId: 'st-jj',
stationName: '嘉兴嘉锦加氢站',
openingBalance: null,
openingAnchorLabel: null,
recharge: 40000,
consume: 28000,
},
];
/** 宿主总览 KPI(三类承担口径,自行结算已并入客户承担) */
export const HOST_KPI = {
totalKgT: 697.16,
companyKgT: 598.01,
customerKgT: 80,
pendingKgT: 19.15,
totalFeeWan: 2093.71,
companyFeeWan: 1795.95,
customerFeeWan: 240,
pendingFeeWan: 57.76,
profitWan: 13.01,
incomeWan: 2106.72,
costWan: 2093.71,
monthKgT: 16.67,
monthFeeWan: 50.36,
monthYearPct: 2.4,
dayKg: 181.78,
dayFee: 6533,
dayMonthPct: 1.1,
};
export const DEFAULT_YEAR = 2026;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,73 +0,0 @@
/** 能源 BI · 氢能总览嵌入功能 · 类型(宿主 bi-next #hydrogen/overview,非 OneOS V2) */
/** 我司成本三维度(本尊 2026-08-07) */
export type CostDim = 'lease' | 'logistics' | 'ops' | 'pending';
/** 租赁成本二级 */
export type LeaseKind = 'company_borne' | 'package_h2';
/** 运维成本二级 */
export type OpsKind = 'abnormal' | 'transfer';
export type VerifyStatus = 'verified' | 'unverified';
/** 氢费承担口径:自行结算并入客户承担,未明确归属的订单进入待核准。 */
export type BorneBy = 'company' | 'customer' | 'pending';
export type FleetScope = 'own' | 'external' | 'all';
/** 按日 | 总览(站日报 / 现结登记已拆独立模块) */
export type HostView = 'daily' | 'overview';
export interface H2OrderRow {
id: string;
occurredAt: string;
stationId: string;
stationName: string;
plateNo: string;
customerId: string;
customerName: string;
deptId: string;
deptName: string;
amount: number;
quantityKg: number;
unitPrice: number;
borneBy: BorneBy;
costDim: CostDim;
leaseKind?: LeaseKind;
opsKind?: OpsKind;
verifyStatus: VerifyStatus;
source: 'api' | 'manual' | 'fence';
fleet: 'own' | 'external';
}
export interface StationPrepaid {
stationId: string;
stationName: string;
openingBalance: number | null;
openingAnchorLabel: string | null;
recharge: number;
consume: number;
}
export const COST_DIM_LABEL: Record<CostDim, string> = {
lease: '租赁成本',
logistics: '物流成本',
ops: '运维成本',
pending: '待归属',
};
export const LEASE_KIND_LABEL: Record<LeaseKind, string> = {
company_borne: '我司承担',
package_h2: '包氢项目',
};
export const OPS_KIND_LABEL: Record<OpsKind, string> = {
abnormal: '异动',
transfer: '调拨',
};
export const BORNE_BY_LABEL: Record<BorneBy, string> = {
company: '我司承担',
customer: '客户承担',
pending: '待核准',
};
export const BORNE_BY_ORDER: BorneBy[] = ['company', 'customer', 'pending'];
@@ -1,109 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { isPhoneUserAgent, phoneRotation } from './phone-viewport';
import { Maximize2 } from 'lucide-react';
import './mobile-list-fullscreen.css';
export function MobileListFullscreenButton({
label = '横屏查看',
placement = 'overlay',
}: {
label?: string;
placement?: 'overlay' | 'inline';
}) {
const [isActive, setIsActive] = useState(false);
const targetRef = useRef<HTMLElement | null>(null);
const savedStyles = useRef(new Map<string, [string, string]>());
const restoreGeometry = () => {
const target = targetRef.current;
if (!target) return;
savedStyles.current.forEach(([value, priority], key) => {
if (value) target.style.setProperty(key, value, priority); else target.style.removeProperty(key);
});
savedStyles.current.clear();
};
const updateGeometry = () => {
const target = targetRef.current;
if (!target) return;
restoreGeometry();
const width = window.visualViewport?.width ?? window.innerWidth;
const height = window.visualViewport?.height ?? window.innerHeight;
const rotate = phoneRotation(navigator.userAgent, width, height);
target.dataset.mobileFullscreenMode = rotate ? 'phone-rotated' : 'native-scroll';
if (!rotate) return;
const styles: Record<string, string> = {
position: 'fixed', top: '0px', left: '0px', right: 'auto', bottom: 'auto',
width: `${height}px`, height: `${width}px`, 'max-width': 'none', 'max-height': 'none',
margin: '0px', transform: `translateX(${width}px) rotate(90deg)`, 'transform-origin': '0 0',
overflow: 'hidden', padding: '0px',
};
Object.entries(styles).forEach(([key, value]) => {
savedStyles.current.set(key, [target.style.getPropertyValue(key), target.style.getPropertyPriority(key)]);
target.style.setProperty(key, value, 'important');
});
};
useEffect(() => {
const resize = () => updateGeometry();
const escape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && targetRef.current) { event.stopPropagation(); leaveLandscape(targetRef.current); }
};
window.addEventListener('resize', resize);
window.visualViewport?.addEventListener('resize', resize);
document.addEventListener('keydown', escape, true);
return () => {
window.removeEventListener('resize', resize);
window.visualViewport?.removeEventListener('resize', resize);
document.removeEventListener('keydown', escape, true);
if (targetRef.current) {
restoreGeometry();
targetRef.current.removeAttribute('data-mobile-fullscreen-active');
targetRef.current.removeAttribute('data-mobile-fullscreen-mode');
targetRef.current.classList.remove('is-mobile-list-fullscreen', 'is-mobile-list-fallback');
targetRef.current = null;
document.documentElement.classList.remove('ehb-landscape-session');
}
};
}, []);
const leaveLandscape = (target: HTMLElement) => {
restoreGeometry();
targetRef.current = null;
target.removeAttribute('data-mobile-fullscreen-active');
target.removeAttribute('data-mobile-fullscreen-mode');
target.classList.remove('is-mobile-list-fullscreen', 'is-mobile-list-fallback');
document.documentElement.classList.remove('ehb-landscape-session');
setIsActive(false);
};
const openFullscreen = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
const target = event.currentTarget.closest<HTMLElement>('[data-mobile-fullscreen-list]');
if (!target) return;
if (target.dataset.mobileFullscreenActive === 'true') {
leaveLandscape(target);
return;
}
target.dataset.mobileFullscreenActive = 'true';
targetRef.current = target;
target.classList.add('is-mobile-list-fullscreen');
document.documentElement.classList.add('ehb-landscape-session');
setIsActive(true);
updateGeometry();
};
return (
<button
type="button"
className={`mobile-list-fullscreen-trigger${placement === 'inline' ? ' is-inline' : ''}`}
aria-label={isActive ? '退出完整宽表' : label}
aria-pressed={isActive}
title={isActive ? '退出宽表模式' : label}
onClick={openFullscreen}
>
<span className="mobile-list-fullscreen-trigger__label">{isActive ? '退出宽表' : isPhoneUserAgent(navigator.userAgent) ? '横屏查看' : '查看完整表格'}</span>
<Maximize2 size={15} aria-hidden />
</button>
);
}
@@ -1,2 +0,0 @@
export * from './types';
export * from './store';
@@ -1,569 +0,0 @@
/**
* 体系 A · 站日现结进账 Mock + localStorage
* 仅本尊提供 Excel 实站;禁造站。
* - 南海:截止8.11 收支汇总 → 现结
* - 东鹏大道:加氢记录-20260802 汇总进账明细 → 现结
*/
import type {
CashIntakeChangeLog,
StationCashIntakeDay,
StationCashIntakeLine,
} from './types';
export const CASH_INTAKE_STORAGE_KEY = 'oneos-energy-spot-cash-intake-v4';
const LEGACY_STORAGE_KEYS = [
'oneos-energy-spot-cash-intake-v3',
'oneos-energy-spot-cash-intake-v2',
];
function seedDays(): StationCashIntakeDay[] {
return [
{
id: 'day-st-fs-nanhai-2026-08-02',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-02',
totalAmount: 2530.42,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-02-0', customerName: "东展供应链(广州)有限公司", amount: 655.12, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-02-1', customerName: "广东开鸿氢能科技有限公司", amount: 503.88, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-02-2', customerName: "现代氢能科技有限公司", amount: 760.38, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-02-3', customerName: "广东氢动力科技服务有限公司", amount: 296.78, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-02-4', customerName: "羚牛氢能科技(广东)有限公司", amount: 314.26, payMethod: 'wechat_scan' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-02 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-03',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-03',
totalAmount: 2193.36,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-03-0', customerName: "现代氢能科技有限公司", amount: 1259.7, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-03-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 634.22, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-03-2', customerName: "东展供应链(广州)有限公司", amount: 299.44, payMethod: 'wechat_scan' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-03 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-04',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-04',
totalAmount: 5216.16,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-04-0', customerName: "广东沣开科技有限公司", amount: 3000.0, payMethod: 'bank_transfer' },
{ id: 'line-st-fs-nanhai-2026-08-04-1', customerName: "现代氢能科技有限公司", amount: 1024.1, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-04-2', customerName: "羚牛氢能科技(广东)有限公司", amount: 473.86, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-04-3', customerName: "广东氢动力科技服务有限公司", amount: 368.6, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-04-4', customerName: "东展供应链(广州)有限公司", amount: 163.78, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-04-5', customerName: "外省过路车", amount: 185.82, payMethod: 'wechat_scan' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-04 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-05',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-05',
totalAmount: 3469.78,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-05-0', customerName: "现代氢能科技有限公司", amount: 1155.58, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-05-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 608.0, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-05-2', customerName: "广东氢动力科技服务有限公司", amount: 530.86, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-05-3', customerName: "广东开鸿氢能科技有限公司", amount: 315.78, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-05-4', customerName: "东展供应链(广州)有限公司", amount: 859.56, payMethod: 'wechat_scan' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-05 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-06',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-06',
totalAmount: 1006.24,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-06-0', customerName: "现代氢能科技有限公司", amount: 478.8, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-06-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 527.44, payMethod: 'wechat_scan' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-06 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-07',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-07',
totalAmount: 22094.18,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-07-0', customerName: "东展供应链(广州)有限公司", amount: 201.02, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-07-1', customerName: "现代氢能科技有限公司", amount: 1410.18, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-07-2', customerName: "羚牛氢能科技(广东)有限公司", amount: 482.98, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-07-3', customerName: "羚牛氢能科技(广东)有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-07 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-08',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-08',
totalAmount: 2845.06,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-08-0', customerName: "东展供应链(广州)有限公司", amount: 745.94, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-08-1', customerName: "现代氢能科技有限公司", amount: 1371.8, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-08-2', customerName: "羚牛氢能科技(广东)有限公司", amount: 727.32, payMethod: 'wechat_scan' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-08 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-09',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-09',
totalAmount: 2485.58,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-09-0', customerName: "现代氢能科技有限公司", amount: 775.2, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-09-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 987.62, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-09-2', customerName: "广东氢动力科技服务有限公司", amount: 250.42, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-09-3', customerName: "东展供应链(广州)有限公司", amount: 472.34, payMethod: 'wechat_scan' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-09 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-10',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-10',
totalAmount: 22823.78,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-10-0', customerName: "现代氢能科技有限公司", amount: 1582.7, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-10-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 757.34, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-10-2', customerName: "广东氢动力科技服务有限公司", amount: 186.2, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-10-3', customerName: "东展供应链(广州)有限公司", amount: 297.54, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-10-4', customerName: "广东瀚清能源有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-10 18:00',
},
{
id: 'day-st-fs-nanhai-2026-08-11',
stationId: 'st-fs-nanhai',
stationName: "佛山南海羚牛加氢站",
bizDate: '2026-08-11',
totalAmount: 2301.04,
lines: [
{ id: 'line-st-fs-nanhai-2026-08-11-0', customerName: "现代氢能科技有限公司", amount: 755.06, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-11-1', customerName: "羚牛氢能科技(广东)有限公司", amount: 596.36, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-11-2', customerName: "广东开鸿氢能科技有限公司", amount: 599.26, payMethod: 'wechat_scan' },
{ id: 'line-st-fs-nanhai-2026-08-11-3', customerName: "东展供应链(广州)有限公司", amount: 350.36, payMethod: 'wechat_scan' },
],
updatedBy: "杨凤娥",
updatedAt: '2026-08-11 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-06-26',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-06-26',
totalAmount: 10000.0,
lines: [
{ id: 'line-st-dp-dongpeng-2026-06-26-0', customerName: "广州市梅洛特物流有限公司", amount: 10000.0, payMethod: 'bank_transfer' },
],
updatedBy: "金可鹏",
updatedAt: '2026-06-26 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-01',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-01',
totalAmount: 1506.75,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-01-0', customerName: "广州新运多租赁有限公司", amount: 1506.75, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-01 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-02',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-02',
totalAmount: 596.4,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-02-0', customerName: "广州中味餐饮服务有限公司", amount: 96.25, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-02-1', customerName: "广州新运多租赁有限公司", amount: 500.15, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-02 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-03',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-03',
totalAmount: 918.4,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-03-0', customerName: "广州新运多租赁有限公司", amount: 918.4, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-03 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-04',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-04',
totalAmount: 349.3,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-04-0', customerName: "广州新运多租赁有限公司", amount: 349.3, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-04 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-07',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-07',
totalAmount: 20640.15,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-07-0', customerName: "广州市梅洛特物流有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
{ id: 'line-st-dp-dongpeng-2026-07-07-1', customerName: "广州中味餐饮服务有限公司", amount: 362.25, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-07-2', customerName: "广州新运多租赁有限公司", amount: 277.9, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-07 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-08',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-08',
totalAmount: 896.35,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-08-0', customerName: "广州新运多租赁有限公司", amount: 896.35, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-08 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-09',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-09',
totalAmount: 902.65,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-09-0', customerName: "广州中味餐饮服务有限公司", amount: 211.4, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-09-1', customerName: "广州新运多租赁有限公司", amount: 691.25, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-09 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-10',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-10',
totalAmount: 1198.05,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-10-0', customerName: "广州中味餐饮服务有限公司", amount: 314.65, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-10-1', customerName: "广州新运多租赁有限公司", amount: 883.4, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-10 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-14',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-14',
totalAmount: 266.7,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-14-0', customerName: "广州中味餐饮服务有限公司", amount: 266.7, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-14 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-15',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-15',
totalAmount: 562.8,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-15-0', customerName: "广州中味餐饮服务有限公司", amount: 262.5, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-15-1', customerName: "广州新运多租赁有限公司", amount: 300.3, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-15 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-16',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-16',
totalAmount: 497.7,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-16-0', customerName: "广州中味餐饮服务有限公司", amount: 200.9, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-16-1', customerName: "广州新运多租赁有限公司", amount: 296.8, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-16 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-17',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-17',
totalAmount: 911.4,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-17-0', customerName: "广州新运多租赁有限公司", amount: 911.4, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-17 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-18',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-18',
totalAmount: 870.45,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-18-0', customerName: "广州新运多租赁有限公司", amount: 870.45, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-18 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-19',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-19',
totalAmount: 468.65,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-19-0', customerName: "广州中味餐饮服务有限公司", amount: 181.3, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-19-1', customerName: "广州新运多租赁有限公司", amount: 287.35, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-19 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-20',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-20',
totalAmount: 708.75,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-20-0', customerName: "广州新运多租赁有限公司", amount: 480.55, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-20-1', customerName: "广州中味餐饮服务有限公司", amount: 228.2, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-20 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-21',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-21',
totalAmount: 234.85,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-21-0', customerName: "广州新运多租赁有限公司", amount: 234.85, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-21 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-25',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-25',
totalAmount: 20686.0,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-25-0', customerName: "广州新运多租赁有限公司", amount: 686.0, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-25-1', customerName: "广州福满华冷链物流有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-25 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-27',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-27',
totalAmount: 213.85,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-27-0', customerName: "广州新运多租赁有限公司", amount: 213.85, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-27 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-28',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-28',
totalAmount: 20872.2,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-28-0', customerName: "广州中味餐饮服务有限公司", amount: 480.9, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-28-1', customerName: "广州新运多租赁有限公司", amount: 391.3, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-28-2', customerName: "广州市梅洛特物流有限公司", amount: 20000.0, payMethod: 'bank_transfer' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-28 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-29',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-29',
totalAmount: 814.8,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-29-0', customerName: "广州中味餐饮服务有限公司", amount: 84.0, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-29-1', customerName: "广州新运多租赁有限公司", amount: 730.8, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-29 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-30',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-30',
totalAmount: 1450.4,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-30-0', customerName: "广州中味餐饮服务有限公司", amount: 393.05, payMethod: 'wechat_scan' },
{ id: 'line-st-dp-dongpeng-2026-07-30-1', customerName: "广州新运多租赁有限公司", amount: 1057.35, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-30 18:00',
},
{
id: 'day-st-dp-dongpeng-2026-07-31',
stationId: 'st-dp-dongpeng',
stationName: "东鹏大道甲醇制氢一体站",
bizDate: '2026-07-31',
totalAmount: 587.65,
lines: [
{ id: 'line-st-dp-dongpeng-2026-07-31-0', customerName: "广州新运多租赁有限公司", amount: 587.65, payMethod: 'wechat_scan' },
],
updatedBy: "金可鹏",
updatedAt: '2026-07-31 18:00',
},
];
}
function normalizeDay(d: StationCashIntakeDay): StationCashIntakeDay {
return {
...d,
changeLogs: Array.isArray(d.changeLogs) ? d.changeLogs : [],
};
}
export function loadCashIntakeDays(): StationCashIntakeDay[] {
try {
const raw = localStorage.getItem(CASH_INTAKE_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as StationCashIntakeDay[];
if (Array.isArray(parsed) && parsed.length) return parsed.map(normalizeDay);
}
for (const key of LEGACY_STORAGE_KEYS) {
const legacy = localStorage.getItem(key);
if (!legacy) continue;
const parsed = JSON.parse(legacy) as StationCashIntakeDay[];
if (Array.isArray(parsed) && parsed.length) {
const next = parsed.map(normalizeDay);
saveCashIntakeDays(next);
return next;
}
}
} catch {
/* ignore */
}
return seedDays().map(normalizeDay);
}
export function saveCashIntakeDays(days: StationCashIntakeDay[]): void {
try {
localStorage.setItem(CASH_INTAKE_STORAGE_KEY, JSON.stringify(days));
} catch {
/* ignore */
}
}
export function getCashIntakeForStationRange(
days: StationCashIntakeDay[],
stationId: string,
startDate: string,
endDate: string,
): StationCashIntakeDay[] {
return days
.filter((d) => d.stationId === stationId && d.bizDate >= startDate && d.bizDate <= endDate)
.slice()
.sort((a, b) => a.bizDate.localeCompare(b.bizDate));
}
export function appendChangeLog(
day: StationCashIntakeDay,
entry: Omit<CashIntakeChangeLog, 'id'> & { id?: string },
): StationCashIntakeDay {
const log = {
id: entry.id || `log-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
at: entry.at,
by: entry.by,
action: entry.action,
summary: entry.summary,
};
const prev = Array.isArray(day.changeLogs) ? day.changeLogs : [];
return { ...day, changeLogs: [log, ...prev].slice(0, 80) };
}
export function upsertCashIntakeDay(
days: StationCashIntakeDay[],
next: StationCashIntakeDay,
): StationCashIntakeDay[] {
const idx = days.findIndex((d) => d.stationId === next.stationId && d.bizDate === next.bizDate);
const copy = days.slice();
if (idx >= 0) copy[idx] = { ...next, changeLogs: next.changeLogs || copy[idx].changeLogs || [] };
else copy.push({ ...next, changeLogs: next.changeLogs || [] });
return copy.sort((a, b) => b.bizDate.localeCompare(a.bizDate));
}
export function deleteCashIntakeDay(
days: StationCashIntakeDay[],
stationId: string,
bizDate: string,
): StationCashIntakeDay[] {
return days.filter((d) => !(d.stationId === stationId && d.bizDate === bizDate));
}
export function sumLines(lines: StationCashIntakeLine[]): number {
return lines.reduce((s, l) => s + (Number(l.amount) || 0), 0);
}
export function summarizeCashIntake(
days: StationCashIntakeDay[],
startDate?: string,
endDate?: string,
): { dayCount: number; totalAmount: number; stationCount: number } {
const filtered = days.filter((d) => {
if (startDate && d.bizDate < startDate) return false;
if (endDate && d.bizDate > endDate) return false;
return true;
});
const stations = new Set(filtered.map((d) => d.stationId));
return {
dayCount: filtered.length,
totalAmount: filtered.reduce((s, d) => s + d.totalAmount, 0),
stationCount: stations.size,
};
}
@@ -1,65 +0,0 @@
/**
* 站日现结进账类型(共享)
* 禁止与预充值能源账户混用(口径见 PRD,不进 UI)。
*/
export type SpotPayMethod = 'wechat_scan' | 'bank_transfer' | 'other';
export const SPOT_PAY_METHOD_LABEL: Record<SpotPayMethod, string> = {
wechat_scan: '微信扫码',
bank_transfer: '对公转账',
other: '其他',
};
export function parsePayMethodLabel(raw: string): SpotPayMethod | null {
const t = String(raw || '').trim();
if (!t) return null;
const entry = (Object.keys(SPOT_PAY_METHOD_LABEL) as SpotPayMethod[]).find(
(k) => SPOT_PAY_METHOD_LABEL[k] === t || k === t,
);
return entry || null;
}
export interface StationCashIntakeLine {
id: string;
customerName: string;
amount: number;
payMethod: SpotPayMethod;
remark?: string;
}
export type CashIntakeChangeAction = 'create' | 'update' | 'delete' | 'import';
export interface CashIntakeChangeLog {
id: string;
at: string;
by: string;
action: CashIntakeChangeAction;
summary: string;
}
/** 唯一键 stationId + bizDate(业务字段仍用 bizDate;UI 称「登记日期」) */
export interface StationCashIntakeDay {
id: string;
stationId: string;
stationName: string;
bizDate: string; // YYYY-MM-DD · 登记日期
totalAmount: number;
remark?: string;
lines: StationCashIntakeLine[];
updatedBy: string;
updatedAt: string;
changeLogs?: CashIntakeChangeLog[];
}
export const CASH_INTAKE_STATIONS = [
{ id: 'st-fs-nanhai', name: '佛山南海羚牛加氢站' },
{ id: 'st-dp-dongpeng', name: '东鹏大道甲醇制氢一体站' },
] as const;
export const DEFAULT_CASH_STATION_ID = 'st-fs-nanhai';
/** Make / 本地预览跳转 */
export const PROTO_PATH_SPOT_CASH = '/prototypes/energy-spot-cash-intake';
export const PROTO_PATH_STATION_DAILY = '/prototypes/energy-h2-station-daily';
export const PROTO_PATH_ENERGY_BI = '/prototypes/energy-h2-bi-board';
@@ -1,385 +0,0 @@
.mobile-list-fullscreen-trigger {
display: none;
}
@media (max-width: 767px) {
[data-mobile-fullscreen-list] {
position: relative;
}
.mobile-list-fullscreen-trigger {
position: absolute;
z-index: 4;
top: 10px;
right: 10px;
display: inline-flex;
width: auto;
min-width: 124px;
height: 32px;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 10px;
border: 1px solid #dbe4f1;
border-radius: 9px;
background: #fff;
color: #fff;
background: #2563eb;
border-color: #2563eb;
box-shadow: 0 5px 14px rgb(37 99 235 / 20%);
cursor: pointer;
}
.mobile-list-fullscreen-trigger.is-inline {
position: relative;
inset: auto;
z-index: auto;
flex: 0 0 auto;
}
.mobile-list-fullscreen-trigger__label {
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.mobile-list-fullscreen-trigger::before {
position: absolute;
inset: -6px;
content: '';
}
[data-mobile-fullscreen-list] > h2,
[data-mobile-fullscreen-list] > .sd-section-head,
[data-mobile-fullscreen-list] > .sd-panel__head-row,
[data-mobile-fullscreen-list] > .ehb-sum-table-card__head,
[data-mobile-fullscreen-list] > .ehb-mobile-detail-tabs-head,
[data-mobile-fullscreen-list] > .ehb-daily-table-head {
box-sizing: border-box;
padding-inline-end: 112px !important;
}
/* 复合标题区的 Tab 在第二行,只让第一行标题避让横屏按钮。 */
[data-mobile-fullscreen-list] > .ehb-mobile-detail-tabs-head {
padding-inline-end: 14px !important;
}
[data-mobile-fullscreen-list] > .ehb-sum-table-card__head .ehb-sum-table-card__meta,
[data-mobile-fullscreen-list] > .sd-section-head .sd-panel__meta,
[data-mobile-fullscreen-list] > .sd-panel__head-row .sd-panel__meta {
max-width: 100%;
overflow-wrap: anywhere;
}
[data-mobile-fullscreen-list]:fullscreen,
[data-mobile-fullscreen-list].is-mobile-list-fullscreen,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] {
box-sizing: border-box;
z-index: 200;
overflow: auto;
padding: 14px;
border-radius: 0;
background: #f4f7fb;
}
[data-mobile-fullscreen-list].is-mobile-list-fullscreen,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] {
position: fixed;
inset: 0;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] > .mobile-list-fullscreen-trigger,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] > * > .mobile-list-fullscreen-trigger {
position: fixed !important;
top: 10px !important;
right: 10px !important;
z-index: 260 !important;
}
[data-mobile-fullscreen-list]:fullscreen .ehb-sum-table-wrap,
[data-mobile-fullscreen-list]:fullscreen .ehb-table-wrap,
[data-mobile-fullscreen-list]:fullscreen .sd-table-scroll,
[data-mobile-fullscreen-list].is-mobile-list-fullscreen .ehb-sum-table-wrap,
[data-mobile-fullscreen-list].is-mobile-list-fullscreen .ehb-table-wrap,
[data-mobile-fullscreen-list].is-mobile-list-fullscreen .sd-table-scroll,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table-wrap,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-table-wrap,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .sd-table-scroll {
max-height: none;
overflow: auto;
background: #fff;
}
}
/* 宽表只切换阅读布局:竖屏保持原方向并由表格横向滚动。 */
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] {
box-sizing: border-box;
position: fixed !important;
z-index: 200 !important;
inset: 0 !important;
overflow: auto !important;
padding: 10px !important;
border-radius: 0 !important;
background: #f4f7fb !important;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger {
position: fixed !important;
z-index: 260 !important;
top: 10px !important;
right: 10px !important;
display: inline-flex !important;
width: auto;
min-width: 92px;
height: 32px;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 10px;
border: 1px solid #dbe4f1;
border-radius: 9px;
background: #fff;
color: #4f6f9f;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table-wrap,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-table-wrap,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .sd-table-scroll {
max-height: none !important;
overflow: auto !important;
background: #fff;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table {
width: max-content !important;
min-width: 100% !important;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table thead th {
position: sticky;
top: 0;
z-index: 4;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table th:first-child,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table td:first-child {
position: sticky;
left: 0;
z-index: 5;
background: #fff;
box-shadow: 5px 0 9px -9px #0f172a;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] table thead th:first-child {
z-index: 7;
background: #f7f9fc;
}
/* 汇总表的第一列只是序号;继续冻结第二列站点名称,横向滚动时保留行身份。 */
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table th:first-child,
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table td:first-child {
width: 42px;
min-width: 42px;
max-width: 42px;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table th:nth-child(2),
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table td:nth-child(2) {
position: sticky;
left: 42px;
z-index: 5;
min-width: 190px;
background: #fff;
box-shadow: 7px 0 10px -10px #0f172a;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"] .ehb-sum-table thead th:nth-child(2) {
z-index: 7;
background: #f7f9fc;
}
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"]::after {
position: fixed;
right: 10px;
bottom: 10px;
z-index: 250;
padding: 5px 9px;
border: 1px solid #dbe4f1;
border-radius: 999px;
background: rgb(255 255 255 / 92%);
color: #64748b;
content: '左右滑动查看更多列';
font-size: 11px;
pointer-events: none;
box-shadow: 0 4px 12px rgb(15 23 42 / 8%);
}
@media (max-width: 767px) and (orientation: portrait) {
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"],
[data-mobile-fullscreen-list][data-mobile-fullscreen-active="true"].is-mobile-list-fallback {
transform: none !important;
transform-origin: initial !important;
}
}
/* 下钻弹层最终移动端约束:本文件最后加载,避免旧版宽表规则反向撑开页面。 */
@media (max-width: 767px) {
.ehb-drill-modal--unified .ehb-modal-body {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
min-width: 0 !important;
padding: 12px !important;
gap: 10px !important;
overflow: hidden !important;
}
.ehb-drill-modal--unified .ehb-modal-meta-bar {
flex: 0 0 auto !important;
width: 100% !important;
min-width: 0 !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 6px !important;
overflow: visible !important;
}
.ehb-drill-modal--unified .ehb-modal-meta-item {
min-width: 0 !important;
padding: 10px !important;
}
.ehb-drill-modal--unified .ehb-real-drill-filter-summary,
.ehb-drill-modal--unified .ehb-drill-filter-summary-row {
display: flex !important;
flex: 0 0 auto !important;
width: 100% !important;
min-width: 0 !important;
}
.ehb-real-drill-primary-actions {
display: grid !important;
flex: 0 0 auto;
grid-template-columns: minmax(0, 1fr) 132px;
width: 100%;
min-width: 0;
gap: 8px;
}
.ehb-real-drill-primary-actions .ehb-real-drill-filter-summary,
.ehb-real-drill-primary-actions .mobile-list-fullscreen-trigger {
position: static !important;
width: 100% !important;
min-width: 0 !important;
height: 48px !important;
border-radius: 12px !important;
}
.ehb-real-drill-primary-actions .ehb-real-drill-filter-summary {
display: grid !important;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 7px;
}
.ehb-real-drill-primary-actions .ehb-real-drill-filter-summary strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ehb-drill-modal--unified .ehb-real-drill-filter-summary {
min-height: 48px !important;
padding: 0 14px !important;
border: 1px solid #d9e3f0 !important;
border-radius: 12px !important;
background: #fff !important;
}
.ehb-drill-modal--unified .ehb-modal-table-wrap {
flex: 1 1 auto !important;
width: 100% !important;
min-width: 0 !important;
min-height: 0 !important;
overflow: auto !important;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.ehb-drill-modal--unified .ehb-drill-filter-summary-row {
display: grid !important;
grid-template-columns: minmax(0, 1fr) auto !important;
align-items: stretch !important;
gap: 8px !important;
}
.ehb-drill-modal--unified .ehb-drill-filter-summary-row .mobile-list-fullscreen-trigger {
position: static !important;
min-width: 124px !important;
height: 48px !important;
border-radius: 12px !important;
}
.ehb-drill-usage-guide {
display: grid !important;
flex: 0 0 auto;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
gap: 6px;
}
.ehb-drill-usage-guide > span {
min-width: 0;
padding: 7px 8px;
border-radius: 8px;
background: #eaf2ff;
color: #52637b;
font-size: 10px;
line-height: 1.35;
text-align: center;
}
.ehb-drill-usage-guide strong { color: #1f5fe0; }
.ehb-drill-modal--unified .ehb-modal-table tr[aria-expanded] > td:first-child {
position: sticky !important;
}
.ehb-drill-modal--unified .ehb-modal-table tr[aria-expanded] > td:first-child::after {
display: inline-flex;
margin-left: 8px;
padding: 2px 6px;
border-radius: 999px;
background: #e8f1ff;
color: #2563eb;
content: '展开';
font-size: 9px;
font-weight: 700;
white-space: nowrap;
vertical-align: middle;
}
.ehb-drill-modal--unified .ehb-modal-table tr[aria-expanded="true"] > td:first-child::after {
background: #e7f8f1;
color: #07875f;
content: '收起';
}
}
html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-body {
width: 100% !important;
min-width: 0 !important;
overflow: hidden !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-meta-bar {
width: 100% !important;
min-width: 0 !important;
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified .ehb-modal-table-wrap {
flex: 1 1 auto !important;
width: 100% !important;
min-width: 0 !important;
min-height: 0 !important;
overflow: auto !important;
}
@@ -1,13 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isPhoneUserAgent, phoneRotation } from './phone-viewport.js';
test('仅手机 UA 在竖屏宽表模式旋转,不影响平板和桌面', () => {
for (const ua of ['Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)', 'Mozilla/5.0 (Linux; Android 14) Mobile Safari/537.36']) {
assert.equal(isPhoneUserAgent(ua), true);
assert.equal(phoneRotation(ua, 390, 844), true);
assert.equal(phoneRotation(ua, 844, 390), false);
}
for (const ua of ['Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X)', 'Mozilla/5.0 (Macintosh; Intel Mac OS X)', 'Mozilla/5.0 (Linux; Android 14) Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)']) {
assert.equal(phoneRotation(ua, 390, 844), false);
}
});
@@ -1,8 +0,0 @@
/** UA-based phone detection intentionally excludes iPad and Android tablets. */
export function isPhoneUserAgent(ua: string): boolean {
return /iPhone|iPod|Windows Phone/i.test(ua) || (/Android/i.test(ua) && /Mobile/i.test(ua));
}
export function phoneRotation(ua: string, width: number, height: number): boolean {
return isPhoneUserAgent(ua) && height > width;
}
@@ -1,16 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { devMockResponse } from "./dev-mock-api";
test("开发 mock 覆盖健康检查与氢能 v2 只读接口", () => {
for (const path of [
"/api/health",
"/api/energy/h2/v2/meta",
"/api/energy/h2/v2/overview",
"/api/energy/h2/v2/daily",
"/api/energy/h2/v2/daily-tree",
"/api/energy/h2/v2/drill",
]) assert.ok(devMockResponse(path), path);
assert.equal(devMockResponse("/api/unknown"), undefined);
});
-175
View File
@@ -1,175 +0,0 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Plugin } from "vite";
const station = {
id: "101",
name: "本地验收加氢站",
province: "浙江省",
city: "嘉兴市",
kg: 12345.67,
lingniuKg: 10000,
externalKg: 2345.67,
cost: 23456.78,
revenue: 34567.89,
customerRevenue: 34567.89,
customerCost: 18000,
companyCost: 4000,
otherCost: 1456.78,
recordCount: 42,
customerCount: 1,
share: 100,
};
const customer = {
id: 1,
name: "本地验收客户",
kg: 12345.67,
customerBearingKg: 10000,
companyBearingKg: 2000,
otherBearingKg: 345.67,
bearer: "both",
cost: 23456.78,
revenue: 34567.89,
customerRevenue: 34567.89,
customerCost: 18000,
companyCost: 4000,
otherCost: 1456.78,
recordCount: 42,
};
const range = { startDate: "2026-01-01", endDate: "2026-08-31" };
const watermark = { ledgerAt: "2026-08-31 16:00:00", paymentAt: null };
const kpis = {
totalKg: 12345.67,
totalCost: 23456.78,
customerBearingKg: 10000,
companyBearingKg: 2000,
otherBearingKg: 345.67,
customerRevenue: 34567.89,
customerCost: 18000,
companyCost: 4000,
otherCost: 1456.78,
totalRevenue: 34567.89,
customerGrossProfit: 16567.89,
monthKg: 3456.78,
monthCost: 6789.01,
todayKg: 123.45,
todayCost: 234.56,
monthShareOfRange: 28,
todayShareOfMonth: 3.57,
recordCount: 42,
stationCount: 1,
};
const overview = {
range,
watermark,
filters: {},
kpis,
monthly: [{
month: "2026-08",
totalKg: 12345.67,
lingniuKg: 10000,
externalKg: 2345.67,
cost: 23456.78,
customerCost: 18000,
companyCost: 4000,
otherCost: 1456.78,
revenue: 34567.89,
customerRevenue: 34567.89,
customerGrossProfit: 16567.89,
}],
topStations: [station],
regions: [{ region: "嘉兴市", kg: 12345.67, share: 100 }],
stations: [station],
customers: [customer],
};
const dailyPoint = {
date: "2026-08-31",
kg: 123.45,
lingniuKg: 100,
externalKg: 23.45,
cost: 234.56,
recordCount: 2,
stationCount: 1,
};
export function devMockResponse(pathname: string) {
if (pathname === "/api/health") return { status: "ok", source: "dev-mock" };
if (pathname.endsWith("/meta")) return {
years: [{ value: 2026, startDate: range.startDate, endDate: range.endDate }],
stations: [{ id: station.id, name: station.name }],
watermark,
};
if (pathname.endsWith("/overview")) return overview;
if (pathname.endsWith("/daily-tree")) return {
date: dailyPoint.date,
stations: [{
id: station.id,
name: station.name,
kg: dailyPoint.kg,
cost: dailyPoint.cost,
recordCount: 2,
customers: [{ id: customer.id, name: customer.name, kg: dailyPoint.kg, cost: dailyPoint.cost, recordCount: 2 }],
}],
};
if (pathname.endsWith("/daily")) return {
range,
watermark,
filters: {},
kpis: { totalKg: 12345.67, totalCost: 23456.78, averageDailyKg: 823.04, stationCount: 1, activeDays: 15 },
trend: [dailyPoint],
days: [dailyPoint],
};
if (pathname.endsWith("/drill")) return {
groupBy: "record",
amountScope: "all",
filters: {},
summary: { recordCount: 1, kg: 123.45, cost: 4000, revenue: 4320 },
groups: [{ ...station, stationCount: 1, customerCount: 1 }],
records: [{
id: 1,
time: "2026-08-31 12:00:00",
orderNo: "DEV-001",
stationId: station.id,
stationName: station.name,
customerId: customer.id,
customerName: customer.name,
plateNo: "浙FDEV01",
source: "dev-mock",
verifyStatus: "verified",
vehicleId: 1,
kg: 123.45,
unitPrice: 35,
cost: 4000,
revenue: 4320,
}],
page: { page: 1, pageSize: 100, hasMore: false },
};
return undefined;
}
function sendJson(response: ServerResponse, body: unknown) {
response.statusCode = 200;
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.end(JSON.stringify(body));
}
export function energyDevMockApi(): Plugin {
return {
name: "energy-dev-mock-api",
configureServer(server) {
server.middlewares.use((request: IncomingMessage, response: ServerResponse, next) => {
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
if (pathname === "/favicon.ico") {
response.statusCode = 204;
return response.end();
}
const body = devMockResponse(pathname);
if (body === undefined) return next();
sendJson(response, body);
});
},
};
}
@@ -1,143 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fetchAllH2BiDrill, fetchAllH2BiDrillRecords } from "./api";
import type { H2BiDrillResponse } from "./types";
const query = {
year: 2026,
vehicleScope: "all" as const,
verifyScope: "all" as const,
};
function response(overrides: Partial<H2BiDrillResponse> = {}): H2BiDrillResponse {
return {
groupBy: "station",
amountScope: "all",
filters: {},
summary: {},
groups: [],
records: [],
page: { page: 1, pageSize: 2, hasMore: false },
...overrides,
};
}
async function withFetch(
handler: (url: URL) => H2BiDrillResponse | Promise<H2BiDrillResponse>,
run: () => Promise<void>,
) {
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
const url = new URL(String(input), "http://ln-bi.local");
return new Response(JSON.stringify(await handler(url)), { status: 200 });
}) as typeof fetch;
try {
await run();
} finally {
globalThis.fetch = original;
}
}
test("完整分组读取跨页,并以短页而非旧服务 hasMore 字段确认结束", async () => {
const pages: number[] = [];
await withFetch((url) => {
const page = Number(url.searchParams.get("page"));
pages.push(page);
return response({
groups: page === 1
? ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 }))
: [{ id: "3", name: "丙", province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 }],
});
}, async () => {
const result = await fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2 });
assert.deepEqual(result.groups.map((row) => row.name), ["甲", "乙", "丙"]);
assert.equal(result.page.complete, true);
assert.equal(result.page.pagesRead, 2);
});
assert.deepEqual(pages, [1, 2]);
});
test("完整记录读取跨页后保留全部订单", async () => {
await withFetch((url) => {
const page = Number(url.searchParams.get("page"));
return response({
groupBy: "record",
records: page === 1
? [{ id: "a" }, { id: "b" }]
: [{ id: "c" }],
});
}, async () => {
const result = await fetchAllH2BiDrillRecords(query, { pageSize: 2 });
assert.deepEqual(result.records.map((row) => row.id), ["a", "b", "c"]);
assert.equal(result.page.hasMore, false);
});
});
test("全量读取在中途请求失败时拒绝,不返回部分结果", async () => {
const original = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
const page = new URL(String(input), "http://ln-bi.local").searchParams.get("page");
if (page === "2") return new Response("failed", { status: 502, statusText: "Bad Gateway" });
return new Response(JSON.stringify(response({
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
})), { status: 200 });
}) as typeof fetch;
try {
await assert.rejects(
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2 }),
/API error: 502/,
);
} finally {
globalThis.fetch = original;
}
});
test("空结果是完整结果而不是加载失败", async () => {
await withFetch(() => response(), async () => {
const result = await fetchAllH2BiDrill({ ...query, groupBy: "station" });
assert.deepEqual(result.groups, []);
assert.equal(result.page.pagesRead, 1);
});
});
test("取消的全量读取不会发起请求或生成部分数据", async () => {
const controller = new AbortController();
controller.abort();
await assert.rejects(
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { signal: controller.signal }),
(error: Error) => error.name === "AbortError",
);
});
test("全量读取在第一页完成后也会响应取消,不会请求下一页", async () => {
const controller = new AbortController();
let calls = 0;
await withFetch(() => {
calls += 1;
controller.abort();
return response({
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
});
}, async () => {
await assert.rejects(
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { signal: controller.signal, pageSize: 2 }),
(error: Error) => error.name === "AbortError",
);
});
assert.equal(calls, 1);
});
test("maxRows 和 maxPages 均拒绝不完整的全量读取", async () => {
await withFetch(() => response({
groups: ["甲", "乙"].map((name, index) => ({ id: String(index), name, province: null, city: null, recordCount: 1, stationCount: 1, customerCount: 1, kg: 1, cost: 1, revenue: 1, lingniuKg: 1, externalKg: 0 })),
}), async () => {
await assert.rejects(
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2, maxRows: 1 }),
/超过 1 条保护上限/,
);
await assert.rejects(
fetchAllH2BiDrill({ ...query, groupBy: "station" }, { pageSize: 2, maxPages: 1 }),
/超过 1 页保护上限/,
);
});
});
@@ -1,23 +0,0 @@
import type { ReactNode } from "react";
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
export function DailyTreeButton({ open, label, children, onClick }: {
open: boolean; label: string; children: ReactNode; onClick: () => void;
}) {
const Icon = open ? ChevronDown : ChevronRight;
return <button type="button" className="ehb-daily-disclosure" aria-expanded={open}
aria-label={`${open ? "收起" : "展开"}${label}`} onClick={onClick}>
<Icon size={15} aria-hidden="true" /><span>{children}</span>
</button>;
}
export function DailyBranchState({ columns, error, empty, onRetry }: {
columns: number; error?: string; empty?: boolean; onRetry: () => void;
}) {
return <tr className="ehb-daily-branch-state"><td colSpan={columns}>
<div role={error ? "alert" : "status"}>
{error || (empty ? "当前范围暂无明细" : "正在加载明细…")}
{error ? <button type="button" onClick={onRetry}><RefreshCw size={14} aria-hidden="true" />重试</button> : null}
</div>
</td></tr>;
}
@@ -1,52 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { DailyBranchState, DailyTreeButton } from "./daily-detail-controls";
import { dailySummaryRows, formatDailyChange } from "../model/daily-detail-format";
import type { H2BiDailyResponse } from "../types";
test("每日环比区分真实零值、缺失值和非数值", () => {
for (const value of [null, undefined, NaN, Infinity, "0"]) assert.equal(formatDailyChange(value), "环比 —");
assert.equal(formatDailyChange(0), "环比 0.00%");
assert.equal(formatDailyChange(-12.5), "环比 -12.50%");
assert.equal(formatDailyChange(12.5), "环比 +12.50%");
});
test("日期汇总导出包含全部日期,不依赖已展开明细并保留真零", () => {
const daily = {
kpis: { stationCount: 2, totalKg: 15.25, totalCost: 450 },
days: [
{ date: "2026-09-03", stationCount: 2, kg: 15.25, cost: 450 },
{ date: "2026-09-02", stationCount: 0, kg: 0, cost: 0 },
],
} as H2BiDailyResponse;
const rows = dailySummaryRows(daily);
assert.equal(rows.length, 4);
assert.deepEqual(rows[1], ["区间合计", 2, 15.25, 450]);
assert.deepEqual(rows[3], ["2026-09-02", 0, 0, 0]);
});
test("层级按钮包含键盘原生语义与明确展开状态", () => {
for (const open of [false, true]) {
const html = renderToStaticMarkup(createElement(DailyTreeButton, {
open, label: "测试站客户明细", children: "测试站", onClick() {},
}));
assert.match(html, /<button type="button"/);
assert.ok(html.includes(`aria-expanded="${open}"`));
assert.ok(html.includes(`${open ? "收起" : "展开"}测试站客户明细`));
}
});
test("展开状态区分加载、空数据、失败及重试入口", () => {
const render = (props: Partial<Parameters<typeof DailyBranchState>[0]>) => renderToStaticMarkup(createElement(DailyBranchState, {
columns: 3, onRetry() {}, ...props,
}));
assert.match(render({}), /正在加载明细/);
assert.match(render({ empty: true }), /当前范围暂无明细/);
const error = render({ error: "站点明细加载失败,请重试" });
assert.match(error, /role="alert"/);
assert.match(error, /<button type="button"/);
assert.match(error, /colSpan="3"/i);
assert.doesNotMatch(error, /正在加载明细/);
});
@@ -1,688 +0,0 @@
@import './drill-workspace.css';
.ehb-drill-modal--unified .ehb-modal-body { padding: 18px; background: #f6f8fb; }
.ehb-drill-modal--unified .ehb-drill-root-tabs { display: flex; justify-content: flex-end; margin: 0 0 12px; }
.ehb-drill-modal--unified .ehb-modal-meta-bar {
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0;
padding: 0; overflow: hidden; border: 1px solid #dfe7f0; border-radius: 8px; background: #fff;
}
.ehb-drill-modal--unified .ehb-modal-meta-item { min-width: 0; padding: 12px 16px; border-right: 1px solid #dfe7f0; }
.ehb-drill-modal--unified .ehb-modal-meta-item:last-child { border-right: 0; }
.ehb-drill-modal--unified .ehb-modal-meta-label { color: #64748b; }
.ehb-drill-modal--unified .ehb-modal-meta-val { color: #172238; font-family: var(--bi-font-mono); font-weight: 750; }
.ehb-drill-modal--unified .ehb-modal-filter-row {
gap: 8px; padding: 10px 12px; margin: 12px 0; border: 1px solid #dfe7f0;
border-radius: 8px; background: #fff;
}
.ehb-drill-modal--unified .ehb-modal-filter-group { width: 100%; gap: 8px; }
.ehb-drill-modal--unified .ehb-modal-hint-text { flex: 1 1 280px; min-width: 220px; color: #7b8aa1; }
.ehb-drill-modal--unified .ehb-modal-table-wrap { border-color: #d7e1ed; border-radius: 8px; background: #fff; }
/* Reserve space for pagination instead of clipping it below a fixed-height table. */
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) {
display: flex;
flex-direction: column;
min-height: 0;
}
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) > * {
flex-shrink: 0;
}
.ehb-drill-modal--unified .ehb-modal-body:has(.ehb-drill-page-controls) > .ehb-modal-table-wrap {
flex: 1 1 auto;
min-height: 100px;
}
.ehb-drill-page-controls {
display: grid !important;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.ehb-drill-page-controls .ehb-btn {
flex: 0 0 auto;
min-width: 72px;
width: 72px !important;
min-height: 40px;
white-space: nowrap;
}
.ehb-drill-modal--unified .ehb-modal-table th {
height: 44px; padding: 10px 12px; border-bottom-color: #d7e1ed;
background: #f0f3f8; color: #52627a;
}
.ehb-drill-modal--unified .ehb-modal-table > tbody > tr > td {
height: 48px; padding-block: 10px; border-bottom-color: #e5ebf2; color: #334155;
}
.ehb-drill-modal--unified .ehb-drill-group-row--station > td { background: #edf3fa; }
.ehb-drill-modal--unified .ehb-drill-group-row--customer > td { background: #f5f7fa; }
.ehb-drill-modal--unified .ehb-drill-group-row--vehicle > td { background: #fafbfd; }
.ehb-drill-modal--unified .ehb-drill-group-row:hover > td { background: #e8f0fb; }
.ehb-drill-modal--unified .ehb-tree-toggle {
display: inline-grid; width: 24px; min-width: 24px; height: 24px; place-items: center;
padding: 0; border: 0; border-radius: 5px; background: transparent; color: #2f6bff;
font-weight: 800; cursor: pointer;
}
.ehb-drill-modal--unified .ehb-tree-toggle:hover,
.ehb-drill-modal--unified .ehb-tree-toggle:focus-visible { background: #eaf1ff; outline: none; }
.ehb-drill-modal--unified .ehb-tree-drill-link {
margin-left: auto; padding: 0; border: 0; background: transparent; color: #2f6bff;
font: inherit; font-size: 12px; font-weight: 700; white-space: nowrap; cursor: pointer;
}
.ehb-drill-modal--unified .ehb-tree-drill-link:hover,
.ehb-drill-modal--unified .ehb-tree-drill-link:focus-visible { color: #174ebc; text-decoration: underline; outline: none; }
.ehb-drill-modal--unified .ehb-modal-table td[style*="text-align: right"] {
color: #334155; font-family: var(--bi-font-mono); font-variant-numeric: tabular-nums;
}
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-volume { color: #2f6bff !important; font-weight: 750; }
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-income { color: #2c8a78 !important; font-weight: 750; }
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-cost { color: #e28a24 !important; font-weight: 750; }
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-profit { color: #2f6bff !important; font-weight: 750; }
.ehb-drill-modal--unified .ehb-modal-table td.ehb-key-external { color: #7ea5ec !important; font-weight: 750; }
.ehb-drill-modal--unified .ehb-summary-volume,
.ehb-drill-modal--unified .ehb-summary-profit { color: #2f6bff; }
.ehb-drill-modal--unified .ehb-summary-income { color: #2c8a78; }
.ehb-drill-modal--unified .ehb-summary-cost { color: #e28a24; }
.ehb-drill-modal--unified .ehb-flat-drill-table tbody tr:nth-child(even) td { background: #fafbfd; }
.ehb-drill-modal--unified .ehb-drill-group-row--date td { background: #fff; }
.ehb-drill-modal--unified .ehb-day-change.is-up { color: #18a67a; font-weight: 700; }
.ehb-drill-modal--unified .ehb-day-change.is-down { color: #e26464; font-weight: 700; }
.ehb-drill-modal--unified .ehb-bearer-tag.is-cust { color: #b86606; border: 1px solid #f2d26c; background: #fffbea; }
.ehb-drill-modal--unified .ehb-bearer-tag.is-lingniu { color: #2f6bff; border: 1px solid #bdd0fb; background: #eef4ff; }
.ehb-drill-modal--unified .ehb-bearer-tag.is-other { color: #64748b; border: 1px solid #cbd5e1; background: #f8fafc; }
.ehb-drill-modal--unified .ehb-tag--source-api,
.ehb-drill-modal--unified .ehb-tag--source-station,
.ehb-drill-modal--unified .ehb-tag--source-lingniu {
border: 1px solid #cbd9e8; background: #eef4f9; color: #526b88;
}
.ehb-drill-local-error {
display: flex;
min-height: 180px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px;
color: #475569;
text-align: center;
}
.ehb-drill-local-error strong { color: #b42318; font-size: 16px; }
.ehb-drill-local-error span { max-width: 520px; line-height: 1.6; }
.ehb-drill-loading {
position: relative;
min-height: 300px;
overflow: hidden;
background: #fff;
animation: ehb-drill-fade-in .18s ease-out both;
}
.ehb-drill-loading__progress {
position: absolute;
inset: 0 auto auto 0;
width: 38%;
height: 3px;
border-radius: 999px;
background: linear-gradient(90deg, transparent, #2f6bff 45%, #67b5ff, transparent);
animation: ehb-drill-progress 1.15s ease-in-out infinite;
}
.ehb-drill-loading__label {
display: flex;
align-items: center;
gap: 12px;
padding: 22px 24px 18px;
border-bottom: 1px solid #e7edf5;
color: #1e293b;
}
.ehb-drill-loading__label > span:last-child {
display: flex;
flex-direction: column;
gap: 3px;
}
.ehb-drill-loading__label strong { font-size: 14px; }
.ehb-drill-loading__label small { color: #7b8aa0; font-size: 12px; }
.ehb-drill-loading__spinner {
width: 20px;
height: 20px;
flex: 0 0 20px;
border: 2px solid #dbe7fb;
border-top-color: #2f6bff;
border-radius: 50%;
animation: ehb-drill-spin .72s linear infinite;
}
.ehb-drill-loading__rows { padding: 4px 18px 18px; }
.ehb-drill-loading__row {
display: grid;
grid-template-columns: minmax(180px, 2.2fr) repeat(3, minmax(90px, 1fr));
gap: 28px;
align-items: center;
min-width: 680px;
height: 48px;
border-bottom: 1px solid #eef2f7;
}
.ehb-drill-loading__row i {
display: block;
height: 11px;
border-radius: 999px;
background: linear-gradient(100deg, #edf2f8 20%, #f8fafc 42%, #e7eef8 64%);
background-size: 220% 100%;
animation: ehb-drill-shimmer 1.25s ease-in-out infinite;
}
.ehb-drill-loading__row i:nth-child(2) { width: 72%; }
.ehb-drill-loading__row i:nth-child(3) { width: 58%; }
.ehb-drill-loading__row i:nth-child(4) { width: 82%; }
.ehb-modal-table-wrap.is-ready > .ehb-modal-table {
animation: ehb-drill-content-in .24s ease-out both;
}
@keyframes ehb-drill-spin { to { transform: rotate(360deg); } }
@keyframes ehb-drill-progress {
0% { transform: translateX(-110%); opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
100% { transform: translateX(370%); opacity: 0; }
}
@keyframes ehb-drill-shimmer { to { background-position: -220% 0; } }
@keyframes ehb-drill-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes ehb-drill-content-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.ehb-drill-loading,
.ehb-drill-loading__progress,
.ehb-drill-loading__spinner,
.ehb-drill-loading__row i,
.ehb-modal-table-wrap.is-ready > .ehb-modal-table { animation: none; }
}
@media (max-width: 760px) {
.ehb-drill-modal--unified .ehb-modal-meta-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ehb-drill-modal--unified .ehb-modal-meta-item:nth-child(2) { border-right: 0; }
.ehb-drill-modal--unified .ehb-modal-meta-item:nth-child(-n + 2) { border-bottom: 1px solid #dfe7f0; }
}
.ehb-real-drill-filter-summary {
display: none;
}
@media (max-width: 767px) {
.ehb-drill-modal--unified .ehb-modal-head {
gap: 8px;
padding: 10px 12px;
}
.ehb-drill-modal--unified .ehb-modal-head__title-group {
min-width: 0;
}
.ehb-drill-modal--unified .ehb-modal-head__title {
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ehb-drill-modal--unified .ehb-modal-head__sub {
font-size: 11px;
}
.ehb-drill-modal--unified .ehb-modal-head__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
}
.ehb-drill-modal--unified .ehb-modal-head__actions .mobile-list-fullscreen-trigger {
position: static;
min-width: 76px;
height: 32px;
padding-inline: 8px;
}
.ehb-drill-modal--unified .ehb-modal-body {
padding: 10px;
}
.ehb-drill-modal--unified .ehb-modal-meta-item {
padding: 9px 10px;
}
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
.ehb-drill-modal--unified .ehb-modal-table td:first-child {
width: 235px;
min-width: 235px;
}
/* 宽表左右移动时首列保留完整的站点/客户名称、层级三角和同省标识。 */
.ehb-drill-modal--unified .ehb-modal-table-wrap { overflow: auto !important; }
.ehb-drill-modal--unified .ehb-modal-table { min-width: max-content; }
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
.ehb-drill-modal--unified .ehb-modal-table td:first-child {
position: sticky;
left: 0;
z-index: 3;
background: #fff;
box-shadow: 6px 0 10px -10px #0f172a;
}
.ehb-drill-modal--unified .ehb-modal-table thead th:first-child {
z-index: 5;
background: #f0f3f8;
}
.ehb-drill-modal--unified .ehb-drill-group-row--station > td:first-child { background: #edf3fa; }
.ehb-drill-modal--unified .ehb-drill-group-row--customer > td:first-child { background: #f5f7fa; }
.ehb-drill-modal--unified .ehb-drill-group-row--vehicle > td:first-child { background: #fafbfd; }
.ehb-real-drill-filter-summary {
display: grid;
width: 100%;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
margin: 0 0 8px;
padding: 10px 12px;
border: 1px solid #dbe4f1;
border-radius: 10px;
background: #f8fafc;
color: #64748b;
text-align: left;
}
.ehb-real-drill-filter-summary > span {
display: inline-flex;
align-items: center;
gap: 5px;
color: #2f6bff;
font-weight: 700;
}
.ehb-real-drill-filter-summary strong {
overflow: hidden;
color: #1e293b;
text-overflow: ellipsis;
white-space: nowrap;
}
.ehb-real-drill-filter-summary > svg {
transition: transform .18s ease;
}
.ehb-real-drill-filter-summary > svg.is-open {
transform: rotate(180deg);
}
.ehb-real-drill-filter-panel:not(.is-open) {
display: none !important;
}
.ehb-real-drill-filter-panel.is-open {
display: flex !important;
}
.ehb-drill-modal--unified .ehb-modal-hint-text {
width: 100%;
overflow: visible !important;
white-space: normal !important;
text-overflow: clip !important;
line-height: 1.45;
}
.ehb-drill-modal--unified .ehb-modal-search-input,
.ehb-drill-modal--unified .ehb-modal-search-input input {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
}
/* 小屏横向空间充足时,把摘要和筛选压成一行,优先留高度给下钻表格。 */
@media (max-height: 500px) and (orientation: landscape) {
.ehb-drill-modal--unified .ehb-modal-head { padding-block: 6px; }
.ehb-drill-modal--unified .ehb-modal-head__sub { display: inline; margin-left: 8px; }
.ehb-drill-modal--unified .ehb-modal-body { padding: 6px 8px 8px; }
.ehb-drill-modal--unified .ehb-drill-root-tabs { margin-bottom: 6px; }
.ehb-drill-modal--unified .ehb-modal-meta-bar {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 6px;
}
.ehb-drill-modal--unified .ehb-modal-meta-item {
padding: 6px 10px;
border-right: 1px solid #dfe7f0;
border-bottom: 0 !important;
}
.ehb-drill-modal--unified .ehb-modal-meta-item:last-child { border-right: 0; }
.ehb-drill-modal--unified .ehb-modal-filter-row { margin-bottom: 6px; padding: 6px; }
.ehb-drill-modal--unified .ehb-modal-hint-text { display: none; }
.ehb-drill-modal--unified .ehb-drill-loading { min-height: 150px; }
}
/* “横屏查看”是页面布局模式:不依赖手机方向锁定。 */
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] {
display: flex;
flex-direction: column;
overflow: hidden !important;
padding: 0 !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
flex: 0 0 auto;
padding: 7px 10px;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-body {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
padding: 7px 8px 8px;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-bar {
grid-template-columns: repeat(4, minmax(150px, 1fr));
margin-bottom: 6px;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-item {
padding: 6px 9px;
border-right: 1px solid #dfe7f0;
border-bottom: 0;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-filter-row {
flex: 0 0 auto;
margin-bottom: 6px;
padding: 6px;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-table-wrap {
min-height: 0;
flex: 1 1 auto;
overflow: auto !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-hint-text,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-filter-summary {
display: none;
}
/* 下钻路径是导航,不是装饰:允许直接回到任一上级。 */
.ehb-drill-breadcrumbs {
display: flex;
flex: 0 0 auto;
min-width: 0;
align-items: center;
gap: 5px;
padding: 9px 18px;
overflow-x: auto;
border-bottom: 1px solid #dfe7f0;
background: #fff;
scrollbar-width: none;
}
.ehb-drill-breadcrumbs::-webkit-scrollbar { display: none; }
.ehb-drill-breadcrumb { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 5px; }
.ehb-drill-breadcrumb i { color: #9aabc0; font-style: normal; }
.ehb-drill-breadcrumb button {
max-width: 210px;
padding: 4px 7px;
overflow: hidden;
border: 0;
border-radius: 7px;
background: transparent;
color: #3564a5;
font: 650 12px/1.3 var(--bi-font);
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.ehb-drill-breadcrumb button:not(:disabled):hover { background: #edf4ff; color: #1f5fe0; }
.ehb-drill-breadcrumb button[aria-current="page"] {
background: #edf3fc;
color: #24344e;
cursor: default;
}
@media (max-width: 767px) {
.ehb-drill-modal--unified {
display: flex !important;
flex-direction: column !important;
}
.ehb-drill-modal--unified .ehb-modal-head { flex: 0 0 auto !important; }
.ehb-drill-modal--unified .ehb-modal-close-btn { display: none !important; }
.ehb-drill-modal--unified .ehb-modal-back-btn {
min-width: 108px !important;
min-height: 42px !important;
justify-content: center;
border-color: rgb(255 255 255 / 28%) !important;
background: rgb(255 255 255 / 10%) !important;
color: #fff !important;
font-weight: 700 !important;
}
.ehb-drill-breadcrumbs {
padding: 8px 10px;
box-shadow: 0 3px 10px rgb(32 55 89 / 6%);
}
.ehb-drill-breadcrumb button { max-width: 150px; min-height: 30px; font-size: 11px; }
.ehb-drill-modal--unified .ehb-modal-body {
height: auto !important;
min-height: 0 !important;
flex: 1 1 auto !important;
}
.ehb-drill-modal--unified .ehb-tree-drill-link { font-size: 10px !important; }
.ehb-drill-modal--unified .ehb-drill-group-row { min-height: 58px; }
.ehb-drill-modal--unified .ehb-drill-group-row > td:first-child { min-width: 220px !important; }
.ehb-drill-modal--unified .ehb-tree-node-title { display: flex; align-items: center; gap: 4px; }
.ehb-drill-modal--unified .ehb-tree-node-name {
display: inline-flex;
min-width: 0;
flex: 1 1 240px;
flex-wrap: wrap;
align-items: baseline;
gap: 4px;
}
.ehb-drill-modal--unified .ehb-tree-node-name strong { min-width: 0; overflow-wrap: anywhere; }
.ehb-drill-modal--unified .ehb-tree-expanded-row > td { padding: 0 !important; background: #f8fbff; }
.ehb-drill-modal--unified .ehb-tree-expanded-content { padding: 8px 14px 10px 38px; border-bottom: 1px solid #dbe7f5; }
.ehb-drill-modal--unified .ehb-tree-expanded-content ul { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 5px 10px; margin: 0; padding: 0; list-style: none; }
.ehb-drill-modal--unified .ehb-tree-expanded-content li { display: flex; justify-content: space-between; gap: 8px; padding: 5px 7px; color: #31578c; }
.ehb-drill-modal--unified .ehb-tree-expanded-all { margin: 8px 7px 0; padding: 3px 0; border: 0; background: transparent; color: #2563eb; font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; }
.ehb-drill-modal--unified .ehb-tree-expanded-all:hover { text-decoration: underline; }
.ehb-drill-modal--unified .ehb-tree-expanded-content small { color: #64748b; white-space: nowrap; }
}
@media (max-width: 767px) and (orientation: landscape) {
.ehb-drill-modal--unified .ehb-modal-table th:first-child,
.ehb-drill-modal--unified .ehb-modal-table td:first-child { width: 300px; min-width: 300px; }
}
/* 竖屏宽表保持原方向;表格容器负责横向滚动,绝不旋转整个页面。 */
@media (max-width: 767px) and (orientation: portrait) {
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback {
display: flex !important;
flex-direction: column !important;
padding: 0 !important;
overflow: auto !important;
background: #f4f7fb !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table-wrap {
width: 100% !important;
min-width: 0 !important;
min-height: 240px !important;
border-radius: 8px !important;
overflow: auto !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table {
min-width: 960px !important;
font-size: 10px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table th,
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table td {
min-width: 112px !important;
height: 38px !important;
min-height: 38px !important;
padding: 6px 8px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table th:first-child,
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback .ehb-modal-table td:first-child {
width: 190px !important;
min-width: 190px !important;
max-width: 190px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback::after {
right: 8px !important;
bottom: 8px !important;
padding: 4px 8px !important;
font-size: 10px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback,
html.ehb-landscape-session .ehb-drill-modal--unified.is-mobile-list-fallback * {
transform: none !important;
}
}
/* 原生横屏与旋转兼容统一进入专注阅读状态。 */
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-breadcrumbs,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-root-tabs,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-meta-bar,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-filter-summary,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-filter-row,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-drill-usage-guide {
display: none !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
min-height: 42px !important;
height: 42px !important;
padding: 4px 142px 4px 8px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__sub {
display: none !important;
}
/* 专注阅读仍是可导航的下钻页面;必须能逐层返回,不能要求先退出宽表。 */
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn {
display: inline-flex !important;
width: 44px !important;
min-width: 44px !important;
min-height: 44px !important;
padding: 0 !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head {
padding: 2px 88px 2px 8px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-back-btn > span {
display: none;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title-group {
display: grid !important;
grid-template-columns: 44px minmax(0, 1fr);
width: 100%;
min-width: 0;
align-items: center;
gap: 7px;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title-group > div {
min-width: 0;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-head__title {
max-width: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-primary-actions,
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger {
width: 80px !important;
min-width: 80px !important;
}
@media (max-width: 767px) {
/* 让三角、名称和轻量下钻提示在同一行网格内分配空间;长名称仅在名称格内换行。 */
.ehb-drill-modal--unified .ehb-tree-node-title {
display: grid !important;
grid-template-columns: 28px minmax(0, 1fr) 24px;
align-items: start;
gap: 3px;
width: 100%;
}
.ehb-drill-modal--unified .ehb-tree-toggle { width: 28px; min-width: 28px; height: 28px; }
.ehb-drill-modal--unified .ehb-tree-node-name {
display: block;
min-width: 0;
flex: none;
overflow-wrap: anywhere;
}
.ehb-drill-modal--unified .ehb-tree-node-name .ehb-tree-node-sub { margin-left: 4px; }
.ehb-drill-modal--unified .ehb-tree-drill-link {
display: inline-grid !important;
width: 24px !important;
min-width: 24px !important;
height: 28px !important;
place-items: center;
margin: 0 !important;
overflow: hidden;
color: transparent !important;
font-size: 0 !important;
text-decoration: none !important;
}
.ehb-drill-modal--unified .ehb-tree-drill-link::after { content: "›" !important; color: #2563eb; font-size: 20px; line-height: 1; }
/* 普通弹层的筛选和宽表入口各占一个网格列;退出宽表后不保留绝对定位层。 */
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .ehb-real-drill-primary-actions {
position: static !important;
display: grid !important;
grid-template-columns: minmax(0, 1fr) 112px;
align-items: stretch;
gap: 8px;
width: 100%;
}
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .mobile-list-fullscreen-trigger.is-inline {
position: static !important;
inset: auto !important;
width: 112px !important;
min-width: 112px !important;
min-height: 48px !important;
}
.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) .mobile-list-fullscreen-trigger.is-inline::before {
display: none;
}
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-real-drill-primary-actions {
position: absolute !important;
z-index: 280 !important;
top: 5px !important;
right: 8px !important;
display: block !important;
width: 126px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .mobile-list-fullscreen-trigger {
position: static !important;
display: inline-flex !important;
width: 126px !important;
min-width: 126px !important;
height: 32px !important;
min-height: 32px !important;
border-radius: 8px !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-body {
display: flex !important;
min-height: 0 !important;
flex: 1 1 auto !important;
padding: 5px 6px 6px !important;
gap: 0 !important;
overflow: hidden !important;
}
html.ehb-landscape-session .ehb-drill-modal--unified[data-mobile-fullscreen-active="true"] .ehb-modal-table-wrap {
width: 100% !important;
min-width: 0 !important;
min-height: 0 !important;
max-height: none !important;
flex: 1 1 auto !important;
margin: 0 !important;
overflow: auto !important;
}
@@ -1,179 +0,0 @@
/* Shared drill workspace: navigation stays visible, data owns the remaining height. */
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] {
display: flex !important;
flex-direction: column !important;
}
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] > .ehb-modal-body {
flex: 1 1 0 !important;
min-height: 0 !important;
padding: 4px 8px !important;
gap: 3px !important;
}
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] .ehb-modal-table {
width: 100% !important;
min-width: 1050px !important;
}
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] .ehb-modal-table th,
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] .ehb-modal-table td {
padding: 7px 9px !important;
font-size: 11px !important;
}
html.ehb-landscape-session .ehb-modal-card.ehb-drill-modal--unified[data-mobile-fullscreen-mode="phone-rotated"] .ehb-drill-page-controls .ehb-btn {
min-height: 32px !important;
padding: 4px 10px !important;
}
.ehb-drill-modal--unified .ehb-inline-child-row > td { background: #f8fbff; border-bottom: 1px solid #e2eaf4; }
.ehb-drill-modal--unified .ehb-inline-child-row > td:first-child { background: #f8fbff !important; }
.ehb-inline-child-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
.ehb-inline-child-name > span { overflow-wrap: anywhere; font-weight: 600; }
.ehb-inline-child-name small { display: block; color: #64748b; }
.ehb-inline-child-toggle { flex: 0 0 32px; width: 32px; height: 36px; border: 0; border-radius: 6px; color: #2563eb; background: #eaf2ff; cursor: pointer; }
.ehb-drill-modal--unified .ehb-inline-child-status > td,
.ehb-drill-modal--unified .ehb-tree-expanded-row > td { padding: 8px 16px !important; background: #f8fbff; }
.ehb-drill-modal--unified .ehb-tree-expanded-all { border: 0; background: transparent; color: #2563eb; font-size: 12px; font-weight: 600; cursor: pointer; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title-group {
min-width: 0;
gap: 14px;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title-group > div { min-width: 0; }
@media (max-width: 767px) {
.ehb-modal-card.ehb-drill-modal--unified:not([data-mobile-fullscreen-active="true"]) > .ehb-modal-head {
min-height: 68px;
padding: 10px 12px !important;
box-sizing: border-box;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title-group {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-back-btn {
width: 44px !important;
min-width: 44px !important;
height: 44px !important;
min-height: 44px !important;
flex: 0 0 44px;
padding: 0 !important;
border: 0 !important;
border-radius: 10px;
background: rgb(255 255 255 / 6%) !important;
box-shadow: none;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-back-btn > span { display: none; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__title {
font-size: 16px !important;
line-height: 1.4 !important;
white-space: normal;
overflow-wrap: anywhere;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-head__sub {
margin-top: 3px;
font-size: 11px !important;
line-height: 1.5;
color: #a7b6cc;
letter-spacing: 0;
}
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-table-wrap > table > thead > tr > th {
position: sticky;
top: 0;
z-index: 10;
background: #f0f3f8;
box-shadow: inset 0 -1px 0 #d7e1ed;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-table-wrap > table > thead > tr > th:first-child {
z-index: 11;
}
.ehb-modal-overlay .ehb-modal-card.ehb-drill-modal--unified {
width: 96vw !important;
max-width: 1800px !important;
height: 94dvh !important;
max-height: 94dvh !important;
display: flex !important;
flex-direction: column !important;
overflow: hidden !important;
}
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-head {
padding: 10px 16px !important;
flex: 0 0 auto;
}
.ehb-modal-card.ehb-drill-modal--unified > .ehb-drill-breadcrumbs { padding: 5px 16px; }
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-body {
flex: 1 1 auto !important;
min-height: 0 !important;
display: flex !important;
flex-direction: column !important;
gap: 6px !important;
padding: 8px 16px !important;
overflow: hidden !important;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-body > * { flex: 0 0 auto; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-body > .ehb-modal-hint-text {
flex: 0 0 auto !important;
min-width: 0;
margin: 0 !important;
line-height: 1.5;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-summary > span {
display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-drill-root-tabs { margin: 0 !important; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-bar { margin: 0 !important; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-item { padding: 6px 12px !important; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-val { font-size: 18px !important; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-primary-actions {
display: flex !important;
align-items: center;
gap: 8px;
margin: 0 !important;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-summary {
display: flex !important;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 40px;
padding: 6px 10px;
border: 1px solid #d7e1ed;
border-radius: 8px;
background: white;
color: #334155;
flex: 1;
min-width: 0;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-summary strong {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-panel:not(.is-open) { display: none !important; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-real-drill-filter-panel.is-open {
display: flex !important;
max-height: 30dvh;
overflow: auto;
margin: 0 !important;
padding: 8px !important;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-drill-usage-guide { font-size: 12px; margin: 0; display: flex; gap: 12px; flex-wrap: wrap; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-body > .ehb-modal-table-wrap {
flex: 1 1 0 !important;
min-height: 0 !important;
max-height: none !important;
margin: 0 !important;
overflow: auto !important;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-drill-page-controls { margin-top: 0 !important; padding: 0; }
@media (max-width: 767px), (max-height: 500px) {
.ehb-modal-overlay .ehb-modal-card.ehb-drill-modal--unified {
width: 100vw !important; height: 100dvh !important; max-height: 100dvh !important;
border-radius: 0 !important; margin: 0 !important;
}
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-head { padding: 6px 10px !important; }
.ehb-modal-card.ehb-drill-modal--unified > .ehb-modal-body {
padding: 6px 8px max(6px, env(safe-area-inset-bottom)) !important; gap: 4px !important;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-bar {
display: flex !important; flex-wrap: nowrap !important; overflow-x: auto !important;
}
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-item { flex: 0 0 auto; width: auto !important; min-width: 120px; padding: 5px 9px !important; }
.ehb-modal-card.ehb-drill-modal--unified .ehb-modal-meta-val { font-size: 16px !important; }
}
@@ -1,762 +0,0 @@
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { Download, RefreshCw, Truck } from "lucide-react";
import { DailyTreeButton, DailyBranchState } from "./daily-detail-controls";
import { dailySummaryRows, formatDailyChange } from "../model/daily-detail-format";
import { formatFixed } from "../model/display-format";
import { fetchH2BiDaily, fetchH2BiDailyTree, fetchH2BiDrill } from "../api";
import { exportAoaSheet } from "../../../../shared/xlsx";
import "./real-daily-mobile.css";
import type {
H2BiDailyResponse,
H2BiDailyTreeResponse,
H2BiDrillResponse,
H2BiQuery,
H2BiVehicleScope,
} from "../types";
const toScope = (scope: "all" | "own" | "external"): H2BiVehicleScope =>
scope === "own" ? "lingniu" : scope;
const sourceLabel = (source: unknown) => {
const value = String(source || "").toLowerCase();
if (value === "lingniu") return "羚牛上报";
if (value === "api") return "API接入";
if (value === "station") return "站点上报";
return String(source || "未知来源");
};
/** Keep raw API statuses out of the UI. Missing/unknown statuses are unverified. */
const verifyLabel = (status: unknown) => {
const value = String(status ?? "").trim().toLowerCase();
return value === "verified" || value === "pass" || value === "已验证"
? "已验证"
: "未验证";
};
export function PrototypeRealDailyView({
startDate,
endDate,
onStartDateChange,
onEndDateChange,
fleetScope,
onFleetScopeChange,
verifyScope,
stationId = null,
onRefresh,
refreshToken = 0,
onLoadingChange,
}: {
startDate: string;
endDate: string;
onStartDateChange: (value: string) => void;
onEndDateChange: (value: string) => void;
fleetScope: "all" | "own" | "external";
onFleetScopeChange: (value: "all" | "own" | "external") => void;
verifyScope: "all" | "verified";
stationId?: string | number | null;
onRefresh: () => void;
refreshToken?: number;
onLoadingChange?: (loading: boolean) => void;
}) {
const query = useMemo<H2BiQuery>(
() => ({
year: Number(startDate.slice(0, 4)),
startDate,
endDate,
vehicleScope: toScope(fleetScope),
verifyScope,
stationId,
}),
[endDate, fleetScope, startDate, stationId, verifyScope],
);
const [daily, setDaily] = useState<H2BiDailyResponse | null>(null);
const [reloadToken, setReloadToken] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [trees, setTrees] = useState<Record<string, H2BiDailyTreeResponse>>({});
const [expandedDate, setExpandedDate] = useState<string | null>(null);
const [expandedStation, setExpandedStation] = useState<
Record<string, boolean>
>({});
const [expandedCustomer, setExpandedCustomer] = useState<
Record<string, boolean>
>({});
const [expandedStationLists, setExpandedStationLists] = useState<
Record<string, boolean>
>({});
const [expandedCustomerLists, setExpandedCustomerLists] = useState<
Record<string, boolean>
>({});
const [expandedRecordLists, setExpandedRecordLists] = useState<
Record<string, boolean>
>({});
const [customerRecords, setCustomerRecords] = useState<
Record<string, H2BiDrillResponse>
>({});
const [error, setError] = useState<string | null>(null);
const [highlightedDate, setHighlightedDate] = useState<string | null>(null);
const [detailMode, setDetailMode] = useState<"key" | "full">("key");
const [branchErrors, setBranchErrors] = useState<Record<string, string>>({});
const requestGeneration = useRef(0);
const pendingBranches = useRef(new Set<string>());
const tableWrapRef = useRef<HTMLDivElement>(null);
const dateRowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
useEffect(() => {
let alive = true;
requestGeneration.current += 1;
pendingBranches.current.clear();
setBranchErrors({});
let finishTimer: number | undefined;
const loadingStartedAt = Date.now();
setIsLoading(true);
onLoadingChange?.(true);
// The selected range is a new data contract. Clear the former response so
// an API error can never be mistaken for fresh data or business zeroes.
setDaily(null);
setError(null);
setTrees({});
setExpandedDate(null);
setExpandedStation({});
setExpandedCustomer({});
setExpandedStationLists({});
setExpandedCustomerLists({});
setExpandedRecordLists({});
setCustomerRecords({});
void fetchH2BiDaily(query)
.then((result) => alive && setDaily(result))
.catch(
(reason: unknown) => {
if (!alive) return;
setDaily(null);
setError(
reason instanceof Error ? reason.message : "按日数据加载失败",
);
},
)
.finally(() => {
if (!alive) return;
const remaining = Math.max(0, 500 - (Date.now() - loadingStartedAt));
finishTimer = window.setTimeout(() => {
if (!alive) return;
setIsLoading(false);
onLoadingChange?.(false);
}, remaining);
});
return () => {
alive = false;
requestGeneration.current += 1;
if (finishTimer !== undefined) window.clearTimeout(finishTimer);
};
}, [query, reloadToken, refreshToken, onLoadingChange]);
const handleRefresh = () => {
setReloadToken((value) => value + 1);
onRefresh();
};
const ensureDateTree = (date: string) => {
if (trees[date] || pendingBranches.current.has(date)) return;
const generation = requestGeneration.current;
pendingBranches.current.add(date);
setBranchErrors((items) => ({ ...items, [date]: "" }));
void fetchH2BiDailyTree(date, {
vehicleScope: query.vehicleScope,
verifyScope,
stationId,
}).then((tree) => {
if (generation === requestGeneration.current) setTrees((items) => ({ ...items, [date]: tree }));
}).catch(() => {
if (generation === requestGeneration.current) setBranchErrors((items) => ({ ...items, [date]: "站点明细加载失败,请重试" }));
}).finally(() => {
if (generation === requestGeneration.current) pendingBranches.current.delete(date);
});
};
const openDate = (date: string, scrollIntoDate = false) => {
setExpandedDate(date);
ensureDateTree(date);
if (!scrollIntoDate) return;
setHighlightedDate(date);
window.setTimeout(() => {
dateRowRefs.current[date]?.scrollIntoView({
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
block: "center",
});
}, 40);
window.setTimeout(() => {
setHighlightedDate((current) => (current === date ? null : current));
}, 2200);
};
const toggleDate = (date: string) => {
if (expandedDate === date) {
setExpandedDate(null);
return;
}
openDate(date);
};
const loadCustomer = (
date: string,
stationId: string | number,
customerId: number,
) => {
const key = `${date}:${stationId}:${customerId}`;
if (customerRecords[key] || pendingBranches.current.has(key)) return;
const generation = requestGeneration.current;
pendingBranches.current.add(key);
setBranchErrors((items) => ({ ...items, [key]: "" }));
void fetchH2BiDrill({
...query,
date,
stationId,
customerId,
groupBy: "record",
page: 1,
pageSize: 200,
}).then((result) => {
if (generation === requestGeneration.current) setCustomerRecords((items) => ({ ...items, [key]: result }));
}).catch(() => {
if (generation === requestGeneration.current) setBranchErrors((items) => ({ ...items, [key]: "车辆明细加载失败,请重试" }));
}).finally(() => {
if (generation === requestGeneration.current) pendingBranches.current.delete(key);
});
};
const toggleCustomer = (date: string, station: string | number, customer: number) => {
const key = `${date}:${station}:${customer}`;
setExpandedCustomer((items) => ({ ...items, [key]: !items[key] }));
loadCustomer(date, station, customer);
};
const exportRows = () => {
if (!daily) return;
exportAoaSheet(
dailySummaryRows(daily),
`每日加氢日期汇总_${startDate}_${endDate}.xlsx`,
"日期汇总",
);
};
const trend = daily?.trend ?? [];
const maxKg = Math.max(...trend.map((row) => row.kg), 1);
const averageKg = daily?.kpis.averageDailyKg ?? 0;
const peak = trend.reduce(
(best, row) => (row.kg > best.kg ? row : best),
trend[0],
);
const trough = trend
.filter((row) => row.kg > 0)
.reduce(
(best, row) => (!best || row.kg < best.kg ? row : best),
undefined as (typeof trend)[number] | undefined,
);
return (
<div className="ehb-daily-container">
{isLoading && !daily ? (
<div className="ehb-live-data-state is-loading" role="status" aria-live="polite">
<span className="ehb-live-data-spinner" aria-hidden />
<strong>正在读取真实日期统计</strong>
<span>加载完成前不展示业务零值</span>
</div>
) : null}
<section className="ehb-daily-filter-card">
<div className="ehb-daily-filter-row">
<div className="ehb-daily-filter-group">
<div className="ehb-pill-tabs">
<button
type="button"
className="ehb-pill-btn"
onClick={() => {
const today = endDate;
onStartDateChange(today.slice(0, 8) + "01");
}}
>
本月
</button>
<button
type="button"
className="ehb-pill-btn"
onClick={() => {
const end = new Date(`${endDate}T00:00:00`);
const begin = new Date(end);
begin.setDate(end.getDate() - 14);
onStartDateChange(begin.toISOString().slice(0, 10));
}}
>
近15天
</button>
<button type="button" className="ehb-pill-btn is-active">
自定义
</button>
</div>
<input
className="ehb-modal-select"
type="date"
value={startDate}
onChange={(event) => onStartDateChange(event.target.value)}
/>
<input
className="ehb-modal-select"
type="date"
value={endDate}
onChange={(event) => onEndDateChange(event.target.value)}
/>
</div>
<div className="ehb-daily-filter-group">
<div className="ehb-fleet-segmented">
<button
type="button"
className={`ehb-fleet-btn ${fleetScope === "all" ? "is-active" : ""}`}
onClick={() => onFleetScopeChange("all")}
>
全部车辆
</button>
<button
type="button"
className={`ehb-fleet-btn ${fleetScope === "own" ? "is-active" : ""}`}
onClick={() => onFleetScopeChange("own")}
>
<Truck size={14} />
羚牛车辆
</button>
<button
type="button"
className={`ehb-fleet-btn ${fleetScope === "external" ? "is-active" : ""}`}
onClick={() => onFleetScopeChange("external")}
>
<Truck size={14} />
外部车辆
</button>
</div>
<button
type="button"
className="ehb-btn ehb-btn--ghost"
onClick={handleRefresh}
disabled={isLoading}
aria-busy={isLoading}
>
<RefreshCw size={14} className={isLoading ? "is-spinning" : ""} />
{isLoading ? "加载中…" : "刷新"}
</button>
</div>
</div>
</section>
{error ? <div className="ehb-empty">{error}</div> : null}
{daily ? <>
<section className="ehb-daily-kpi-grid">
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title">区间加氢量</div>
<div className="ehb-daily-kpi-val">
{formatFixed(daily?.kpis.totalKg ?? 0)} <small>Kg</small>
</div>
<div className="ehb-daily-kpi-sub">
{startDate} 至 {endDate}
</div>
</div>
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title">区间成本</div>
<div className="ehb-daily-kpi-val">
¥{formatFixed(daily?.kpis.totalCost ?? 0)}
</div>
<div className="ehb-daily-kpi-sub">真实成本台账汇总</div>
</div>
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title">有效天数</div>
<div className="ehb-daily-kpi-val">{daily?.kpis.activeDays ?? 0}</div>
<div className="ehb-daily-kpi-sub">
日均 {formatFixed(daily?.kpis.averageDailyKg ?? 0)} Kg
</div>
</div>
<div className="ehb-daily-kpi-card">
<div className="ehb-daily-kpi-title">涉及加氢站</div>
<div className="ehb-daily-kpi-val">
{daily?.kpis.stationCount ?? 0} <small>站</small>
</div>
<div className="ehb-daily-kpi-sub">按明细站点去重</div>
</div>
</section>
<section className="ehb-daily-chart-section">
<div className="ehb-daily-chart-head">
<div className="ehb-daily-chart-title">
每日加氢量{" "}
<span className="ehb-title-sub">
(点击柱体下锚定位到对应日期明细)
</span>
</div>
<div className="ehb-daily-chart-meta-group">
<div className="ehb-daily-chart-legend">
<span className="ehb-legend-item">
<span className="ehb-legend-dot is-own" />
内部客户
</span>
<span className="ehb-legend-item">
<span className="ehb-legend-dot is-ext" />
外部客户
</span>
</div>
<span className="ehb-daily-chart-meta">时间单位:日 · 单位 Kg</span>
</div>
</div>
<div className="ehb-daily-summary-pills">
<div className="ehb-daily-pill-item">
<span>峰值日</span>
<strong>{peak ? `${peak.date} ${formatFixed(peak.kg)} Kg` : "—"}</strong>
</div>
<div className="ehb-daily-pill-item">
<span>低谷日</span>
<strong>
{trough ? `${trough.date} ${formatFixed(trough.kg)} Kg` : "—"}
</strong>
</div>
<div className="ehb-daily-pill-item">
<span>零数日</span>
<strong>{trend.filter((row) => row.kg === 0).length} 天</strong>
</div>
</div>
<div className="ehb-daily-bar-container">
<div
className="ehb-daily-avg-line"
style={{
bottom: `${Math.min(92, Math.round((averageKg / maxKg) * 100))}%`,
}}
>
<span className="ehb-daily-avg-label">
均值 {formatFixed(averageKg)} Kg
</span>
</div>
{trend.map((row) => {
const total = row.kg || 1;
const ownRatio = (row.lingniuKg / total) * 100;
const extRatio = (row.externalKg / total) * 100;
const active = expandedDate === row.date;
return (
<button
type="button"
key={row.date}
className="ehb-daily-bar-col"
onClick={() => openDate(row.date, true)}
title={`${row.date} 加氢总量 ${formatFixed(row.kg)} Kg;点击展开并定位当日明细`}
aria-label={`展开并定位${row.date}当日加氢明细`}
>
<div
className="ehb-daily-bar-val"
style={{
color: active ? "#0284c7" : undefined,
fontWeight: active ? 700 : undefined,
}}
>
{Math.round(row.kg)}
</div>
<div
className={`ehb-daily-bar-fill is-stacked ${active ? "is-active" : ""}`}
style={{ height: `${Math.max(0, (row.kg / maxKg) * 100)}%` }}
>
{row.externalKg > 0 ? (
<div
className="ehb-bar-segment is-ext"
style={{ height: `${extRatio}%` }}
/>
) : null}
{row.lingniuKg > 0 ? (
<div
className="ehb-bar-segment is-own"
style={{ height: `${ownRatio}%` }}
/>
) : null}
</div>
<div
className="ehb-daily-bar-label"
style={{
color: active ? "#0284c7" : undefined,
fontWeight: active ? 700 : undefined,
}}
>
{row.date.slice(5)}
</div>
</button>
);
})}
</div>
</section>
</> : null}
<section className="ehb-daily-table-card ehb-real-daily-detail" data-detail-mode={detailMode}>
<div className="ehb-daily-table-head">
<div className="ehb-daily-table-title">
每日加氢数据明细{" "}
<span className="ehb-title-sub">
(可多层下钻:按日 → 加氢站 → 客户 → 车辆及数据源)
</span>
</div>
<button
type="button"
className="ehb-btn ehb-btn--outline ehb-export-btn"
onClick={exportRows}
disabled={!daily || isLoading || !daily.days.length}
title="导出所选区间的全部日期汇总,不含客户和车辆流水"
>
<Download size={14} />
导出日期汇总
</button>
</div>
<div className="ehb-daily-detail-toolbar">
<div className="ehb-daily-view-switch" role="group" aria-label="明细显示方式">
{([['key', '重点指标'], ['full', '完整表格']] as const).map(([mode, label]) =>
<button type="button" key={mode} aria-pressed={detailMode === mode} onClick={() => {
setDetailMode(mode);
if (tableWrapRef.current) tableWrapRef.current.scrollLeft = 0;
}}>{label}</button>)}
</div>
<label className="ehb-daily-date-jump">
<span>定位日期</span>
<select value={expandedDate ?? ""} disabled={!daily?.days.length} onChange={(event) => {
if (event.target.value) openDate(event.target.value, true);
}}>
<option value="">选择日期</option>
{(daily?.days ?? []).map(day => <option key={day.date} value={day.date}>{day.date}</option>)}
</select>
</label>
<button type="button" className="ehb-daily-collapse" disabled={!expandedDate} onClick={() => {
setExpandedDate(null); setExpandedStation({}); setExpandedCustomer({});
setExpandedStationLists({}); setExpandedCustomerLists({}); setExpandedRecordLists({});
}}>全部收起</button>
</div>
<div
className="ehb-h5-scroll-hint ehb-daily-table-scroll-hint"
aria-hidden="true"
>
{detailMode === "key" ? "点击名称逐层查看:日期 → 站点 → 客户 → 车辆" : "首列已固定 · 左右滑动查看全部指标"}
</div>
{!daily ? <div className="ehb-daily-detail-empty" role={error ? "alert" : "status"}>
{error ? <>明细暂时无法加载<button type="button" onClick={handleRefresh}>重新加载</button></> : "正在加载日期明细…"}
</div> : !daily.days.length ? <div className="ehb-daily-detail-empty" role="status">所选日期和车辆范围内暂无加氢记录,请调整筛选条件。</div> :
<div ref={tableWrapRef} className="ehb-table-wrap" role="region" aria-label="每日加氢明细,可左右滚动" tabIndex={0}>
<table className="ehb-table">
<thead>
<tr>
<th>日期 / 明细</th>
<th>单价(元/Kg)</th>
<th>加氢量(Kg)</th>
<th>成本(元) / 环比</th>
<th>预充值余额 / 数据来源</th>
</tr>
</thead>
<tbody>
<tr style={{ background: "#f8fafc", fontWeight: 700 }}>
<td>合计</td>
<td />
<td>{formatFixed(daily?.kpis.totalKg ?? 0)}</td>
<td>¥{formatFixed(daily?.kpis.totalCost ?? 0)}</td>
<td>暂无来源</td>
</tr>
{(daily?.days ?? []).map((day) => {
const tree = trees[day.date];
const open = expandedDate === day.date;
return (
<Fragment key={day.date}>
<tr
ref={(node) => {
dateRowRefs.current[day.date] = node;
}}
id={`daily-row-${day.date}`}
className={`ehb-daily-date-row${highlightedDate === day.date ? " is-highlighted" : ""}`}
style={{
background: open ? "#f0f9ff" : undefined,
}}
>
<td>
<DailyTreeButton open={open} label={`${day.date}加氢站明细`} onClick={() => toggleDate(day.date)}>
{day.date}{" "}
<span className="ehb-title-sub">
({day.stationCount ?? 0} 个加氢站)
</span>
</DailyTreeButton>
</td>
<td>—</td>
<td>{formatFixed(day.kg)}</td>
<td>
<strong className="ehb-daily-cost">{formatFixed(day.cost)}</strong>
<small className="ehb-daily-change">{formatDailyChange((day as { chainPct?: number }).chainPct)}</small>
</td>
<td>暂无来源</td>
</tr>
{open && (!tree || tree.stations.length === 0) ? <DailyBranchState
columns={detailMode === "key" ? 3 : 5} error={branchErrors[day.date]}
empty={!!tree} onRetry={() => ensureDateTree(day.date)} /> : null}
{open &&
tree?.stations
.slice(0, expandedStationLists[day.date] ? undefined : 10)
.map((station) => {
const stationKey = `${day.date}:${station.id}`;
const stationOpen = !!expandedStation[stationKey];
return (
<Fragment key={stationKey}>
<tr
style={{
background: "#f8fafc",
}}
>
<td className="ehb-tree-cell-l1">
<DailyTreeButton open={stationOpen} label={`${station.name}客户明细`} onClick={() =>
setExpandedStation(items => ({ ...items, [stationKey]: !items[stationKey] }))}>
<small className="ehb-daily-level-label">加氢站</small>{station.name}
</DailyTreeButton>
</td>
<td>—</td>
<td>{formatFixed(station.kg)}</td>
<td>¥{formatFixed(station.cost)}</td>
<td>暂无来源</td>
</tr>
{stationOpen && station.customers.length === 0 ? <DailyBranchState
columns={detailMode === "key" ? 3 : 5} empty onRetry={() => {}} /> : null}
{stationOpen &&
station.customers
.slice(
0,
expandedCustomerLists[stationKey]
? undefined
: 10,
)
.map((customer) => {
const customerKey = `${stationKey}:${customer.id}`;
const customerOpen =
!!expandedCustomer[customerKey];
const allRecords =
customerRecords[customerKey]?.records ?? [];
const records = allRecords.slice(
0,
expandedRecordLists[customerKey]
? undefined
: 20,
);
return (
<Fragment key={customerKey}>
<tr>
<td className="ehb-tree-cell-l2">
<DailyTreeButton open={customerOpen} label={`${customer.name}车辆明细`}
onClick={() => toggleCustomer(day.date, station.id, customer.id)}>
<small className="ehb-daily-level-label">客户</small>{customer.name}{" "}
<span className="ehb-title-sub">
({customer.recordCount} 笔)
</span>
</DailyTreeButton>
</td>
<td>—</td>
<td>{formatFixed(customer.kg)}</td>
<td>¥{formatFixed(customer.cost)}</td>
<td>点击查看真实流水</td>
</tr>
{customerOpen && (!customerRecords[customerKey] || allRecords.length === 0) ? <DailyBranchState
columns={detailMode === "key" ? 3 : 5} error={branchErrors[customerKey]}
empty={!!customerRecords[customerKey]} onRetry={() => loadCustomer(day.date, station.id, customer.id)} /> : null}
{customerOpen &&
records.map((record) => (
<tr key={String(record.id)}>
<td className="ehb-tree-cell-l3">
<small className="ehb-daily-level-label">车辆 · {String(record.time || "—").slice(11, 16)}</small>
<strong>
{String(record.plateNo || "无车牌")}
</strong>{" "}
<span
className={`ehb-daily-record-tag ${record.vehicleScope === "lingniu" ? "is-own" : "is-external"}`}
>
{record.vehicleScope === "lingniu"
? "羚牛车辆"
: "外部车辆"}
</span>
<span
className="ehb-daily-record-tag is-source"
title={String(record.source || "未知来源")}
>
{sourceLabel(record.source)}
</span>
<span
className={`ehb-daily-record-tag ${verifyLabel(record.verifyStatus) === "已验证" ? "is-verified" : "is-unverified"}`}
title={verifyLabel(record.verifyStatus)}
>
{verifyLabel(record.verifyStatus)}
</span>
</td>
<td>
{formatFixed(
Number(record.unitPrice ?? 0),
)}
</td>
<td>
{formatFixed(Number(record.kg ?? 0))}
</td>
<td>
¥{formatFixed(Number(record.cost ?? 0))}
</td>
<td>暂无预充值余额</td>
</tr>
))}
{customerOpen && allRecords.length > 20 ? (
<tr className="ehb-daily-tree-more-row">
<td colSpan={detailMode === "key" ? 3 : 5}>
<button
type="button"
className="ehb-daily-tree-more-btn"
onClick={(event) => {
event.stopPropagation();
setExpandedRecordLists((items) => ({
...items,
[customerKey]: !items[customerKey],
}));
}}
>
{expandedRecordLists[customerKey]
? "收起车辆明细"
: `更多车辆明细(还有 ${allRecords.length - 20} 笔)`}
</button>
</td>
</tr>
) : null}
</Fragment>
);
})}
{stationOpen && station.customers.length > 10 ? (
<tr className="ehb-daily-tree-more-row">
<td colSpan={detailMode === "key" ? 3 : 5}>
<button
type="button"
className="ehb-daily-tree-more-btn"
onClick={(event) => {
event.stopPropagation();
setExpandedCustomerLists((items) => ({
...items,
[stationKey]: !items[stationKey],
}));
}}
>
{expandedCustomerLists[stationKey]
? "收起客户"
: `更多客户(还有 ${station.customers.length - 10} 个)`}
</button>
</td>
</tr>
) : null}
</Fragment>
);
})}
{open && tree && tree.stations.length > 10 ? (
<tr className="ehb-daily-tree-more-row">
<td colSpan={detailMode === "key" ? 3 : 5}>
<button
type="button"
className="ehb-daily-tree-more-btn"
onClick={(event) => {
event.stopPropagation();
setExpandedStationLists((items) => ({
...items,
[day.date]: !items[day.date],
}));
}}
>
{expandedStationLists[day.date]
? "收起加氢站"
: `更多加氢站(还有 ${tree.stations.length - 10} 个)`}
</button>
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</div>}
</section>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,189 +0,0 @@
/* Scoped to the live daily table; keep the other boards and drill dialogs intact. */
.ehb-real-daily-detail {
min-width: 0;
}
.ehb-daily-detail-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 16px;
margin: 12px 0;
}
.ehb-daily-view-switch {
display: inline-flex;
padding: 3px;
background: #f1f5f9;
border-radius: 9px;
}
.ehb-real-daily-detail button,
.ehb-real-daily-detail select { font: inherit; }
.ehb-daily-view-switch button,
.ehb-daily-collapse,
.ehb-daily-branch-state button,
.ehb-daily-detail-empty button {
min-height: 40px;
border: 0;
padding: 8px 12px;
border-radius: 7px;
background: transparent;
color: #475569;
cursor: pointer;
font-size: 13px;
}
.ehb-daily-view-switch button[aria-pressed="true"] {
background: #fff;
color: #1d4ed8;
box-shadow: 0 1px 4px #0f172a14;
font-weight: 600;
}
.ehb-daily-date-jump { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #64748b; }
.ehb-daily-date-jump select { min-height: 40px; border: 1px solid #e2e8f0; border-radius: 7px; padding: 6px 8px; background: #fff; color: #334155; }
.ehb-real-daily-detail button:disabled { opacity: .45; cursor: default; }
.ehb-real-daily-detail :is(button, select, [tabindex]):focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.ehb-real-daily-detail .ehb-daily-disclosure {
display: flex;
align-items: flex-start;
gap: 5px;
width: 100%;
min-height: 44px;
padding: 6px 0;
border: 0;
background: none;
color: inherit;
text-align: left;
line-height: 1.5;
cursor: pointer;
}
.ehb-daily-disclosure > svg { flex-shrink: 0; margin-top: 3px; color: #64748b; }
.ehb-daily-disclosure > span { min-width: 0; }
.ehb-daily-disclosure[aria-expanded="true"] { font-weight: 600; }
.ehb-daily-disclosure[aria-expanded="true"] > svg { color: #2563eb; }
.ehb-daily-level-label,
.ehb-daily-change { display: block; font-size: 11px; font-weight: 400; color: #64748b; line-height: 1.6; }
.ehb-daily-cost { font-weight: 500; }
.ehb-daily-branch-state > td > div { display: flex; align-items: center; gap: 8px; }
.ehb-daily-branch-state button { display: inline-flex; align-items: center; gap: 5px; color: #1d4ed8; background: #eff6ff; }
.ehb-daily-detail-empty { padding: 24px 16px; text-align: center; color: #64748b; font-size: 13px; }
.ehb-daily-detail-empty button { display: block; margin: 8px auto 0; color: #1d4ed8; }
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table { min-width: 0; }
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td):is(:nth-child(2), :nth-child(5)) { display: none; }
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td):first-child:not([colspan]) { width: 36%; }
.ehb-real-daily-detail .ehb-table td:not(:first-child) { text-align: right; }
.ehb-real-daily-detail .ehb-table th:not(:first-child) { text-align: right; }
.ehb-real-daily-detail .ehb-table-wrap {
isolation: isolate;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
.ehb-real-daily-detail .ehb-table {
table-layout: fixed;
min-width: 940px;
}
.ehb-real-daily-detail .ehb-table tr {
background-color: #fff;
}
.ehb-real-daily-detail .ehb-table th:first-child,
.ehb-real-daily-detail .ehb-table td:first-child:not([colspan]) {
position: sticky;
left: 0;
z-index: 2;
width: 260px;
background-color: inherit;
box-shadow: 1px 0 0 #e2e8f0, 5px 0 8px -6px #64748b;
overflow-wrap: anywhere;
white-space: normal;
}
/* Give nested day → station → customer → vehicle rows a viewport-sized workspace. */
.ehb-real-daily-detail.ehb-daily-table-card > .ehb-table-wrap {
height: 80vh;
height: 80dvh;
max-height: none;
min-height: 0;
overflow: auto;
}
.ehb-real-daily-detail .ehb-table th:first-child {
z-index: 4;
background-color: #f8fafc;
}
.ehb-real-daily-detail .ehb-table th {
z-index: 3;
}
.ehb-real-daily-detail .ehb-table td:not(:first-child) {
font-variant-numeric: tabular-nums;
}
@media (max-width: 767px), (max-width: 1024px) and (max-height: 500px) {
.ehb-real-daily-detail.ehb-daily-table-card {
padding: 12px 0 0;
overflow: hidden;
}
.ehb-real-daily-detail .ehb-daily-table-head {
padding: 0 12px;
gap: 8px;
align-items: flex-start;
}
.ehb-real-daily-detail .ehb-daily-table-title {
font-size: 15px;
line-height: 1.5;
}
.ehb-real-daily-detail .ehb-daily-table-title .ehb-title-sub {
display: none;
}
.ehb-real-daily-detail .ehb-export-btn {
display: inline-flex;
flex-shrink: 0;
min-height: 44px;
padding: 6px 8px;
font-size: 12px;
}
.ehb-real-daily-detail .ehb-daily-detail-toolbar { padding: 0 12px; gap: 8px; }
.ehb-real-daily-detail .ehb-daily-view-switch { width: 100%; }
.ehb-real-daily-detail .ehb-daily-view-switch button { flex: 1; min-height: 44px; }
.ehb-real-daily-detail .ehb-daily-date-jump { flex: 1; }
.ehb-real-daily-detail .ehb-daily-date-jump select { min-height: 44px; }
.ehb-real-daily-detail .ehb-daily-collapse { min-height: 44px; padding: 6px; font-size: 12px; }
.ehb-real-daily-detail .ehb-daily-table-scroll-hint {
display: block;
padding: 8px 12px;
font-size: 11px;
color: #64748b;
background: #f8fafc;
}
.ehb-real-daily-detail .ehb-table-wrap {
max-height: 65dvh;
}
.ehb-real-daily-detail .ehb-table {
min-width: 760px;
font-size: 13px;
}
.ehb-real-daily-detail .ehb-table th:first-child,
.ehb-real-daily-detail .ehb-table td:first-child:not([colspan]) {
width: 142px;
}
.ehb-real-daily-detail .ehb-table td {
height: 48px;
padding: 10px 12px;
line-height: 1.5;
box-sizing: border-box;
}
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table { min-width: 0; width: 100%; }
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table :is(th, td) { padding: 10px 8px; }
.ehb-real-daily-detail[data-detail-mode="key"] .ehb-table td:not(:first-child) { font-size: 12px; overflow-wrap: anywhere; }
.ehb-real-daily-detail .ehb-table td:first-child:has(.ehb-daily-disclosure) { padding-top: 4px; padding-bottom: 4px; }
.ehb-real-daily-detail .ehb-table th {
padding: 10px 12px;
white-space: normal;
line-height: 1.5;
}
.ehb-real-daily-detail .ehb-table td:first-child .ehb-title-sub {
display: block;
margin-left: 0;
}
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l1 { padding-left: 16px; }
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l2 { padding-left: 22px; }
.ehb-real-daily-detail .ehb-table td.ehb-tree-cell-l3 { padding-left: 28px; }
.ehb-real-daily-detail .ehb-daily-record-tag {
display: inline-block;
margin: 2px 3px 0 0;
}
}
@@ -1,93 +0,0 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -1,10 +0,0 @@
# JetBrains 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。
@@ -1,45 +0,0 @@
/**
* OneOS V2 · JetBrains Mono(自托管)
* 版本:2.304 · 许可:SIL OFL 1.1(见同目录 OFL.txt)
* 禁止改回 Google Fonts CDN:内网 / Windows 无外网时必须本地 woff2。
*/
@font-face {
font-family: 'JetBrains Mono';
src: url('./JetBrainsMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('./JetBrainsMono-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('./JetBrainsMono-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('./JetBrainsMono-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('./JetBrainsMono-ExtraBold.woff2') format('woff2');
font-weight: 800;
font-style: normal;
font-display: swap;
}
@@ -1,37 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import test from "node:test";
const here = path.dirname(fileURLToPath(import.meta.url));
/** 抽出文件里指向某个模块的 import 说明符(支持静态 import 与动态 import())。 */
function importedSpecifier(source: string, moduleName: string): string | null {
const patterns = [
new RegExp(`from\\s*["']([^"']*${moduleName})["']`),
new RegExp(`import\\(\\s*["']([^"']*${moduleName})["']\\s*\\)`),
];
for (const re of patterns) {
const match = source.match(re);
if (match) return match[1];
}
return null;
}
test("能源氢费 BI 入口与独立验收地址复用同一看板", () => {
const entryPath = path.join(here, "index.tsx");
const appPath = path.join(here, "..", "..", "..", "App.tsx");
const entry = readFileSync(entryPath, "utf8");
const app = readFileSync(appPath, "utf8");
const entrySpec = importedSpecifier(entry, "EnergyBiBoardApp");
const appSpec = importedSpecifier(app, "EnergyBiBoardApp");
assert.ok(entrySpec, "氢费 BI 入口必须引用 EnergyBiBoardApp");
assert.ok(appSpec, "App.tsx 的独立验收路由必须引用 EnergyBiBoardApp");
// 不锁定具体路径,只锁定"两个入口解析到同一个文件",避免目录调整造成无意义漂移。
const resolve = (spec: string, from: string) => path.resolve(path.dirname(from), spec);
assert.equal(resolve(entrySpec!, entryPath), resolve(appSpec!, appPath));
assert.match(entry, /return <EnergyBiBoardApp \/>/);
});
-9
View File
@@ -1,9 +0,0 @@
import { EnergyBiBoardApp } from './board/EnergyBiBoardApp';
/**
* The accepted hydrogen fee BI surface. It is shared with the independent
* acceptance route so the menu entry and /energy/hydrogen-board cannot drift.
*/
export default function HydrogenModule() {
return <EnergyBiBoardApp />;
}
@@ -1,18 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { bearingLabels } from "./bearing-labels";
test("records display their ledger bearing type", () => {
assert.deepEqual(bearingLabels({ settlementType: 1 }).map(x => x.label), ["客户承担"]);
assert.deepEqual(bearingLabels({ settlementType: "2" }).map(x => x.label), ["我司承担"]);
assert.deepEqual(bearingLabels({ settlementType: 3 }).map(x => x.label), ["客户自行结算"]);
});
test("groups display every distinct bearing type, including unknown", () => {
assert.deepEqual(bearingLabels({ settlementTypes: "1,2,3,unknown,1" }).map(x => x.label),
["客户承担", "我司承担", "客户自行结算", "未明确"]);
});
test("missing and unrecognized values do not imply an actual payer", () => {
for (const settlementType of [null, undefined, "", 4, "all"]) {
assert.deepEqual(bearingLabels({ settlementType }).map(x => x.label), ["未明确"]);
}
});
@@ -1,13 +0,0 @@
const labels: Record<string, { label: string; className: string }> = {
"1": { label: "客户承担", className: "is-cust" },
"2": { label: "我司承担", className: "is-lingniu" },
"3": { label: "客户自行结算", className: "is-other" },
};
// Use ledger settlement types, never the selected filter or monetary amounts.
export function bearingLabels(row: { settlementTypes?: unknown; settlementType?: unknown }) {
const types = String(row.settlementTypes ?? row.settlementType ?? "")
.split(",").map((value) => value.trim());
const results = types.map((value) => labels[value] ?? { label: "未明确", className: "is-other" });
return [...new Map(results.map((item) => [item.label, item])).values()];
}
@@ -1,15 +0,0 @@
import type { H2BiDailyResponse } from "../types";
export function formatDailyChange(value: unknown): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "环比 —";
return `环比 ${value > 0 ? "+" : ""}${value.toFixed(2)}%`;
}
/** Export the entire selected range, independently of which branches were opened. */
export function dailySummaryRows(daily: H2BiDailyResponse): Array<Array<string | number>> {
return [
["日期", "加氢站数", "加氢量(Kg)", "成本(元)"],
["区间合计", daily.kpis.stationCount, daily.kpis.totalKg, daily.kpis.totalCost],
...daily.days.map(day => [day.date, day.stationCount ?? "—", day.kg, day.cost]),
];
}
@@ -1,19 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { formatFixed, isFiniteNumberValue } from "./display-format";
test("默认口径:缺失值按 0 展示,与氢能明细表既有行为一致", () => {
assert.equal(formatFixed(0), "0.00");
assert.equal(formatFixed(null), "0.00");
assert.equal(formatFixed(undefined), "0.00");
assert.equal(formatFixed(1234.5), "1,234.50");
assert.equal(formatFixed(1234.5, 0), "1,235");
});
test("显式开启 blankForMissing 时区分真实零值与不可用值", () => {
for (const value of [null, undefined, NaN, Infinity, -Infinity, "0"]) {
assert.equal(isFiniteNumberValue(value), false);
assert.equal(formatFixed(value, 2, { blankForMissing: true }), "—");
}
assert.equal(formatFixed(0, 2, { blankForMissing: true }), "0.00");
});
@@ -1,33 +0,0 @@
/**
* 氢能看板的数值格式化。
*
* 此前两个下钻视图各自复制了一份同样的实现(共 42 处调用),另有一份
* 无人使用的 "—" 版本只被自己的测试引用。这里收敛为唯一实现,并把
* "缺失值是否显示为 0" 变成显式选项,而不是靠不同的函数名区分口径。
*/
export interface FormatOptions {
/**
* 缺失或不可用(非有限数)时返回 "—" 而不是 0。
* 默认 false:明细表按 0 展示,与既有氢能账本口径一致;
* 需要区分"真实零值"与"接口未返回"时显式开启。
*/
blankForMissing?: boolean;
}
/** 该值本身是否为可参与计算的有限数字(不把 null / undefined / "0" 视为数字)。 */
export function isFiniteNumberValue(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
/**
* 固定小数位的千分位文案。
* 默认路径与既有实现逐字一致:`Number(value ?? 0)` 后按固定小数位格式化。
*/
export function formatFixed(value: unknown, digits = 2, options: FormatOptions = {}): string {
if (options.blankForMissing && !isFiniteNumberValue(value)) return '—';
return Number(value ?? 0).toLocaleString('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
@@ -1,63 +0,0 @@
# 加氢站日报 · 产品需求说明(PRD)
> 原型路径:`src/prototypes/energy-h2-station-daily`
> 口令:`lingniu`(轻门禁)
> 设计:能源 BI / 经营看板皮(非 OneOS V2 台账)
> 对外:只叙事、禁听众标签、禁说明书墙
> 作者:OneOS
> 日期:2026-08-12
> **2026-08-13**:去掉总览堆积「加氢量占比」条;占比改到各站概况行内「加氢量占比」(含进度条);各站按区间加氢量从高到低排序
---
## 0. 定位与边界
| 项 | 口径 |
|---|---|
| 名称 | 加氢站日报 |
| 职责 | 查询区间总览 → 钻取单站明细;现结区展示进账事实 |
| 不做 | 现结登记办理;经营看板三维度;台账列表总览;「单站驾驶舱」表述;总览页导出 |
| 与经营看板 | 可嵌入经营看板「单站」;也可独立打开。**嵌入时看板标题旁不展示查询区间**(维度不同) |
### 视图决策
| 决策 | 结论 | 理由 |
|---|---|---|
| 总览形态 | Hero KPI + 各站单卡片(行内加氢量占比) | 经营读数与下钻;占比不下单独堆积条 |
| 各站概况 | **单卡片**;默认「全部」,右上可切「单站」;**按区间加氢量降序** | 本尊 2026-08-12 / 2026-08-13 |
| 台账列表总览 | **不做** | 缺经营感 |
| 明细表集 | 日汇总 → 区间趋势 → 客户月量/费 →(收支∥现结明细) | 页长可控 |
| 车辆明细 | **不外挂**;仅导出取证派生 | 外层过长 |
| 时间维度 | **起止日期**(精确到日)+ 本日/本周/本月快捷;下方 KPI/站卡按区间重算 | 本尊 2026-08-12 |
| 看板嵌入 | 单站模式**不显示**经营看板顶栏时间芯片 | 查询维度不同 |
| 空数值 | 写 `0` | 本尊 2026-08-12 |
| 听众标签 | **禁止**进页面 | 只说事不对人 |
---
## 1. 用户故事
- **起点:** 打开日报 / 看板「单站」→ 选查询起止日期。
- **怎么运作:** KPI「统计加氢总量 / 统计加氢量 / 统计现结金额」可点开日明细;各站行内看「加氢量占比」与进度条;点站卡进站明细。
- **闭环:** 钻取页可导出 `.xlsx`;总览无导出。
---
## 2. 验收
1. 口令 `lingniu`。
2. 看板单站模式:标题旁**无**查询区间芯片;页面无「单站驾驶舱」文案。
3. 查询日期为起止选择器,含本日/本周/本月;改区间后 KPI/站卡同步变化。
4. 加氢站副文案为「统计站点数」;三个 KPI 可钻取日表(条数=区间天数)。
5. **无**总览堆积占比条;各站行有「加氢量占比」数字 + 进度条。
6. 各站概况为单卡片,右上「全部 / 单站」切换;全部列表按区间加氢量从高到低。
---
## 3. 关联
- 现结登记:`energy-spot-cash-intake`
- 经营看板:`energy-h2-bi-board`
- 共享现结:`oneos-energy-spot-cash-intake-v3`
- 加氢客户(外部)/ 外部车辆:`energy-h2-external-customer` · `energy-h2-external-vehicle`(外部量仅统计,不进车辆氢费明细)
- 量侧实站(禁造站):南海 + 东鹏大道(未提供 Excel 不上 mock)
@@ -1,128 +0,0 @@
/**
* 客户多选(能源 BI 皮 · 禁 V2)
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, X } from 'lucide-react';
export const SdCustomerMultiSelect: React.FC<{
options: string[];
value: string[];
onChange: (next: string[]) => void;
label?: string;
}> = ({ options, value, onChange, label = '客户' }) => {
const [open, setOpen] = useState(false);
const [q, setQ] = useState('');
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
const filtered = useMemo(() => {
const key = q.trim();
if (!key) return options;
return options.filter((n) => n.includes(key));
}, [options, q]);
const allSelected = value.length === 0 || value.length === options.length;
const triggerText = allSelected
? '全部客户'
: value.length <= 2
? value.join('、')
: `已选 ${value.length} 家`;
const toggle = (name: string) => {
if (value.length === 0) {
// 从「全部」切入:只留当前点中
onChange([name]);
return;
}
if (value.includes(name)) {
const next = value.filter((n) => n !== name);
onChange(next.length === 0 ? [] : next);
return;
}
const next = [...value, name];
onChange(next.length === options.length ? [] : next);
};
return (
<div className={`sd-msel ${open ? 'is-open' : ''}`} ref={rootRef}>
<button
type="button"
className="sd-msel__trigger"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<span className="sd-msel__label">{label}</span>
<span className="sd-msel__value" title={triggerText}>
{triggerText}
</span>
<ChevronDown size={14} aria-hidden className="sd-msel__chev" />
</button>
{open ? (
<div className="sd-msel__panel" role="listbox" aria-multiselectable>
<div className="sd-msel__search">
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索客户"
aria-label="搜索客户"
/>
{q ? (
<button type="button" className="sd-msel__clear" onClick={() => setQ('')} aria-label="清空搜索">
<X size={12} />
</button>
) : null}
</div>
<div className="sd-msel__actions">
<button type="button" onClick={() => onChange([])}>
全选
</button>
<button
type="button"
onClick={() => {
onChange([]);
setOpen(false);
}}
>
重置
</button>
</div>
<ul className="sd-msel__list">
{filtered.length === 0 ? (
<li className="sd-msel__empty">无匹配客户</li>
) : (
filtered.map((name) => {
const checked = allSelected || value.includes(name);
return (
<li key={name}>
<button
type="button"
className={`sd-msel__opt ${checked ? 'is-on' : ''}`}
role="option"
aria-selected={checked}
onClick={() => toggle(name)}
>
<span className="sd-msel__check" aria-hidden>
{checked ? <Check size={12} /> : null}
</span>
<span className="sd-msel__name" title={name}>{name}</span>
</button>
</li>
);
})
)}
</ul>
</div>
) : null}
</div>
);
};
@@ -1,263 +0,0 @@
/**
* 站日报 · 起止日期区间(精确到日)+ 本日/本周/本月快捷
* 能源 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<string, { start: string; end: string }> {
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<PickTarget>('start');
const [draftStart, setDraftStart] = useState(start);
const [draftEnd, setDraftEnd] = useState(end);
const rootRef = useRef<HTMLDivElement>(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 (
<div className={`sd-date sd-date--range ${align === 'right' ? 'sd-date--right' : ''}`} ref={rootRef}>
<button
type="button"
className={`sd-date__trigger ${open ? 'is-open' : ''}`}
onClick={() => setOpen((v) => !v)}
aria-haspopup="dialog"
aria-expanded={open}
>
<span className="sd-date__label">{label}</span>
<span className="sd-date__value">
{displayYmd(start)} 至 {displayYmd(end)}
</span>
<Calendar size={15} aria-hidden className="sd-date__icon" />
</button>
{open ? (
<div className="sd-date__popover sd-date__popover--range" role="dialog" aria-label={label}>
<div className="sd-date__shortcuts">
<button type="button" onClick={() => applyShortcut('today')}>
本日
</button>
<button type="button" onClick={() => applyShortcut('week')}>
本周
</button>
<button type="button" onClick={() => applyShortcut('month')}>
本月
</button>
</div>
<div className="sd-date__range-tabs">
<button
type="button"
className={picking === 'start' ? 'is-on' : ''}
onClick={() => setPicking('start')}
>
开始 {displayYmd(draftStart)}
</button>
<button
type="button"
className={picking === 'end' ? 'is-on' : ''}
onClick={() => setPicking('end')}
>
结束 {displayYmd(draftEnd)}
</button>
</div>
<div className="sd-date__header">
<button type="button" className="sd-date__nav" onClick={goPrev} aria-label="上一月">
<ChevronLeft size={16} />
</button>
<div className="sd-date__title">
{viewYear}年{pad2(viewMonth)}月
</div>
<button type="button" className="sd-date__nav" onClick={goNext} aria-label="下一月">
<ChevronRight size={16} />
</button>
</div>
<div className="sd-date__week">
{['日', '一', '二', '三', '四', '五', '六'].map((w) => (
<span key={w}>{w}</span>
))}
</div>
<div className="sd-date__grid">
{blanks.map((i) => (
<span key={`b-${i}`} className="sd-date__day is-empty" />
))}
{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 (
<button key={d} type="button" className={cls} onClick={(e) => pickDay(d, e)}>
{d}
</button>
);
})}
</div>
<div className="sd-date__footer sd-date__footer--range">
<button type="button" className="sd-date__today" onClick={() => applyShortcut('today')}>
本日
</button>
<button type="button" className="sd-btn sd-btn--primary sd-date__apply" onClick={applyDraft}>
确定
</button>
</div>
</div>
) : null}
</div>
);
};
@@ -1,531 +0,0 @@
// @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 (
<div className="sd-station-row__trend" aria-label="近7日加氢趋势">
<span className="sd-station-row__trend-title">近7日趋势</span>
<div className="sd-station-row__trend-bars">
{recent.map((v) => (
<div
key={v.date}
className={`sd-station-row__trend-col ${v.quantityKg === 0 ? 'is-zero' : ''}`}
aria-label={`${v.date},加氢量 ${kg(v.quantityKg)} Kg`}
>
<span className="sd-station-row__trend-tooltip" role="tooltip">
<strong>{v.date}</strong>
<em>{kg(v.quantityKg)} Kg</em>
</span>
<span
className="sd-station-row__trend-bar-slot"
style={{ '--trend-bar-height': v.quantityKg === 0 ? '0%' : `${(v.quantityKg / peak) * 100}%` } as React.CSSProperties}
>
<span className="sd-station-row__trend-value">
{v.quantityKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<i style={{ height: v.quantityKg === 0 ? '0' : `${(v.quantityKg / peak) * 100}%` }} />
</span>
<small>{v.date.slice(5)}</small>
</div>
))}
{recent.length === 0 ? <span className="sd-station-row__trend-empty">暂无趋势数据</span> : null}
</div>
</div>
);
}
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<StationDailyAppProps> = ({
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<string | null>(null);
// 初值不用伪造时间戳;成功回调会写入真实 latestLedgerTime,缺数据时与之一致。
const [updatedAt, setUpdatedAt] = useState('暂无账本更新时间');
const [tick, setTick] = useState(0);
const [kpiDrill, setKpiDrill] = useState<KpiDrill>(null);
const [liveBoard, setLiveBoard] = useState<HydrogenStationBoardResponse | null>(null);
const [liveError, setLiveError] = useState<string | null>(null);
const [liveLoading, setLiveLoading] = useState(true);
const [stationCashBoard, setStationCashBoard] = useState<HydrogenStationBoardResponse[] | null>(null);
const [stationCashError, setStationCashError] = useState<string | null>(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<HTMLElement>('.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<string, { date: string; kg: number; amt: number; vehicles: number }>();
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<string, number>();
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 = (
<StationDailyDetailView
stationId={drillStationId}
asOf={endDate}
rangeStart={startDate}
rangeEnd={endDate}
updatedAt={updatedAt}
onBack={() => setDrillStationId(null)}
onRefresh={handleRefresh}
onRangeChange={({ start, end }) => {
setStartDate(start);
setEndDate(end);
}}
/>
);
if (embedded) {
return <div className="sd-embedded" data-annotation-id="energy-h2-station-daily-embedded">{detail}</div>;
}
return (
<div className="ehb-shell ehb-shell--station-daily" data-annotation-id="energy-h2-station-daily">
<div className="ehb-body sd-body">{detail}</div>
</div>
);
}
const hasCash = boardRows.some((r) => r.cashDays > 0);
const dayCount = calendarDays(startDate, endDate).length;
const cockpit = (
<>
<header className={`sd-topbar ${embedded ? 'sd-topbar--embedded' : ''}`}>
{!embedded ? (
<div className="sd-topbar__lead">
<p className="sd-topbar__kicker">羚牛氢能 · 加氢站经营</p>
<h1 className="sd-topbar__title">加氢站日报</h1>
<p className="sd-topbar__updated">最后更新时间 {updatedAt}</p>
</div>
) : null}
{!embedded ? <div className="sd-topbar__tools">
<SdDateRangePicker
label="查询日期"
start={startDate}
end={endDate}
anchorYmd={endDate}
onChange={({ start, end }) => {
setStartDate(start);
setEndDate(end);
}}
/>
<button type="button" className="sd-btn sd-btn--ghost" onClick={handleRefresh}>
<RefreshCw size={15} aria-hidden />
刷新
</button>
</div> : null}
</header>
<section className="sd-mobile-operating-overview" aria-label="经营概览">
<div className="sd-mobile-operating-overview__head">
<div>
<span>经营概览</span>
<small>{startDate} 至 {endDate}</small>
</div>
<strong>{boardRows.length} 个站点</strong>
</div>
<div className="sd-mobile-operating-overview__source">
数据来源:加氢业务账本;现结金额来自加氢站收款流水
</div>
<div className="sd-mobile-operating-overview__summary">
<button type="button" className="sd-mobile-operating-overview__primary" onClick={() => setKpiDrill('volumeTotal')}>
<span>统计加氢总量</span>
<strong>{kg(totals.rangeKg)}<small> Kg</small></strong>
<em><Fuel size={13} aria-hidden />{totals.endVehicles} 车次</em>
</button>
<div className="sd-mobile-operating-overview__financials">
<div><span>统计金额</span><strong>¥{money(totals.rangeAmt)}</strong></div>
<button type="button" onClick={() => setKpiDrill('cashDays')}>
<span>现结金额</span><strong>¥{money(totals.cashTotal)}</strong>
</button>
</div>
</div>
</section>
<div className="sd-hero-kpis" aria-label="全站核心指标">
<div className="sd-hero-kpi">
<div className="sd-hero-kpi__label">加氢站</div>
<div className="sd-hero-kpi__value">{boardRows.length}</div>
<div className="sd-hero-kpi__sub">统计站点数</div>
<div className="sd-hero-kpi__source">来源:加氢业务账本(保留范围内无加氢记录的站点)<br />区间:{startDate} 至 {endDate}</div>
</div>
<button type="button" className="sd-hero-kpi sd-hero-kpi--accent sd-hero-kpi--click" onClick={() => setKpiDrill('volumeTotal')}>
<div className="sd-hero-kpi__label">统计加氢总量</div>
<div className="sd-hero-kpi__value">
{kg(totals.rangeKg)}
<span className="sd-unit">Kg</span>
</div>
<div className="sd-hero-kpi__sub">
{totals.endVehicles} 车次 · 点按查看日明细
</div>
<div className="sd-hero-kpi__source">来源:加氢业务账本(有效加氢记录按加氢量求和)<br />区间:{startDate} 至 {endDate}</div>
</button>
<button type="button" className="sd-hero-kpi sd-hero-kpi--click" onClick={() => setKpiDrill('volumeDays')}>
<div className="sd-hero-kpi__label">加氢车次</div>
<div className="sd-hero-kpi__value">
{totals.endVehicles}
<span className="sd-unit">车次</span>
</div>
<div className="sd-hero-kpi__sub">{dayCount} 天 · 日均 {(totals.endVehicles / Math.max(dayCount, 1)).toFixed(1)} 车次</div>
<div className="sd-hero-kpi__source">来源:加氢业务账本(有效记录数)<br />区间:{startDate} 至 {endDate}</div>
</button>
<button type="button" className="sd-hero-kpi sd-hero-kpi--click" onClick={() => setKpiDrill('cashDays')}>
<div className="sd-hero-kpi__label">统计现结金额</div>
<div className="sd-hero-kpi__value">
{money(totals.cashTotal)}
<span className="sd-unit">元</span>
</div>
<div className="sd-hero-kpi__sub">{cashDaily ? (hasCash ? `${cashDaily.filter((d) => d.amount > 0).length} 天有进账` : '0 天有进账') : '正在读取当前站点流水'}</div>
<div className="sd-hero-kpi__source">来源:加氢站收款流水(已入账金额)<br />区间:{startDate} 至 {endDate}</div>
</button>
</div>
{liveLoading && !liveBoard ? (
<div className="sd-live-loading-overlay" role="status" aria-live="polite">
<span className="sd-live-loading-spinner" aria-hidden />
<strong>正在读取单站真实统计数据</strong>
<span>加载完成前不展示业务零值</span>
</div>
) : liveLoading ? <div className="ehb-empty">正在更新,暂时保留上一份有效数据…</div> : null}
{liveError ? <div className="ehb-empty">单站统计加载失败:{liveError}</div> : null}
<section className="sd-station-board" aria-label="站点经营概况" data-mobile-fullscreen-list>
<div className="sd-section-head">
<h2 className="sd-section-title">站点经营概况</h2>
<span className="sd-station-board__fullscreen">
<MobileListFullscreenButton label="横屏全屏查看站点经营概况" />
</span>
<span className="sd-panel__meta">全部自营站 · {boardRows.length} 站</span>
</div>
<div className="sd-station-single-card">
{boardRows.map((r) => (
<button
key={r.id}
type="button"
className="sd-station-row is-solo"
onClick={() => openStation(r.id)}
>
<div className="sd-station-row__main">
<span className="sd-station-card__icon" aria-hidden>
<Fuel size={18} />
</span>
<div className="sd-station-row__id">
<div className="sd-station-card__name">{r.name}</div>
{r.endVehicles === 0 ? <div className="sd-station-card__metric-source">当前期间无加氢记录</div> : null}
<div className="sd-station-card__region">
<MapPin size={12} aria-hidden />
{r.region}
</div>
</div>
<div className="sd-station-row__metrics">
<div>
<span className="sd-station-card__m-label">加氢量</span>
<strong>
{kg(r.rangeKg)}
<span>Kg</span>
</strong>
<small className="sd-station-card__metric-source">加氢业务账本 · {startDate} 至 {endDate}</small>
</div>
<div>
<span className="sd-station-card__m-label">车次</span>
<strong>{r.endVehicles}</strong>
</div>
<div>
<span className="sd-station-card__m-label">金额</span>
<strong>¥{money(r.rangeAmt)}</strong>
</div>
</div>
<StationTrend vols={r.vols} />
<span className="sd-station-card__go" aria-hidden>
<ArrowUpRight size={16} />
</span>
</div>
</button>
))}
</div>
</section>
{kpiDrill ? (
<div className="sd-kpi-modal" role="dialog" aria-modal="true">
<button type="button" className="sd-kpi-modal__mask" aria-label="关闭" onClick={() => setKpiDrill(null)} />
<div className="sd-kpi-modal__panel">
<div className="sd-kpi-modal__head">
<h3>
{kpiDrill === 'volumeTotal' && '统计加氢总量 · 日明细'}
{kpiDrill === 'volumeDays' && '统计加氢量 · 单日数据'}
{kpiDrill === 'cashDays' && '统计现结金额 · 单日数据'}
</h3>
<span className="sd-kpi-modal__meta">
{startDate} 至 {endDate}
</span>
<button type="button" className="sd-btn sd-btn--ghost" onClick={() => setKpiDrill(null)}>
<X size={14} aria-hidden />
关闭
</button>
</div>
<div className="sd-table-scroll">
{kpiDrill === 'cashDays' && cashDaily ? (
<table className="sd-bi-table sd-bi-table--fill">
<thead>
<tr>
<th>日期</th>
<th className="is-num">现结金额(元)</th>
</tr>
</thead>
<tbody>
{cashDaily.map((d) => (
<tr key={d.date}>
<td className="is-mono">{d.date}</td>
<td className="is-num">{money(d.amount)}</td>
</tr>
))}
</tbody>
</table>
) : kpiDrill === 'cashDays' ? (
<div className="ehb-empty" role="status">
{stationCashError ? `当前站点现结流水加载失败:${stationCashError}` : '正在读取当前站点现结流水…'}
</div>
) : (
<table className="sd-bi-table sd-bi-table--fill">
<thead>
<tr>
<th>日期</th>
<th className="is-num">加氢车次</th>
<th className="is-num">加氢量(Kg)</th>
<th className="is-num">加氢金额(元)</th>
</tr>
</thead>
<tbody>
{dailyAgg.map((d) => (
<tr key={d.date}>
<td className="is-mono">{d.date}</td>
<td className="is-num">{d.vehicles}</td>
<td className="is-num">{kg(d.kg)}</td>
<td className="is-num">{money(d.amt)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
) : null}
</>
);
if (embedded) {
return <div className="sd-embedded" data-annotation-id="energy-h2-station-daily-embedded">{cockpit}</div>;
}
return (
<div className="ehb-shell ehb-shell--station-daily" data-annotation-id="energy-h2-station-daily">
<div className="ehb-body sd-body">{cockpit}</div>
</div>
);
};
@@ -1,884 +0,0 @@
// @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 (
<span className="sd-delta" aria-hidden>
{curr > prev ? '▲' : '▼'}
</span>
);
}
function MoreToggle({
expanded,
total,
onToggle,
}: {
expanded: boolean;
total: number;
onToggle: () => void;
}) {
if (total <= ROW_LIMIT) return null;
return (
<button type="button" className="sd-more-btn" onClick={onToggle}>
{expanded ? (
<>
收起
<ChevronUp size={14} aria-hidden />
</>
) : (
<>
更多(还有 {total - ROW_LIMIT} 条)
<ChevronDown size={14} aria-hidden />
</>
)}
</button>
);
}
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<string[]>([]);
const [hoverDate, setHoverDate] = useState<string | null>(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<string>('');
const [orderDate, setOrderDate] = useState<string | null>(null);
const [liveBoard, setLiveBoard] = useState<HydrogenStationBoardResponse | null>(null);
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const exportController = useRef<AbortController | null>(null);
const [liveError, setLiveError] = useState<string | null>(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<string, Record<string, number>>();
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<string, Record<string, number>>();
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<string, number>,
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 (
<td key={m} className={`is-num ${cls} ${zeroClass} ${currentMonthClass}`}>
{fmt(curr)}
<DeltaMark curr={curr} prev={prev} />
</td>
);
});
if (liveLoading || liveError) {
return (
<div className="sd-detail" data-annotation-id="station-daily-detail">
<header className="sd-detail-top">
<div className="sd-detail-top__lead">
<button type="button" className="sd-btn sd-btn--ghost" onClick={onBack} aria-label="返回上一级">
<ArrowLeft size={15} aria-hidden />
返回
</button>
<div>
<h1 className="sd-detail-top__title">{stationName}</h1>
<p className="sd-detail-top__meta">{startDate} 至 {end}</p>
</div>
</div>
</header>
<div className="ehb-empty" role={liveError ? 'alert' : 'status'}>
{liveError ? `站点详情加载失败:${liveError}` : '正在读取当前站点真实统计数据,完成前不展示业务零值'}
</div>
</div>
);
}
return (
<div className="sd-detail sd-detail--mobile-pilot" data-annotation-id="station-daily-detail">
<header className="sd-detail-top">
<div className="sd-detail-top__lead">
<button type="button" className="sd-btn sd-btn--ghost" onClick={onBack}>
<ArrowLeft size={15} aria-hidden />
返回
</button>
<div>
<h1 className="sd-detail-top__title">{stationName}</h1>
<p className="sd-detail-top__meta">
{region ? `${region} · ` : ''}
{startDate} 至 {end}
</p>
<p className="sd-detail-top__updated">最后更新时间 {updatedAt}</p>
</div>
</div>
<div className="sd-detail-top__tools">
<SdDateRangePicker
label="查询日期"
start={rangeStart}
end={rangeEnd}
anchorYmd={asOf}
onChange={onRangeChange}
/>
<button
type="button"
className="sd-btn sd-btn--ghost"
onClick={() => {
onRefresh();
setCashTick((n) => n + 1);
}}
>
<RefreshCw size={16} aria-hidden />
刷新
</button>
<button type="button" className="sd-btn sd-btn--primary ehb-hide-h5" disabled={liveLoading || Boolean(liveError) || exporting} onClick={handleExport}>
<Download size={15} aria-hidden />
{exporting ? '正在读取全部记录…' : '导出取证'}
</button>
{exporting ? <button type="button" className="sd-btn sd-btn--ghost" onClick={() => exportController.current?.abort()}>取消导出</button> : null}
</div>
</header>
{exportError ? <div role="alert" className="ehb-empty">导出失败:{exportError}。未生成文件,请缩小日期范围或重试。</div> : null}
{liveError ? (
<div className="ehb-empty" role="alert">站点详情加载失败:{liveError}</div>
) : null}
<div className="sd-hero-kpis sd-hero-kpis--detail">
<div className="sd-hero-kpi sd-hero-kpi--accent">
<div className="sd-hero-kpi__label">当日加氢</div>
<div className={`sd-hero-kpi__value ${stockDeltaClass(asOfVolume?.quantityKg ?? 0, prevDayKg)}`}>
{asOfVolume ? kg(asOfVolume.quantityKg) : '0.00'}
<span className="sd-unit">Kg</span>
<DeltaMark curr={asOfVolume?.quantityKg ?? 0} prev={prevDayKg} />
</div>
<div className="sd-hero-kpi__sub">
{asOfVolume
? `¥${money(asOfVolume.amountYuan)} · ${asOfVolume.vehicleCount} 车次`
: '¥0.00 · 0 车次'}
</div>
</div>
<div className="sd-hero-kpi">
<div className="sd-hero-kpi__label">查询区间加氢</div>
<div className="sd-hero-kpi__value">
{kg(rangeKg)}
<span className="sd-unit">Kg</span>
</div>
<div className="sd-hero-kpi__sub">¥{money(rangeAmt)} · {dateRangeLabel(startDate, end)}</div>
</div>
<div className="sd-hero-kpi">
<div className="sd-hero-kpi__label">区间内本月加氢</div>
<div className="sd-hero-kpi__value">
{kg(monthKg)}
<span className="sd-unit">Kg</span>
</div>
<div className="sd-hero-kpi__sub">{asOf.slice(0, 7)} · 仅计入查询区间</div>
</div>
<div className="sd-hero-kpi">
<div className="sd-hero-kpi__label">单站现结流水</div>
<div className="sd-hero-kpi__value">
{money(cashTotal)}
<span className="sd-unit">元</span>
</div>
<div className="sd-hero-kpi__sub">
{cashDays.length ? `${cashDays.length} 天有进账 · ${dateRangeLabel(startDate, end)}` : `0 天有进账 · ${dateRangeLabel(startDate, end)}`}
<br />不含客户级充值/进账
</div>
</div>
</div>
<div className="sd-mobile-detail-hub" data-mobile-fullscreen-list>
<header className="sd-mobile-detail-tabs">
<div className="sd-mobile-detail-tabs__title">
<span>经营明细</span>
<MobileListFullscreenButton label="横屏全屏查看经营明细" placement="inline" />
</div>
<div className="sd-mobile-detail-tabs__rail" role="tablist" aria-label="经营明细类型">
{([
['daily', '日加氢'],
['customer', '客户月度'],
['balance', '收支'],
['cash', '进账'],
] as const).map(([key, label]) => (
<button
key={key}
type="button"
role="tab"
aria-selected={mobileDetailTab === key}
className={mobileDetailTab === key ? 'is-active' : ''}
onClick={() => setMobileDetailTab(key)}
>
{label}
</button>
))}
</div>
</header>
<section className={`sd-panel sd-panel--block sd-mobile-detail-panel ${mobileDetailTab === 'daily' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
<h2 className="sd-panel__title">加氢站每日加氢量汇总(近 7 日)</h2>
<MobileDailyList key={`${stationId}|${rangeStart}|${rangeEnd}`}
rows={volume7} allRows={volumeRows} loading={liveLoading} error={liveError}
onOpenOrders={setOrderDate} />
<div className="sd-table-scroll">
<table className="sd-bi-table sd-bi-table--fill">
<thead>
<tr>
<th>日期</th>
<th className="is-num">加氢量(Kg)</th>
<th className="is-num">较昨日</th>
<th className="is-num">单价</th>
<th className="is-num">金额(元)</th>
<th className="is-num">车次</th>
</tr>
</thead>
<tbody>
<tr className="is-total">
<td>近 7 日合计</td>
<td className="is-num">{kg(volume7Kg)}</td>
<td className="is-num">0</td>
<td className="is-num">0</td>
<td className="is-num">{money(volume7Amt)}</td>
<td className="is-num">{volume7Vehicles}</td>
</tr>
{volume7.length === 0 ? (
<tr>
<td colSpan={6} className="ehb-empty-cell">
本窗暂无加氢量
</td>
</tr>
) : (
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 (
<tr key={r.date}>
<td>{padYmd(r.date)}</td>
<td className={`is-num ${cls}`}>
{kg(r.quantityKg)}
<DeltaMark curr={r.quantityKg} prev={prevFull} />
</td>
<td className={`is-num ${cls}`}>
{diff == null ? '0.00' : `${diff > 0 ? '+' : ''}${kg(diff)}`}
</td>
<td className="is-num">{r.unitPrice}</td>
<td className="is-num">{money(r.amountYuan)}</td>
<td className="is-num">{r.vehicleCount}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</section>
<section className="sd-panel sd-panel--block sd-mobile-trend-panel">
<div className="sd-panel__head-row">
<h2 className="sd-panel__title">区间加氢量趋势</h2>
<span className="sd-trend-legend-chip" aria-hidden>
<i className="sd-trend-legend-dot" />
{stationName}
</span>
</div>
<div
className="sd-trend sd-trend--fill"
role="img"
aria-label="区间加氢量趋势"
onMouseLeave={() => setHoverDate(null)}
>
{volumeRows.length === 0 ? (
<div className="ehb-empty-cell" style={{ padding: 24 }}>
本窗暂无趋势
</div>
) : (
volumeRows.map((r) => (
<div
key={r.date}
className={`sd-trend__col ${hoverDate === r.date ? 'is-hover' : ''}`}
onMouseEnter={() => setHoverDate(r.date)}
>
<div className="sd-trend__val">{r.quantityKg.toFixed(0)}</div>
<div className="sd-trend__bar-wrap">
<div
className="sd-trend__bar"
style={{ height: `${Math.max(8, (r.quantityKg / maxKg) * 100)}%` }}
/>
</div>
<div className="sd-trend__date">
<span className="sd-trend__date--desktop">{stationTrendDateLabel(r.date)}</span>
<span className="sd-trend__date--mobile">{stationTrendDateLabel(r.date).slice(5)}</span>
</div>
</div>
))
)}
{hoverRow ? (
<div className="sd-trend-tip" role="tooltip">
<div className="sd-trend-tip__date">{padYmd(hoverRow.date)}</div>
<div className="sd-trend-tip__row">
<i className="sd-trend-legend-dot" />
<span className="sd-trend-tip__name">{stationName}</span>
<strong>
{kg(hoverRow.quantityKg)} Kg · ¥{money(hoverRow.amountYuan)}
</strong>
</div>
<div className="sd-trend-tip__sub">{hoverRow.vehicleCount} 车次</div>
</div>
) : null}
</div>
</section>
<section className={`sd-panel sd-panel--block sd-mobile-detail-panel ${mobileDetailTab === 'customer' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
<div className="sd-panel__head-row">
<div className="sd-customer-month-head">
<h2 className="sd-panel__title">客户月度汇总(近 12 个月)</h2>
<div className="sd-customer-month-tabs" role="tablist" aria-label="客户月度汇总指标">
<button
type="button"
role="tab"
aria-selected={customerMonthlyMetric === 'volume'}
className={customerMonthlyMetric === 'volume' ? 'is-active' : ''}
onClick={() => setCustomerMonthlyMetric('volume')}
>
加氢量(Kg)
</button>
<button
type="button"
role="tab"
aria-selected={customerMonthlyMetric === 'fee'}
className={customerMonthlyMetric === 'fee' ? 'is-active' : ''}
onClick={() => setCustomerMonthlyMetric('fee')}
>
加氢费(元)
</button>
</div>
</div>
<SdCustomerMultiSelect
options={customerOptions}
value={selectedCustomers}
onChange={setSelectedCustomers}
/>
</div>
<p className="sd-panel__meta">自营站全部车辆与客户,包含羚牛及外部车辆;未关联客户保留统计,不含手工充值。</p>
<MobileCustomerMonthList key={stationId} months={customerMonthKeys}
volumeCustomers={allCustCells} feeCustomers={allFeeCells}
month={mobileMonthKey} onMonthChange={setMobileMonthKey}
metric={customerMonthlyMetric} onMetricChange={setCustomerMonthlyMetric}
loading={liveLoading} error={liveError || (!customerMonthRows ? '客户数据暂不可用' : null)} />
<div className="sd-table-scroll sd-table-scroll--matrix sd-desktop-matrix-table">
<table className={`sd-bi-table sd-bi-table--matrix ${customerMonthlyMetric === 'fee' ? 'is-amount' : ''}`}>
<thead>
<tr>
<th>客户</th>
{customerMonthKeys.map((m, i) => (
<th
key={m}
className={`is-num ${i === customerMonthKeys.length - 1 ? 'is-current-month' : ''}`}
>
{customerMonthLabel(m)}
</th>
))}
</tr>
</thead>
<tbody>
<tr className="is-total">
<td>合计</td>
{customerMonthlyMetric === 'volume'
? renderMonthCells(kgMonthTot, kg)
: renderMonthCells(feeMonthTot, money)}
</tr>
{(customerMonthlyMetric === 'volume' ? custCells : feeCells).length === 0 ? (
<tr>
<td colSpan={1 + customerMonthKeys.length} className="ehb-empty-cell">
{!customerMonthRows ? '客户数据暂不可用' : '无匹配客户'}
</td>
</tr>
) : (
(customerMonthlyMetric === 'volume' ? custVisible : feeVisible).map((c) => (
<tr key={c.customerName}>
<td>{c.customerName}</td>
{renderMonthCells(c.months, customerMonthlyMetric === 'volume' ? kg : money)}
</tr>
))
)}
</tbody>
</table>
</div>
<div className="sd-table-scroll sd-mobile-combined-fullscreen-table">
<table className="sd-bi-table sd-bi-table--ledger">
<thead><tr><th>客户</th><th className="is-num">加氢量(Kg)</th><th className="is-num">加氢费(元)</th></tr></thead>
<tbody>
{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 <tr key={customer.customerName}><td>{customer.customerName}</td><td className={`is-num ${businessValueClass(volumeValue)}`}>{kg(volumeValue)}</td><td className={`is-num ${businessValueClass(feeValue)}`}>{money(feeValue)}</td></tr>;
})}
</tbody>
</table>
</div>
<MoreToggle
expanded={expandCust}
total={(customerMonthlyMetric === 'volume' ? custCells : feeCells).length}
onToggle={() => setExpandCust((v) => !v)}
/>
</section>
<div className="sd-dual sd-dual--cash sd-dual--ledger">
<section className={`sd-panel sd-mobile-detail-panel ${mobileDetailTab === 'balance' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
<h2 className="sd-panel__title">客户氢费收支汇总</h2>
<p className="sd-panel__meta">完整账户余额未接入:新进账表不能单独推算余额或经营利润。</p>
<div className="sd-mobile-record-list sd-mobile-balance-list">
{balVisible.map((customer) => (
<article key={customer.customerName} className="sd-mobile-record">
<div className="sd-mobile-record__lead"><strong>{customer.customerName}</strong><span>{customer.remark || '账户正常'}</span></div>
<div className="sd-mobile-record__value"><strong className={businessValueClass(customer.balanceYuan, true)}>¥{money(customer.balanceYuan)}</strong><span>余额</span></div>
<div className="sd-mobile-record__meta">充值 ¥{money(customer.rechargeOrSpotYuan)} · 扣预付 ¥{money(customer.consumePrepaidYuan)} · 现结 ¥{money(customer.consumeSpotYuan)}</div>
</article>
))}
</div>
<div className="sd-table-scroll">
<table className="sd-bi-table sd-bi-table--ledger">
<thead>
<tr>
<th>客户</th>
<th className="is-num">充值/现金结算</th>
<th className="is-num">扣预付</th>
<th className="is-num">现结</th>
<th className="is-num">余额</th>
<th>备注</th>
</tr>
</thead>
<tbody>
{balanceRows.length === 0 ? (
<tr>
<td colSpan={6} className="ehb-empty-cell">
暂无收支汇总
</td>
</tr>
) : (
<>
{balVisible.map((b) => (
<tr key={b.customerName}>
<td title={b.customerName}>{b.customerName}</td>
<td className={`is-num ${businessValueClass(b.rechargeOrSpotYuan)}`}>
{money(b.rechargeOrSpotYuan)}
</td>
<td className={`is-num ${businessValueClass(b.consumePrepaidYuan)}`}>
{money(b.consumePrepaidYuan)}
</td>
<td className={`is-num ${businessValueClass(b.consumeSpotYuan)}`}>
{money(b.consumeSpotYuan)}
</td>
<td className={`is-num ${businessValueClass(b.balanceYuan, true)}`}>
{money(b.balanceYuan)}
</td>
<td>{b.remark || ''}</td>
</tr>
))}
<tr className="is-total">
<td>小计</td>
<td className={`is-num ${businessValueClass(balanceSubtotal.recharge)}`}>
{money(balanceSubtotal.recharge)}
</td>
<td className={`is-num ${businessValueClass(balanceSubtotal.prepaid)}`}>{money(balanceSubtotal.prepaid)}</td>
<td className={`is-num ${businessValueClass(balanceSubtotal.spot)}`}>{money(balanceSubtotal.spot)}</td>
<td className={`is-num ${businessValueClass(balanceSubtotal.balance, true)}`}>
{money(balanceSubtotal.balance)}
</td>
<td />
</tr>
</>
)}
</tbody>
</table>
</div>
<MoreToggle expanded={expandBal} total={balanceRows.length} onToggle={() => setExpandBal((v) => !v)} />
</section>
<section className={`sd-panel sd-mobile-detail-panel sd-external-receipts ${mobileDetailTab === 'cash' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
<div className="sd-panel__head-row sd-external-receipt-head">
<h2 className="sd-panel__title">外部客户充值/现结进账</h2>
<span className="sd-panel__meta">{liveBoard?.selected?.externalReceipts ? `合计 ¥${money(receiptTotal)} · ${cashLines.length} 条` : '金额暂不可用'}</span>
</div>
<p className="sd-panel__meta">当前租户全部外部客户,不按站点归属。现结自动汇总已包含在加氢业务中,不重复计入单站现结或收益;充值也不等于利润。</p>
{!cashLines.length ? <p className="sd-panel__meta">{liveBoard?.selected?.externalReceipts ? '查询区间暂无客户进账记录' : '客户进账数据暂不可用:接口未返回新数据,请刷新或检查服务版本。'}</p> : null}
<div className="sd-mobile-record-list sd-mobile-cash-list">
{cashVisible.map((line) => (
<article key={line.id} className="sd-mobile-record">
<div className="sd-mobile-record__lead"><strong>{line.customerName}</strong><span>{line.bizDate}</span></div>
<div className="sd-mobile-record__value"><strong className={businessValueClass(line.amount)}>¥{money(line.amount)}</strong><span className="sd-mobile-pay-tag">{line.payLabel}</span></div>
<div className="sd-mobile-record__meta">{line.sourceLabel} · {line.sourceRecordCount} 条源记录{line.updatedAt ? ` · 更新 ${line.updatedAt}` : ''}</div>
</article>
))}
</div>
<div className="sd-table-scroll sd-table-scroll--cash-lines">
<table className="sd-bi-table sd-bi-table--ledger">
<thead>
<tr>
<th>充值日期</th>
<th>客户</th>
<th>付款方式</th>
<th>来源 / 源记录数</th>
<th className="is-num">金额(元)</th>
</tr>
</thead>
<tbody>
{cashLines.length === 0 ? (
<tr>
<td colSpan={5} className="ehb-empty-cell">
{liveBoard?.selected?.externalReceipts ? '本窗暂无进账明细' : '客户进账数据暂不可用'}
</td>
</tr>
) : (
cashVisible.map((l) => (
<tr key={l.id}>
<td className="is-mono">{l.bizDate}</td>
<td title={l.customerName}>{l.customerName}</td>
<td>{l.payLabel}</td>
<td>{l.sourceLabel} / {l.sourceRecordCount}</td>
<td className={`is-num ${businessValueClass(l.amount)}`}>{money(l.amount)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
<MoreToggle expanded={expandCash} total={cashLines.length} onToggle={() => setExpandCash((v) => !v)} />
</section>
</div>
</div>
{orderDate ? <PrototypeDrillModal kind="records"
label={`${stationName} · ${orderDate} 加氢订单`}
query={{ year: Number(orderDate.slice(0, 4)), startDate: orderDate, endDate: orderDate,
date: orderDate, stationId, vehicleScope: 'all', verifyScope: 'all' }}
onClose={() => setOrderDate(null)} /> : null}
</div>
);
};
@@ -1,104 +0,0 @@
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 <section className="sd-mobile-list-pilot" aria-busy="true">日报正在加载…</section>;
if (error) return <section className="sd-mobile-list-pilot sd-mobile-list__state is-error" role="alert">日报加载失败:{error}</section>;
if (!dailyRows.length) return <section className="sd-mobile-list-pilot sd-mobile-list__state">暂无日报数据</section>;
return <section className="sd-mobile-list-pilot" aria-label="近七日日报">
<p className="sd-mobile-list__hint">最新日期在前 · 点日期展开车次、单价和订单</p>
<div className="sd-mobile-list__head sd-mobile-daily__head"><span>日期</span><span>加氢量</span><span>金额</span></div>
<div className="sd-mobile-list__rows">
{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 <details className="sd-mobile-list__detail" key={row.date}>
<summary className="sd-mobile-daily__row">
<span><strong>{row.date.slice(5)}</strong><small>{row.date.slice(0, 4)}</small></span>
<span className={hasRecord ? '' : 'is-zero'}><strong>{formatKg(row.quantityKg)}</strong><small>kg</small></span>
<span className={hasRecord ? '' : 'is-zero'}><strong>¥{formatMoney(row.amountYuan)}</strong>{!hasRecord && <small>无记录</small>}</span>
</summary>
<div className="sd-mobile-list__expanded">
<span>车次:{row.vehicleCount} 次</span><span>单价:¥{formatMoney(row.unitPrice)}/kg</span><span>较昨日:{delta}</span>
<button type="button" onClick={() => onOpenOrders(row.date)}>查看当天订单</button>
</div>
</details>;
})}
</div>
</section>;
}
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 <section className="sd-mobile-list-pilot" aria-busy="true">客户月度数据正在加载…</section>;
if (error) return <section className="sd-mobile-list-pilot sd-mobile-list__state is-error" role="alert">客户月度加载失败:{error}</section>;
return <section className="sd-mobile-list-pilot" aria-label="客户月度排行">
<div className="sd-mobile-customer__controls">
<select aria-label="选择月份" value={month} onChange={(event) => onMonthChange(event.target.value)}>
{months.map((item) => <option value={item} key={item}>{monthLabel(item)}</option>)}
</select>
<div className="sd-mobile-customer__metric" aria-label="选择指标">
<button type="button" aria-pressed={metric === 'volume'} className={metric === 'volume' ? 'is-active' : ''} onClick={() => onMetricChange('volume')}>数量</button>
<button type="button" aria-pressed={metric === 'fee'} className={metric === 'fee' ? 'is-active' : ''} onClick={() => onMetricChange('fee')}>金额</button>
</div>
<strong>{monthLabel(month)}合计:{metric === 'volume' ? `${formatKg(total)} kg` : `¥${formatMoney(total)}`}</strong>
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索全部客户" aria-label="搜索全部客户" />
</div>
<p className="sd-mobile-list__hint">点客户展开近 12 个月 · 合计不随搜索缩减</p>
{!sortedCustomers.length ? <p className="sd-mobile-list__state">{search ? '未找到匹配客户' : '该月暂无客户数据'}</p> : <div className="sd-mobile-list__rows">
{sortedCustomers.map((customer) => <details className="sd-mobile-list__detail sd-mobile-customer__detail" key={customer.customerName}>
<summary><span>{customer.customerName}</span><strong className={customerMetricValue(customer, month) === 0 ? 'is-zero' : ''}>{metric === 'volume' ? `${formatKg(customerMetricValue(customer, month))} kg` : `¥${formatMoney(customerMetricValue(customer, month))}`}</strong></summary>
<div className="sd-mobile-customer__trend" aria-label={`${customer.customerName}十二个月趋势`}>
{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 <div key={item}><span>{item}</span><i aria-hidden style={{ width }} className={value === 0 ? 'is-zero' : ''} /><strong>{metric === 'volume' ? `${formatKg(value)} kg` : `¥${formatMoney(value)}`}</strong></div>;
})}
</div>
</details>)}
</div>}
</section>;
}
File diff suppressed because it is too large Load Diff
@@ -1,36 +0,0 @@
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');
});
@@ -1,63 +0,0 @@
/** 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<string, number>;
}
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;
}
@@ -1,37 +0,0 @@
/* 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; }
}
@@ -1,22 +0,0 @@
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);
});
@@ -1,37 +0,0 @@
/**
* 客户月度接口固定返回“截至查询结束日的近 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}`;
}
File diff suppressed because it is too large Load Diff
-297
View File
@@ -1,297 +0,0 @@
export type H2BiScope = "global" | "station";
export type H2BiView = "overview" | "daily";
export type H2BiVehicleScope = "all" | "lingniu" | "external";
export type H2BiVerifyScope = "all" | "verified" | "unverified";
/** 账本 settlement_type 的承担口径;下钻、KPI 与趋势图必须使用同一口径。 */
export type H2BiAmountScope = "all" | "customer" | "company" | "other";
export type H2BiRegionGranularity = "province" | "city";
export interface H2BiQuery {
year: number;
startDate?: string;
endDate?: string;
date?: string;
month?: string;
stationId?: string | number | null;
customerId?: number | null;
customerName?: string | null;
plateNo?: string | null;
region?: string | null;
regionGranularity?: H2BiRegionGranularity;
vehicleScope: H2BiVehicleScope;
verifyScope: H2BiVerifyScope;
}
export interface H2BiRange {
startDate: string | null;
endDate: string | null;
}
export interface H2BiWatermark {
ledgerAt: string | null;
paymentAt: string | null;
}
export interface H2BiStationOption {
id: string | number;
name: string;
}
export interface H2BiMetaResponse {
years: Array<{ value: number; startDate: string; endDate: string }>;
stations: H2BiStationOption[];
watermark: H2BiWatermark;
}
export interface H2BiKpis {
totalKg: number;
totalCost: number;
/** 加氢量:账本 settlement_type=1(客户承担)。 */
customerBearingKg: number;
/** 加氢量:账本 settlement_type=2(我司承担)。 */
companyBearingKg: number;
/** 加氢量:其他承担方式(含客户自行结算、未知)。 */
otherBearingKg: number;
/** 对客金额:账本 settlement_type=1 的 fee_total。 */
customerRevenue: number;
/** 成本金额:账本 settlement_type=1 的 cost_total。 */
customerCost: number;
/** 成本金额:账本 settlement_type=2 的 cost_total。 */
companyCost: number;
/** 成本金额:其他承担方式的 cost_total。 */
otherCost: number;
totalRevenue: number;
customerGrossProfit: number;
monthKg: number;
monthCost: number;
todayKg: number;
todayCost: number;
monthShareOfRange: number;
todayShareOfMonth: number;
recordCount: number;
stationCount: number;
}
export interface H2BiMonthlyPoint {
month: string;
totalKg: number;
lingniuKg: number;
externalKg: number;
cost: number;
customerCost: number;
companyCost: number;
otherCost: number;
revenue: number;
customerRevenue: number;
customerGrossProfit: number;
}
export interface H2BiRegionRow {
region: string;
kg: number;
share: number;
}
export interface H2BiStationRow {
rank?: number;
id: string | number | null;
name: string;
province: string | null;
city: string | null;
kg: number;
lingniuKg: number;
externalKg: number;
cost: number;
revenue: number;
/** 对客金额:账本 settlement_type=1 的 fee_total。 */
customerRevenue: number;
customerCost: number;
companyCost: number;
otherCost: number;
recordCount: number;
customerCount: number;
share: number;
}
export interface H2BiCustomerRow {
rank?: number;
id: number | null;
name: string;
kg: number;
/** 加氢订单中账本 settlement_type=1(客户承担)的量。 */
customerBearingKg: number;
/** 加氢订单中账本 settlement_type=2(我司承担)的量。 */
companyBearingKg: number;
/** 其他承担方式(含客户自行结算、未知)的量。 */
otherBearingKg: number;
bearer: "company" | "customer" | "both" | "other";
cost: number;
revenue: number;
customerRevenue: number;
customerCost: number;
companyCost: number;
otherCost: number;
recordCount: number;
}
export interface H2BiOverviewResponse {
range: H2BiRange;
watermark: H2BiWatermark;
filters: Partial<H2BiQuery>;
kpis: H2BiKpis;
monthly: H2BiMonthlyPoint[];
topStations: H2BiStationRow[];
regions: H2BiRegionRow[];
stations: H2BiStationRow[];
customers: H2BiCustomerRow[];
}
export interface H2BiDailyPoint {
date: string;
kg: number;
lingniuKg: number;
externalKg: number;
cost: number;
recordCount: number;
}
export interface H2BiDailyRow extends H2BiDailyPoint {
stationCount?: number;
}
export interface H2BiDailyKpis {
totalKg: number;
totalCost: number;
averageDailyKg: number;
stationCount: number;
activeDays: number;
}
export interface H2BiDailyResponse {
range: H2BiRange;
watermark: H2BiWatermark;
filters: Partial<H2BiQuery>;
kpis: H2BiDailyKpis;
trend: H2BiDailyPoint[];
days: H2BiDailyRow[];
}
export type H2BiDrillMetric =
| "totalKg"
| "totalCost"
| "totalRevenue"
| "customerGrossProfit"
| "monthKg"
| "todayKg"
| "station"
| "customer"
| "day";
export type H2BiDrillGroupBy =
"station" | "customer" | "date" | "vehicle" | "record";
export interface H2BiDrillQuery extends H2BiQuery {
groupBy: H2BiDrillGroupBy;
amountScope?: H2BiAmountScope;
customerId?: number | null;
plateNo?: string | null;
page?: number;
pageSize?: number;
}
export interface H2BiDrillSummary {
label?: string;
value?: number;
count?: number;
[key: string]: string | number | null | undefined;
}
export type H2BiDrillRecord = Record<
string,
string | number | boolean | null | undefined
>;
export interface H2BiDrillGroupRow {
/** Distinct ledger settlement types for this group; never the UI filter. */
settlementTypes?: string | null;
[key: string]: string | number | boolean | null | undefined;
id: string;
name: string;
province: string | null;
city: string | null;
recordCount: number;
stationCount: number;
customerCount: number;
kg: number;
cost: number;
revenue: number;
lingniuKg: number;
externalKg: number;
}
export interface H2BiDrillResponse {
groupBy: H2BiDrillGroupBy;
amountScope: H2BiAmountScope;
filters: Partial<H2BiQuery>;
summary: H2BiDrillSummary;
groups: H2BiDrillGroupRow[];
records: H2BiDrillRecord[];
page: {
page: number;
pageSize: number;
/** Number of rows returned on this page (groups or records). */
itemCount?: number;
hasMore: boolean;
};
}
/** Options shared by paged drill reads and explicit full exports. */
export interface H2BiDrillReadOptions {
signal?: AbortSignal;
}
/**
* A deliberately bounded, complete drill read. This is for exports and
* dedicated detail views only; normal drill tables should use one page.
*/
export interface H2BiFullDrillResponse extends H2BiDrillResponse {
page: H2BiDrillResponse["page"] & {
pagesRead: number;
complete: true;
};
}
export interface H2BiFullDrillOptions extends H2BiDrillReadOptions {
/** Protect the browser from an accidental unbounded export. Default: 250. */
maxPages?: number;
/** Reject instead of retaining an unexpectedly huge complete result. Default: 50000. */
maxRows?: number;
/** API page size, capped by the server at 200. Default: 200. */
pageSize?: number;
}
export interface H2BiDailyTreeCustomer {
id: number;
name: string;
kg: number;
cost: number;
recordCount: number;
}
export interface H2BiDailyTreeStation {
id: string | number;
name: string;
kg: number;
cost: number;
recordCount: number;
customers: H2BiDailyTreeCustomer[];
}
export interface H2BiDailyTreeResponse {
date: string;
stations: H2BiDailyTreeStation[];
}
export type H2BiDailyTreeQuery = Omit<
Partial<H2BiQuery>,
"date" | "startDate" | "endDate" | "month"
>;
+65 -67
View File
@@ -1,79 +1,77 @@
/** 能源模块(电能 / ETC / 单站日报)共用的 DTO 与筛选枚举。 */
export type CustomerType = 'all' | 'external' | 'lingniu';
export type CustomerType = 'external' | 'lingniu';
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
export interface HydrogenStationBoardStation {
id: number;
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;
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 HydrogenStationBoardDailyRow {
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 {
date: string;
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;
totalKg: number;
chainPct: number;
customerType: CustomerType;
stations: HydrogenStationRow[];
}
export interface ElectricKpi {
@@ -1,6 +1,5 @@
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;
@@ -11,6 +10,19 @@ type Props = {
onMapClick: (longitude: number, latitude: number) => void;
};
type AmapInstance = {
Map: new (container: HTMLElement, options: Record<string, unknown>) => any;
HeatMap: new (map: any, options: Record<string, unknown>) => any;
ToolBar: new (options?: Record<string, unknown>) => any;
Scale: new (options?: Record<string, unknown>) => 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;
@@ -50,9 +62,36 @@ export default function HydrogenAmapCanvas({ config, points, max, metric, focusQ
async function initialize() {
try {
const AMap = await loadAmap(config);
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;
if (cancelled || !container) return;
const { map, heatmap } = createHeatmapMap(AMap, container, { radius: 34, opacity: [0.14, 0.84] });
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',
},
});
map.on('click', (event: any) => clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat()));
mapRef.current = map;
heatmapRef.current = heatmap;
@@ -4,12 +4,10 @@ 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';
// 接口返回数据水位前的兜底范围:近 30 天滚动窗口(不再写死历史区间)。
const FALLBACK_RANGE = recentDayRange(30);
const DEFAULT_START = '2026-01-01';
const DEFAULT_END = '2026-07-13';
const integerFormat = new Intl.NumberFormat('zh-CN');
const kgFormat = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 });
@@ -39,8 +37,8 @@ export default function HydrogenHeatmapModule() {
const [config, setConfig] = useState<AmapConfig | null>(null);
const [data, setData] = useState<HydrogenHeatmapResponse | null>(null);
const [nearby, setNearby] = useState<HydrogenNearbyResponse | null>(null);
const [startDate, setStartDate] = useState(FALLBACK_RANGE.start);
const [endDate, setEndDate] = useState(FALLBACK_RANGE.end);
const [startDate, setStartDate] = useState(DEFAULT_START);
const [endDate, setEndDate] = useState(DEFAULT_END);
const [query, setQuery] = useState('');
const [payer, setPayer] = useState<HydrogenPayer>('all');
const [metric, setMetric] = useState<HydrogenHeatmapMetric>('kg');
@@ -114,8 +112,8 @@ export default function HydrogenHeatmapModule() {
}, [deferredQuery, endDate, metric, payer, startDate]);
const reset = () => {
setStartDate(meta?.startDate || FALLBACK_RANGE.start);
setEndDate(meta?.endDate || FALLBACK_RANGE.end);
setStartDate(meta?.startDate || DEFAULT_START);
setEndDate(meta?.endDate || DEFAULT_END);
setQuery('');
setPayer('all');
setMetric('kg');
@@ -166,7 +164,7 @@ export default function HydrogenHeatmapModule() {
<Metric value={integerFormat.format(data?.stationCount ?? meta?.eligibleStationCount ?? 0)} label="站点" />
</div>
<div className="flex items-center gap-3">
<p className="flex items-center gap-2 text-[12px] text-slate-500 max-xl:hidden"><Clock3 size={15} strokeWidth={1.7} />数据更新至 {meta?.endDate || FALLBACK_RANGE.end}</p>
<p className="flex items-center gap-2 text-[12px] text-slate-500 max-xl:hidden"><Clock3 size={15} strokeWidth={1.7} />数据更新至 {meta?.endDate || DEFAULT_END}</p>
<button type="button" onClick={() => setIsFullscreen((current) => !current)} className="grid h-9 w-9 place-items-center rounded-lg border border-slate-200 text-slate-500 transition hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700" title={isFullscreen ? '退出全屏' : '地图全屏'} aria-label={isFullscreen ? '退出加氢地图全屏' : '加氢地图全屏'} aria-pressed={isFullscreen}>
{isFullscreen ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
@@ -182,8 +180,8 @@ export default function HydrogenHeatmapModule() {
<HydrogenHeatmapFilters
startDate={startDate}
endDate={endDate}
minDate={meta?.startDate || FALLBACK_RANGE.start}
maxDate={meta?.endDate || FALLBACK_RANGE.end}
minDate={meta?.startDate || DEFAULT_START}
maxDate={meta?.endDate || DEFAULT_END}
query={query}
payer={payer}
metric={metric}
@@ -208,7 +206,7 @@ export default function HydrogenHeatmapModule() {
<div className="pointer-events-none absolute bottom-5 left-5 z-10 w-[300px] rounded-xl border border-slate-200/80 bg-white/95 p-3.5 shadow-[0_8px_30px_rgba(15,23,42,0.11)] backdrop-blur-sm max-lg:bottom-[calc(43vh+16px)] max-md:hidden">
<div className="flex items-center justify-between text-[11px] font-medium text-slate-700"><span>{metricName(metric)}</span>{loading ? <i className="h-3 w-3 animate-spin rounded-full border-2 border-cyan-600 border-t-transparent" /> : null}</div>
<p className="mt-1 text-[10px] leading-4 text-slate-500">{metricDescription(metric)}</p>
<div className="mt-2.5 h-2.5 rounded-full" style={{ background: HEATMAP_LEGEND_GRADIENT }} />
<div className="mt-2.5 h-2.5 rounded-full bg-[linear-gradient(90deg,#2563eb_0%,#0891b2_25%,#16a34a_45%,#eab308_65%,#f97316_82%,#dc2626_100%)]" />
<div className="mt-1.5 flex justify-between text-[10px] text-slate-400"><span>相对较低</span><span>相对较高</span></div>
</div>
+3 -2
View File
@@ -6,6 +6,7 @@ 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;
@@ -159,7 +160,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-black text-slate-900 font-mono truncate">{vehicle.plate}</span>
<span className="text-sm font-black text-slate-900 font-mono truncate"><Blur>{vehicle.plate}</Blur></span>
<span className={`text-[8px] px-1 rounded font-bold ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'}`}>
{vehicle.isOnline ? '在线' : '离线'}
</span>
@@ -168,7 +169,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
{vehicle.rentStatus || ''}
{vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''}
{vehicle.customer ? ` · ` : ''}
{vehicle.customer}
{vehicle.customer && <Blur>{vehicle.customer}</Blur>}
</div>
</div>
</div>
@@ -21,6 +21,7 @@ import {
XAxis,
YAxis,
} from 'recharts';
import Blur from '../../../components/Blur';
import type { MileageReportGroup } from '../api';
import {
filterAndSortVehicles,
@@ -166,7 +167,7 @@ export default function VehicleTable({ group }: { group: MileageReportGroup }) {
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs font-black text-slate-900">
<BarChart3 size={15} className="text-blue-600" />
{trendVehicle.plate}
<Blur>{trendVehicle.plate}</Blur>
<span className="font-bold text-slate-400">近7日里程</span>
</div>
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[10px] font-bold text-slate-500">
@@ -248,12 +249,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' : ''}`}
>
<span className={`sticky left-3 z-10 -my-2.5 flex self-stretch flex-col justify-center font-mono font-black text-slate-800 shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)] md:flex-row md:items-center md:shadow-none ${frozenCellClass}`}>
{vehicle.plate}
<Blur>{vehicle.plate}</Blur>
<span className={`mt-0.5 font-sans text-[9px] md:hidden ${vehicle.mileageBand === 'INVENTORY' ? 'text-amber-600' : 'text-emerald-600'}`}>{vehicle.status}</span>
</span>
<span className={`sticky left-[110px] z-10 -my-2.5 hidden self-stretch items-center shadow-[8px_0_12px_-12px_rgba(15,23,42,0.45)] md:flex ${frozenCellClass} ${vehicle.mileageBand === 'INVENTORY' ? 'text-amber-600' : 'text-emerald-600'}`}>{vehicle.status}</span>
<span className="truncate text-slate-500">{vehicle.department || vehicle.inventoryRegion || '未标注'}</span>
<span className="truncate text-slate-500">{vehicle.customer || '未绑定客户'}</span>
<span className="truncate text-slate-500"><Blur>{vehicle.customer || '未绑定客户'}</Blur></span>
<span className={`text-right tabular-nums ${vehicle.mileageBand === 'HIGH' ? 'text-blue-600' : vehicle.mileageBand === 'INVENTORY' && vehicle.dailyMileage > 0 ? 'text-rose-600' : 'text-slate-700'}`}>
{fmtKm(vehicle.dailyMileage)} km
</span>
@@ -1,6 +1,7 @@
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';
@@ -291,7 +292,7 @@ export default function FullscreenMonitoring({
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<div className="text-xs font-bold text-white">{v.plate}</div>
<div className="text-xs font-bold text-white"><Blur>{v.plate}</Blur></div>
<span
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
title={sourceDisplay.title}
@@ -303,7 +304,7 @@ export default function FullscreenMonitoring({
{statisticTime.label}
</div>
</td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.customer || '-'}</td>
<td className="px-3 py-2 text-[11px] text-slate-400"><Blur>{v.customer || '-'}</Blur></td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.brand || '-'}</td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.rentStatus || '-'}</td>
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
@@ -1,6 +1,7 @@
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';
@@ -72,7 +73,7 @@ export default function VehicleList({
</div>
<div className="overflow-hidden flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono">{v.plate}</span>
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{v.plate}</Blur></span>
<span className={`text-[8px] px-1 rounded ${v.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{v.isOnline ? '在线' : '离线'}
</span>
@@ -88,12 +89,12 @@ export default function VehicleList({
</div>
<div className="flex items-center gap-1.5 md:hidden">
<span className="text-[8px] text-slate-300 font-bold">{v.rentStatus || ''}{v.department ? ` · ${v.department.replace('业务', '')}` : ''}</span>
<span className="text-[9px] font-bold text-slate-600 truncate">{v.customer || '-'}</span>
<span className="text-[9px] font-bold text-slate-600 truncate"><Blur>{v.customer || '-'}</Blur></span>
</div>
</div>
</div>
<div className="hidden min-w-0 md:block">
<div className="truncate text-xs font-bold text-slate-700">{v.customer || '-'}</div>
<div className="truncate text-xs font-bold text-slate-700"><Blur>{v.customer || '-'}</Blur></div>
<div className="mt-1 truncate text-[9px] font-medium text-slate-400">
{[v.rentStatus, v.department?.replace('业务', ''), v.project].filter(Boolean).join(' · ') || '暂无归属信息'}
</div>
@@ -1,6 +1,7 @@
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,
@@ -115,7 +116,7 @@ export default function AllVehiclesPanel({
</div>
<div className="overflow-hidden flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-black text-slate-900 font-mono">{vehicle.plateNumber}</span>
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{vehicle.plateNumber}</Blur></span>
<span className={`text-[8px] px-1 rounded ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
{vehicle.isOnline ? '在线' : '离线'}
</span>

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