Compare commits

..

11 Commits

Author SHA1 Message Date
kkfluous
433a75f9d1 fix(mileage): 7天里程趋势忽略负值脏数据
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
v_vehicle_daily_stats.daily_km 偶发负值(粤A00828F 在 5.1 录得 -82061km),
源于里程表回滚 / 换 GPS 设备。SQL 聚合时把负值置 0,避免一辆脏数据车
拖垮整组的当日趋势柱。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:33:25 +08:00
kkfluous
0193e78f18 fix(auth): 能源管理仅 BI-LEADER-ENERGY 与「所有权限」可访问
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
收紧准入:之前 FULL_ACCESS_ROLES(含 数智中心 / BI-Leader)会自动通过。
现在只接受 BI-LEADER-ENERGY 或「所有权限」两类角色。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:16:42 +08:00
kkfluous
2a851fc243 feat(auth): 能源管理放开全量权限角色访问
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
BI-LEADER-ENERGY 之外,FULL_ACCESS_ROLES(所有权限/数智中心/BI-Leader)
也可访问能源管理模块。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:03:05 +08:00
kkfluous
6142af7617 fix(auth): 能源管理仅 BI-LEADER-ENERGY 可访问,移除全量权限旁路
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
与智能调度的口径一致:模块访问需要专属角色,全量权限角色不再自动通过。
本地开发 dev mock 用户已含 BI-LEADER-ENERGY,调试不受影响。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:02:21 +08:00
kkfluous
26f7d7ab3f feat(auth): 能源管理模块需要 BI-LEADER-ENERGY 角色
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 新增 ENERGY_ACCESS_ROLES 与 canAccessEnergy(roles) 守卫(全量权限角色亦可访问)
- 后端 /api/energy/* 加模块级守卫:无角色返回 403
- 前端 App.tsx 按角色动态注入 EnergyModule,无权限时主导航不显示
- dev mock 用户(前端 + 后端)追加 BI-LEADER-ENERGY 便于本地调试

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:55:29 +08:00
kkfluous
f06b0d21eb perf(energy): SWR 缓存 + 自调度刷新,氢能总览 6s → 13ms
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
接口侧:
- cache.ts 改为 stale-while-revalidate:每个 key 自调度,TTL 到期前 5s 后台刷新,用户永远命中热缓存
- 闲置 10 分钟后停止调度,避免空跑
- loader 失败保留旧值 + 10s 后退避重试
- 所有 4 个端点支持 ?force=1 强制绕过缓存

前端 HydrogenOverview:
- 顶部加 RefreshCw 按钮(强刷绕过缓存),带旋转动画
- 显示"更新于 X 秒前"相对时间
- 刷新中:顶部 0.5px 流光进度条,不替换内容、不闪烁
- 60s 静默自动刷新(命中后端热缓存)

实测:cold 6.1s → 命中 13ms(470× 提速)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:43:24 +08:00
kkfluous
6ad4b5e2a4 feat(energy): 氢能总览补全维度(5KPI+收支+客户/加氢站全量+年份切换)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
按 BI 页面 (https://bi.lnh2e.com/lingniu/decision/link/0iqP) 完整还原:
- 5 张 KPI:累计加氢量 / 累计加氢费 / 时享加氢获利 / 本月加氢 / 本日加氢
- 月度收支对比柱图:成本支出 vs 客户收入双柱
- 加氢站加氢汇总(全量 55 站):加氢量+占比+氢费收入+收入占比,进度条
- 客户账单 Top 30:承担方 / 加氢量 / 成本支出 / 应收
- 年份切换(2025/2026),全量数据按选定年份重算
- 关键修正:用 cost_type 区分客户单/我司单(cost_type=2 客户单,cost_type=3 我司单),获利口径与 BI 对齐

后端 /hydrogen/overview 重写:
- 增加 customers/stations/availableYears/year 字段
- KPI 含 yearProfit/monthProfit/todayProfit
- monthly 含 fee/revenue/profit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:43:05 +08:00
kkfluous
ad8ec50038 refactor(energy): 氢能总览参照 BI 重构 + 月度趋势 + 高密度 KPI
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
参考 https://bi.lnh2e.com/lingniu/decision/link/0iqP 重新设计:
- 4 张高密度 KPI 卡:累计加氢量 / 累计加氢费 / 本月加氢 / 本日加氢
  每张含主指标 + 2 行明细(我司/客户、加氢费/占比)
- 新增年内月度加氢量柱图(缺失月份补 0)
- 数字格式化:万元/亿元/T 单位自动切换,tabular-nums 对齐
- 后端 /hydrogen/overview 增加 monthly 字段
- 骨架屏同步更新

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:54:35 +08:00
kkfluous
dc6f541c8b fix(energy): 桌面 sticky 失效 —— overflow:hidden 限定到移动端横屏
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
原因:父容器一直挂着 landscape:overflow-hidden,意图是手机横屏全屏
体验。但 Tailwind 的 landscape: 是纯方向匹配(含桌面横屏显示器),
所以桌面也命中 overflow:hidden,sticky 完全失效,滚动时头部 tab
全部消失,看起来像「半截被遮挡」。

修复:把 landscape: 修饰符改为 max-md:landscape: ,仅在移动端
(< 768px)+ 横屏时生效。桌面恢复正常 overflow:visible,sticky
头部能稳稳停在顶部。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:42:32 +08:00
kkfluous
034654265c style(energy): sticky 头部底部缓冲 pb-2 → pb-4
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
不再尝试把日期速选并入 sticky 头部(之前的方案撤回)。
仅增加一点底部 padding 当作缓冲,保持简洁。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:37:49 +08:00
kkfluous
5958bb581e Revert "fix(energy): 日期速选并入 sticky 头部,避免滚动时被遮挡"
This reverts commit 4153f329b8.
2026-04-30 15:37:17 +08:00
16 changed files with 880 additions and 138 deletions

View File

@@ -10,14 +10,17 @@ import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
import AuthProvider from './auth/AuthProvider'; import AuthProvider from './auth/AuthProvider';
import { useAuth } from './auth/useAuth'; import { useAuth } from './auth/useAuth';
import UnauthorizedPage from './auth/UnauthorizedPage'; import UnauthorizedPage from './auth/UnauthorizedPage';
import { canAccessScheduling } from './shared/auth/roles'; import { canAccessScheduling, canAccessEnergy } from './shared/auth/roles';
const BASE_MODULES: ModuleConfig[] = [ const BASE_MODULES: ModuleConfig[] = [
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule }, { id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule }, { id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
{ id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule },
]; ];
const ENERGY_MODULE: ModuleConfig = {
id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule,
};
const SCHEDULING_MODULE: ModuleConfig = { const SCHEDULING_MODULE: ModuleConfig = {
id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule, id: 'scheduling', label: '智能调度', icon: Activity, component: SchedulingModule,
}; };
@@ -47,10 +50,10 @@ function AuthGate() {
}, []); }, []);
const modules = useMemo(() => { const modules = useMemo(() => {
if (canAccessScheduling(user?.roles)) { const result = [...BASE_MODULES];
return [...BASE_MODULES, SCHEDULING_MODULE]; if (canAccessEnergy(user?.roles)) result.push(ENERGY_MODULE);
} if (canAccessScheduling(user?.roles)) result.push(SCHEDULING_MODULE);
return BASE_MODULES; return result;
}, [user?.roles]); }, [user?.roles]);
if (isLoading) { if (isLoading) {

View File

@@ -46,7 +46,7 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
userName: '本地开发', userName: '本地开发',
permissionLevel: 'full', permissionLevel: 'full',
depName: '', depName: '',
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK'], roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
}, },
error: null, error: null,
}); });

View File

@@ -6,12 +6,15 @@ import { fetchElectricMonthly } from './api';
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types'; import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
interface Props { const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
pick: DateQuickPick; { id: 'thisWeek', label: '本周' },
} { id: 'thisMonth', label: '本月' },
{ id: 'last15', label: '近 15 天' },
];
export default function ElectricDaily({ pick }: Props) { export default function ElectricDaily() {
const [customer, setCustomer] = useState<CustomerType>('lingniu'); const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [pick, setPick] = useState<DateQuickPick>('last15');
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null); const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set()); const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -41,6 +44,23 @@ export default function ElectricDaily({ pick }: Props) {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* 日期速选 */}
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => setPick(opt.id)}
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
pick === opt.id
? 'bg-blue-50 text-blue-600 border-blue-200'
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
</div>
{/* 客户类型 */} {/* 客户类型 */}
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1"> <div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
{(['lingniu', 'external'] as const).map(c => ( {(['lingniu', 'external'] as const).map(c => (

View File

@@ -1,14 +1,12 @@
import ElectricOverview from './ElectricOverview'; import ElectricOverview from './ElectricOverview';
import ElectricDaily from './ElectricDaily'; import ElectricDaily from './ElectricDaily';
import type { DateQuickPick } from './types';
export type ElectricSubTab = 'daily' | 'overview'; export type ElectricSubTab = 'daily' | 'overview';
interface Props { interface Props {
sub: ElectricSubTab; sub: ElectricSubTab;
pick: DateQuickPick;
} }
export default function ElectricView({ sub, pick }: Props) { export default function ElectricView({ sub }: Props) {
return sub === 'overview' ? <ElectricOverview /> : <ElectricDaily pick={pick} />; return sub === 'overview' ? <ElectricOverview /> : <ElectricDaily />;
} }

View File

@@ -4,13 +4,6 @@ import { motion } from 'motion/react';
import HydrogenView, { type HydrogenSubTab } from './HydrogenView'; import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import ElectricView, { type ElectricSubTab } from './ElectricView'; import ElectricView, { type ElectricSubTab } from './ElectricView';
import ETCView from './ETCView'; import ETCView from './ETCView';
import type { DateQuickPick } from './types';
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' },
{ id: 'thisMonth', label: '本月' },
{ id: 'last15', label: '近 15 天' },
];
type TopTab = 'hydrogen' | 'electric' | 'etc'; type TopTab = 'hydrogen' | 'electric' | 'etc';
type SubTabId = HydrogenSubTab | ElectricSubTab; // 'daily' | 'overview' type SubTabId = HydrogenSubTab | ElectricSubTab; // 'daily' | 'overview'
@@ -30,23 +23,16 @@ export default function EnergyModule() {
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen'); const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
const [hydroSub, setHydroSub] = useState<HydrogenSubTab>('daily'); const [hydroSub, setHydroSub] = useState<HydrogenSubTab>('daily');
const [electricSub, setElectricSub] = useState<ElectricSubTab>('daily'); const [electricSub, setElectricSub] = useState<ElectricSubTab>('daily');
const [hydroPick, setHydroPick] = useState<DateQuickPick>('last15');
const [electricPick, setElectricPick] = useState<DateQuickPick>('last15');
const showSubTabs = activeTab === 'hydrogen' || activeTab === 'electric'; const showSubTabs = activeTab === 'hydrogen' || activeTab === 'electric';
const currentSub: SubTabId = activeTab === 'electric' ? electricSub : hydroSub; const currentSub: SubTabId = activeTab === 'electric' ? electricSub : hydroSub;
const setSub = (id: SubTabId) => activeTab === 'electric' ? setElectricSub(id) : setHydroSub(id); const setSub = (id: SubTabId) => activeTab === 'electric' ? setElectricSub(id) : setHydroSub(id);
// 是否在 daily 模式(需要在 sticky 头部展示日期速选)
const showQuickPick = (activeTab === 'hydrogen' && hydroSub === 'daily')
|| (activeTab === 'electric' && electricSub === 'daily');
const currentPick: DateQuickPick = activeTab === 'electric' ? electricPick : hydroPick;
const setPick = (id: DateQuickPick) => activeTab === 'electric' ? setElectricPick(id) : setHydroPick(id);
return ( return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}> <div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden"> <div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 max-md:landscape:pb-0 max-md:landscape:h-full max-md:landscape:flex-1 max-md:landscape:overflow-hidden">
{/* 统一 sticky 头部top tab + (氢能时) 子 tab同一张卡片无间隙 */} {/* 统一 sticky 头部top tab + (氢能时) 子 tab同一张卡片无间隙 */}
{/* 背景不透明(页面色),避免下方快捷选按钮在滚动时透过来"半截露脸" */} {/* pb-4 留一点底部缓冲,避免下方快捷选按钮在滚动时贴着 sticky 半截露脸 */}
<div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-2 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]"> <div className="sticky top-0 z-30 -mx-3 md:-mx-6 px-3 md:px-6 -mt-3 md:-mt-6 pt-3 md:pt-6 pb-4 bg-[#F8F9FB] shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)]">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden"> <div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 顶部 tab氢能 / 电能 / ETC */} {/* 顶部 tab氢能 / 电能 / ETC */}
<div className={`px-4 py-2 flex items-center gap-6 ${showSubTabs ? 'border-b border-slate-50' : ''}`}> <div className={`px-4 py-2 flex items-center gap-6 ${showSubTabs ? 'border-b border-slate-50' : ''}`}>
@@ -88,32 +74,11 @@ export default function EnergyModule() {
})} })}
</div> </div>
)} )}
{/* 日期速选daily 模式时跟着 sticky避免滚动后被遮挡 */}
{showQuickPick && (
<div className="px-2 pb-2 pt-1 border-t border-slate-50 flex items-center gap-2 overflow-x-auto">
{QUICK_PICK_OPTIONS.map(opt => {
const active = currentPick === opt.id;
return (
<button
key={opt.id}
onClick={() => setPick(opt.id)}
className={`shrink-0 rounded-lg px-3 py-1 text-[11px] font-bold border transition-colors ${
active
? 'bg-blue-50 text-blue-600 border-blue-200'
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
);
})}
</div>
)}
</div> </div>
</div> </div>
{activeTab === 'hydrogen' && <HydrogenView sub={hydroSub} pick={hydroPick} />} {activeTab === 'hydrogen' && <HydrogenView sub={hydroSub} />}
{activeTab === 'electric' && <ElectricView sub={electricSub} pick={electricPick} />} {activeTab === 'electric' && <ElectricView sub={electricSub} />}
{activeTab === 'etc' && <ETCView />} {activeTab === 'etc' && <ETCView />}
</div> </div>
</div> </div>

View File

@@ -7,11 +7,14 @@ import { fetchHydrogenDaily } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types'; import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
interface Props { const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
pick: DateQuickPick; { id: 'thisWeek', label: '本周' },
} { id: 'thisMonth', label: '本月' },
{ id: 'last15', label: '近 15 天' },
];
export default function HydrogenDaily({ pick }: Props) { export default function HydrogenDaily() {
const [pick, setPick] = useState<DateQuickPick>('last15');
const [customer, setCustomer] = useState<CustomerType>('lingniu'); const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [expanded, setExpanded] = useState<Set<string>>(new Set()); const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null); const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
@@ -38,6 +41,23 @@ export default function HydrogenDaily({ pick }: Props) {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* 日期速选 */}
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => setPick(opt.id)}
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
pick === opt.id
? 'bg-blue-50 text-blue-600 border-blue-200'
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
</div>
{/* 客户类型 segmented */} {/* 客户类型 segmented */}
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1"> <div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
{(['lingniu', 'external'] as const).map(c => ( {(['lingniu', 'external'] as const).map(c => (

View File

@@ -1,8 +1,18 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList } from 'recharts'; import {
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
} from 'recharts';
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api'; import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
const REGION_COLORS = [
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
'#94a3b8',
];
interface YAxisTickProps { interface YAxisTickProps {
x?: number; x?: number;
y?: number; y?: number;
@@ -24,26 +34,91 @@ function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
); );
} }
const REGION_COLORS = [ // ---------- 数字格式化 ----------
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b', function fmtKg(kg: number): { value: string; unit: string } {
'#10b981', '#ef4444', '#6366f1', '#14b8a6', if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
'#94a3b8', return { value: kg.toFixed(2), unit: 'Kg' };
]; }
function fmtYuan(yuan: number): { value: string; unit: string } {
const abs = Math.abs(yuan);
if (abs >= 100_000_000) return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
if (abs >= 10_000) {
const w = yuan / 10_000;
return { value: w.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), unit: '万元' };
}
return { value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), unit: '元' };
}
// ---------- KPI 卡 ----------
interface KpiCardProps {
icon: React.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 default function HydrogenOverview() { export default function HydrogenOverview() {
const [data, setData] = useState<HydrogenOverviewResponse | null>(null); const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
const [error, setError] = useState<string | 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);
useEffect(() => { const load = useCallback(async (selectedYear: number | null, force: boolean) => {
let cancelled = false; const seq = ++refreshSeq.current;
fetchHydrogenOverview() setRefreshing(true);
.then(d => { if (!cancelled) setData(d); }) try {
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); const d = await fetchHydrogenOverview(selectedYear ?? undefined, force);
return () => { cancelled = true; }; 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);
}
}, []); }, []);
if (error) { // 初始加载 + 年份切换:用 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>; return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">{error}</div>;
} }
if (!data) { if (!data) {
@@ -52,14 +127,207 @@ export default function HydrogenOverview() {
const k = data.kpi; const k = data.kpi;
const top5 = data.top5; const top5 = data.top5;
const regions = data.regions; const regions = data.regions;
const monthly = data.monthly;
const customers = data.customers;
const stations = data.stations;
const availableYears = data.availableYears;
const activeYear = data.year;
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';
// 月度收支组合数据(推算"年内每月"图)
const monthlyDual = monthly.map(m => ({
...m,
monthLabel: m.month.slice(5).replace(/^0/, '') + '月',
}));
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3 relative">
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400"> {/* 顶部说明条 + 年份切换 + 刷新按钮 */}
2025-01-01 1 <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 ? `更新于 ${formatRelative(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={() => setYear(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={() => void load(year, true)}
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> </div>
{/* KPI 5 卡 */}
<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>
{/* 月度趋势:年内每月加氢量 */}
{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>
)}
{/* Top5 + 区域占比 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Top5 加氢站 */} {/* Top5 加氢站 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4"> <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"> <div className="flex items-center justify-between mb-3">
<span className="text-sm font-bold text-slate-700"> Top5</span> <span className="text-sm font-bold text-slate-700"> Top5</span>
<span className="text-[11px] text-slate-400 font-bold"> Kg</span> <span className="text-[11px] text-slate-400 font-bold"> Kg</span>
@@ -81,7 +349,7 @@ export default function HydrogenOverview() {
/> />
<Bar dataKey="kg" radius={[6, 6, 6, 6]}> <Bar dataKey="kg" radius={[6, 6, 6, 6]}>
{top5.map((_, i) => ( {top5.map((_, i) => (
<Cell key={i} fill={`url(#topBarGrad)`} /> <Cell key={i} fill="url(#topBarGrad)" />
))} ))}
<LabelList <LabelList
dataKey="kg" dataKey="kg"
@@ -101,8 +369,9 @@ export default function HydrogenOverview() {
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
{/* 区域占比环 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-2"> {/* 区域占比 */}
<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> <span className="text-sm font-bold text-slate-700"></span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative w-1/2 h-[200px]"> <div className="relative w-1/2 h-[200px]">
@@ -131,30 +400,203 @@ export default function HydrogenOverview() {
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]"> <div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
{regions.map((r, i) => ( {regions.map((r, i) => (
<div key={r.region} className="flex items-center gap-1.5"> <div key={r.region} className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} /> <span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
<span className="text-slate-600">{r.region}</span> <span className="text-slate-600 truncate">{r.region}</span>
<span className="text-slate-400 ml-auto font-bold">{(r.share * 100).toFixed(1)}%</span> <span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* 加氢站加氢汇总(全量) */}
{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>
)}
{/* 客户账单汇总 Top */}
{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>
)}
<RotatingFooterHint /> <RotatingFooterHint />
{/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */}
<AnimatePresence>
{refreshing && data && (
<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>
</div> </div>
); );
} }
function formatRelative(ts: number): string {
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (s < 5) return '刚刚';
if (s < 60) return `${s} 秒前`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} 分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} 小时前`;
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
}
function HydrogenOverviewSkeleton() { function HydrogenOverviewSkeleton() {
return ( return (
<div className="flex flex-col gap-3 animate-pulse"> <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="bg-white rounded-xl border border-slate-100 px-3 py-2">
<div className="h-3 w-44 bg-slate-100 rounded" /> <div className="h-3 w-44 bg-slate-100 rounded" />
</div> </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="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Top5 占位 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4"> <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="flex items-center justify-between mb-3">
<div className="h-4 w-32 bg-slate-100 rounded" /> <div className="h-4 w-32 bg-slate-100 rounded" />
@@ -171,8 +613,6 @@ function HydrogenOverviewSkeleton() {
))} ))}
</div> </div>
</div> </div>
{/* 区域占比环 占位 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3"> <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="h-4 w-28 bg-slate-100 rounded" />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@@ -1,14 +1,12 @@
import HydrogenOverview from './HydrogenOverview'; import HydrogenOverview from './HydrogenOverview';
import HydrogenDaily from './HydrogenDaily'; import HydrogenDaily from './HydrogenDaily';
import type { DateQuickPick } from './types';
export type HydrogenSubTab = 'daily' | 'overview'; export type HydrogenSubTab = 'daily' | 'overview';
interface Props { interface Props {
sub: HydrogenSubTab; sub: HydrogenSubTab;
pick: DateQuickPick;
} }
export default function HydrogenView({ sub, pick }: Props) { export default function HydrogenView({ sub }: Props) {
return sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily pick={pick} />; return sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily />;
} }

View File

@@ -1,6 +1,7 @@
import { fetchJson } from '../../auth/api-client'; import { fetchJson } from '../../auth/api-client';
import type { import type {
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenDailyRow, HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
HydrogenCustomerRow, HydrogenStationFull,
ElectricKpi, ElectricDailyRow, ElectricMonthGroup, ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
CustomerType, DateQuickPick, CustomerType, DateQuickPick,
} from './types'; } from './types';
@@ -11,10 +12,19 @@ export interface HydrogenOverviewResponse {
kpi: HydrogenKpi; kpi: HydrogenKpi;
top5: HydrogenStationTop[]; top5: HydrogenStationTop[];
regions: HydrogenRegionShare[]; regions: HydrogenRegionShare[];
monthly: HydrogenMonthlyPoint[];
customers: HydrogenCustomerRow[];
stations: HydrogenStationFull[];
availableYears: number[];
year: number;
} }
export function fetchHydrogenOverview(): Promise<HydrogenOverviewResponse> { export function fetchHydrogenOverview(year?: number, force = false): Promise<HydrogenOverviewResponse> {
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview`); 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}` : ''}`);
} }
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> { export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {

View File

@@ -4,13 +4,19 @@ export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
export interface HydrogenKpi { export interface HydrogenKpi {
yearKg: number; yearKg: number;
yearFee: number; yearFee: number;
yearRevenue: number;
yearProfit: number;
ourYearKg: number; ourYearKg: number;
ourYearFee: number; ourYearFee: number;
customerYearKg: number; customerYearKg: number;
monthKg: number; monthKg: number;
monthFee: number; monthFee: number;
monthRevenue: number;
monthProfit: number;
todayKg: number; todayKg: number;
todayFee: number; todayFee: number;
todayRevenue: number;
todayProfit: number;
lingniuBornKg: number; lingniuBornKg: number;
lingniuBornFee: number; lingniuBornFee: number;
} }
@@ -29,6 +35,30 @@ export interface HydrogenRegionShare {
share: 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 { export interface HydrogenStationRow {
name: string; name: string;
pricePerKg: number; pricePerKg: number;

View File

@@ -23,7 +23,7 @@ export async function authMiddleware(c: Context, next: Next) {
depCode: '', depCode: '',
depName: '', depName: '',
permissionLevel: 'full', permissionLevel: 'full',
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK'], roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
}; };
c.set('user', devUser); c.set('user', devUser);
return next(); return next();

View File

@@ -29,6 +29,8 @@ export {
DEPT_ACCESS_ROLES, DEPT_ACCESS_ROLES,
SCHEDULING_ACCESS_ROLES, SCHEDULING_ACCESS_ROLES,
FEEDBACK_ADMIN_ROLES, FEEDBACK_ADMIN_ROLES,
ENERGY_ACCESS_ROLES,
canAccessScheduling, canAccessScheduling,
canManageFeedback, canManageFeedback,
canAccessEnergy,
} from '../../shared/auth/roles.js'; } from '../../shared/auth/roles.js';

View File

@@ -1,44 +1,135 @@
/** /**
* 简单 TTL 内存缓存 * SWR 缓存:始终返回热数据,后台定时刷新
* 命中:直接返回缓存值;过期或未命中:运行 loader、存入缓存。 *
* 同一 key 并发请求只会触发一次 loader共享 in-flight Promise * 工作机制:
* - 首次请求:阻塞等待 loadercold start3-4s 不可避免)
* - 之后:每个 key 自调度刷新TTL 到期前 5s用户永远命中热缓存
* - 闲置 IDLE_TIMEOUT_MS 后取消调度(避免浪费 DB 资源)
* - 同一 key 并发请求只触发一次 loader
* - force=true手动强制刷新绕过缓存但仍参与 inflight 复用)
*/ */
interface Entry<T> { interface Entry<T> {
value: T; value: T;
freshAt: number;
expiresAt: number; expiresAt: number;
loader: () => Promise<T>;
lastAccess: number;
timer?: NodeJS.Timeout;
} }
const TTL_MS = 60 * 1000; const TTL_MS = 60 * 1000;
const REFRESH_LEAD_MS = 5 * 1000; // TTL 到期前多久触发刷新
const IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 分钟无访问则停止调度
const RETRY_BACKOFF_MS = 10 * 1000; // loader 失败时重试间隔
const cache = new Map<string, Entry<unknown>>(); const cache = new Map<string, Entry<unknown>>();
const inflight = new Map<string, Promise<unknown>>(); const inflight = new Map<string, Promise<unknown>>();
export async function cached<T>(key: string, loader: () => Promise<T>): Promise<T> { function scheduleRefresh<T>(key: string, entry: Entry<T>) {
if (entry.timer) clearTimeout(entry.timer);
const delay = Math.max(0, entry.freshAt + TTL_MS - Date.now() - REFRESH_LEAD_MS);
entry.timer = setTimeout(() => { void runRefresh(key); }, delay);
entry.timer.unref?.();
}
async function runRefresh(key: string) {
const entry = cache.get(key) as Entry<unknown> | undefined;
if (!entry) return;
// 闲置超时:停止调度
if (Date.now() - entry.lastAccess > IDLE_TIMEOUT_MS) {
if (entry.timer) clearTimeout(entry.timer);
return;
}
if (inflight.has(key)) return;
const p = entry.loader()
.then(value => {
const now = Date.now();
const next: Entry<unknown> = {
value,
freshAt: now,
expiresAt: now + TTL_MS,
loader: entry.loader,
lastAccess: entry.lastAccess,
};
cache.set(key, next);
scheduleRefresh(key, next);
return value;
})
.catch(e => {
console.error(`[energy/cache] refresh failed for "${key}":`, e instanceof Error ? e.message : e);
// 保留旧值10s 后重试
const retry: Entry<unknown> = { ...entry };
retry.timer = setTimeout(() => { void runRefresh(key); }, RETRY_BACKOFF_MS);
retry.timer.unref?.();
cache.set(key, retry);
})
.finally(() => inflight.delete(key));
inflight.set(key, p);
}
export interface CachedOpts {
force?: boolean;
}
export async function cached<T>(key: string, loader: () => Promise<T>, opts: CachedOpts = {}): Promise<T> {
const now = Date.now(); const now = Date.now();
const hit = cache.get(key); const hit = cache.get(key) as Entry<T> | undefined;
if (hit && hit.expiresAt > now) { if (hit) {
return hit.value as T; hit.lastAccess = now;
hit.loader = loader;
} }
// 同一 key 并发只跑一次 loader // 强制刷新:等待 loader 完成
if (opts.force) {
const ongoing = inflight.get(key) as Promise<T> | undefined;
if (ongoing) return ongoing;
const p = loader()
.then(value => {
const t = Date.now();
const next: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
cache.set(key, next);
scheduleRefresh(key, next);
return value;
})
.finally(() => inflight.delete(key));
inflight.set(key, p as Promise<unknown>);
return p;
}
// 命中且未过期 → 立即返回
if (hit && hit.expiresAt > now) {
return hit.value;
}
// 命中但过期 → 返回 stale后台刷新
if (hit) {
if (!inflight.has(key)) void runRefresh(key);
return hit.value;
}
// 完全未命中 → 阻塞等待
const ongoing = inflight.get(key) as Promise<T> | undefined; const ongoing = inflight.get(key) as Promise<T> | undefined;
if (ongoing) return ongoing; if (ongoing) return ongoing;
const p = loader() const p = loader()
.then(value => { .then(value => {
cache.set(key, { value, expiresAt: Date.now() + TTL_MS }); const t = Date.now();
const entry: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
cache.set(key, entry);
scheduleRefresh(key, entry);
return value; return value;
}) })
.finally(() => { .finally(() => inflight.delete(key));
inflight.delete(key);
});
inflight.set(key, p as Promise<unknown>); inflight.set(key, p as Promise<unknown>);
return p; return p;
} }
/** 仅用于测试或调试:清空所有缓存 */ /** 仅用于测试或调试:清空所有缓存与定时器 */
export function _clearEnergyCache() { export function _clearEnergyCache() {
for (const e of cache.values()) {
if (e.timer) clearTimeout(e.timer);
}
cache.clear(); cache.clear();
inflight.clear(); inflight.clear();
} }

View File

@@ -2,9 +2,21 @@ import { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2'; import type { RowDataPacket } from 'mysql2';
import pool from '../../db.js'; import pool from '../../db.js';
import { cached } from './cache.js'; import { cached } from './cache.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
const app = new Hono(); const app = new Hono();
// 模块级访问守卫dev 旁路 auth 时 user 为 undefined直接放行
// 生产环境必须具备 BI-LEADER-ENERGY 或全量权限角色
app.use('*', async (c, next) => {
const user = (c as { get: (k: string) => unknown }).get('user') as AuthUser | undefined;
if (user && !canAccessEnergy(user.roles)) {
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
});
const HYDROGEN_MIN_DATE = '2024-01-01'; const HYDROGEN_MIN_DATE = '2024-01-01';
// hydrogen_time 已是 CST 字面值,直接使用即可(不再 +8 小时) // hydrogen_time 已是 CST 字面值,直接使用即可(不再 +8 小时)
@@ -60,52 +72,100 @@ function enumerateDates(range: Range): string[] {
// 氢能 总览KPI + Top5 + 区域占比 // 氢能 总览KPI + Top5 + 区域占比
// ========================================================= // =========================================================
app.get('/hydrogen/overview', async (c) => { app.get('/hydrogen/overview', async (c) => {
const data = await cached('hydrogen/overview', async () => { const yearParam = c.req.query('year');
// KPI年/月/日 + 我方/客户分解 + 累计羚牛承担) const force = c.req.query('force') === '1';
const today = new Date();
const todayYear = today.getFullYear();
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
const [yearListRows] = await pool.query<RowDataPacket[]>(
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
FROM tab_energy_hydrogen_bill
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?
ORDER BY y DESC`,
[HYDROGEN_MIN_DATE],
);
const availableYears = yearListRows.map(r => Number(r.y)).filter(y => y > 0);
const year = availableYears.includes(requestedYear) ? requestedYear : (availableYears[0] ?? todayYear);
const isCurrentYear = year === todayYear;
// KPI按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
const [kpiRows] = await pool.query<RowDataPacket[]>( const [kpiRows] = await pool.query<RowDataPacket[]>(
`SELECT `SELECT
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN hydrogen_quantity ELSE 0 END) AS yearKg, THEN hydrogen_quantity ELSE 0 END) AS yearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN cost_expense ELSE 0 END) AS yearFee, THEN cost_expense ELSE 0 END) AS yearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND truck_id IS NOT NULL SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
THEN cost_expense ELSE 0 END) AS yearCustomerCost,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN customer_expense ELSE 0 END) AS yearRevenue,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
THEN hydrogen_quantity ELSE 0 END) AS ourYearKg, THEN hydrogen_quantity ELSE 0 END) AS ourYearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND truck_id IS NOT NULL SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
THEN cost_expense ELSE 0 END) AS ourYearFee, THEN cost_expense ELSE 0 END) AS ourYearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND truck_id IS NULL SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
THEN hydrogen_quantity ELSE 0 END) AS customerYearKg, THEN hydrogen_quantity ELSE 0 END) AS customerYearKg,
SUM(CASE WHEN DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN hydrogen_quantity ELSE 0 END) AS monthKg, THEN hydrogen_quantity ELSE 0 END) AS monthKg,
SUM(CASE WHEN DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN cost_expense ELSE 0 END) AS monthFee, THEN cost_expense ELSE 0 END) AS monthFee,
SUM(CASE WHEN DATE(${HYDROGEN_LOCAL}) = CURDATE() SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') AND cost_type = 2
THEN cost_expense ELSE 0 END) AS monthCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN customer_expense ELSE 0 END) AS monthRevenue,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN hydrogen_quantity ELSE 0 END) AS todayKg, THEN hydrogen_quantity ELSE 0 END) AS todayKg,
SUM(CASE WHEN DATE(${HYDROGEN_LOCAL}) = CURDATE() SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN cost_expense ELSE 0 END) AS todayFee, THEN cost_expense ELSE 0 END) AS todayFee,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE() AND cost_type = 2
THEN cost_expense ELSE 0 END) AS todayCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN customer_expense ELSE 0 END) AS todayRevenue,
SUM(CASE WHEN truck_id IS NOT NULL SUM(CASE WHEN truck_id IS NOT NULL
THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg, THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg,
SUM(CASE WHEN truck_id IS NOT NULL SUM(CASE WHEN truck_id IS NOT NULL
THEN cost_expense ELSE 0 END) AS lingniuBornFee THEN cost_expense ELSE 0 END) AS lingniuBornFee
FROM tab_energy_hydrogen_bill FROM tab_energy_hydrogen_bill
WHERE is_deleted = 0 AND hydrogen_time >= ?`, WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?`,
[HYDROGEN_MIN_DATE], [year, year, year, year, year, year, year,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
HYDROGEN_MIN_DATE],
); );
const k = kpiRows[0] ?? {}; const k = kpiRows[0] ?? {};
const yearFee = Number(k.yearFee) || 0;
const yearCustomerCost = Number(k.yearCustomerCost) || 0;
const yearRevenue = Number(k.yearRevenue) || 0;
const monthFee = Number(k.monthFee) || 0;
const monthCustomerCost = Number(k.monthCustomerCost) || 0;
const monthRevenue = Number(k.monthRevenue) || 0;
const todayFee = Number(k.todayFee) || 0;
const todayCustomerCost = Number(k.todayCustomerCost) || 0;
const todayRevenue = Number(k.todayRevenue) || 0;
const kpi = { const kpi = {
yearKg: Number(k.yearKg) || 0, yearKg: Number(k.yearKg) || 0,
yearFee: Number(k.yearFee) || 0, yearFee,
yearRevenue,
yearProfit: yearRevenue - yearCustomerCost,
ourYearKg: Number(k.ourYearKg) || 0, ourYearKg: Number(k.ourYearKg) || 0,
ourYearFee: Number(k.ourYearFee) || 0, ourYearFee: Number(k.ourYearFee) || 0,
customerYearKg: Number(k.customerYearKg) || 0, customerYearKg: Number(k.customerYearKg) || 0,
monthKg: Number(k.monthKg) || 0, monthKg: Number(k.monthKg) || 0,
monthFee: Number(k.monthFee) || 0, monthFee,
monthRevenue,
monthProfit: monthRevenue - monthCustomerCost,
todayKg: Number(k.todayKg) || 0, todayKg: Number(k.todayKg) || 0,
todayFee: Number(k.todayFee) || 0, todayFee,
todayRevenue,
todayProfit: todayRevenue - todayCustomerCost,
lingniuBornKg: Number(k.lingniuBornKg) || 0, lingniuBornKg: Number(k.lingniuBornKg) || 0,
lingniuBornFee: Number(k.lingniuBornFee) || 0, lingniuBornFee: Number(k.lingniuBornFee) || 0,
}; };
// Top5 加氢站(本年 // Top5 加氢站(指定年份
const [top5Rows] = await pool.query<RowDataPacket[]>( const [top5Rows] = await pool.query<RowDataPacket[]>(
`SELECT b.hydrogen_station_id AS id, `SELECT b.hydrogen_station_id AS id,
COALESCE(MAX(s.short_name), MAX(s.name), COALESCE(MAX(s.short_name), MAX(s.name),
@@ -120,12 +180,12 @@ app.get('/hydrogen/overview', async (c) => {
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
WHERE b.is_deleted = 0 WHERE b.is_deleted = 0
AND b.hydrogen_time >= ? AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.hydrogen_time) = YEAR(CURDATE()) AND YEAR(b.${HYDROGEN_LOCAL}) = ?
GROUP BY b.hydrogen_station_id GROUP BY b.hydrogen_station_id
ORDER BY kg DESC ORDER BY kg DESC
LIMIT 5`, LIMIT 5`,
[HYDROGEN_MIN_DATE], [HYDROGEN_MIN_DATE, year],
); );
const top5KgSum = kpi.yearKg || 1; const top5KgSum = kpi.yearKg || 1;
const top5 = top5Rows.map((r, i) => ({ const top5 = top5Rows.map((r, i) => ({
@@ -136,7 +196,38 @@ app.get('/hydrogen/overview', async (c) => {
share: (Number(r.kg) || 0) / top5KgSum, share: (Number(r.kg) || 0) / top5KgSum,
})); }));
// 区域占比(按城市,本年)— 取前 8其余合并为"其他" // 加氢站全量汇总(同年所有站,按加氢量降序)
const [stationFullRows] = await pool.query<RowDataPacket[]>(
`SELECT b.hydrogen_station_id AS id,
COALESCE(MAX(s.short_name), MAX(s.name),
MAX(os.fixed_station_name), MAX(os.station_name),
MAX(i.hydrogen_station_name),
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
SUM(b.hydrogen_quantity) AS kg,
SUM(b.customer_expense) AS revenue
FROM tab_energy_hydrogen_bill b
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
WHERE b.is_deleted = 0
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
GROUP BY b.hydrogen_station_id
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year],
);
const stationKgSum = stationFullRows.reduce((s, r) => s + (Number(r.kg) || 0), 0) || 1;
const stationRevSum = stationFullRows.reduce((s, r) => s + (Number(r.revenue) || 0), 0) || 1;
const stations = stationFullRows.map(r => ({
name: r.name as string,
kg: Number(r.kg) || 0,
revenue: Number(r.revenue) || 0,
share: (Number(r.kg) || 0) / stationKgSum,
revenueShare: (Number(r.revenue) || 0) / stationRevSum,
}));
// 区域占比(按城市,指定年份)— 取前 8其余合并为"其他"
const [regionRows] = await pool.query<RowDataPacket[]>( const [regionRows] = await pool.query<RowDataPacket[]>(
`SELECT region, SUM(kg) AS kg FROM ( `SELECT region, SUM(kg) AS kg FROM (
SELECT REPLACE(REPLACE(SUBSTRING_INDEX(COALESCE(s.city, os.city, '未知'), '-', -1), '市', ''), '省', '') AS region, SELECT REPLACE(REPLACE(SUBSTRING_INDEX(COALESCE(s.city, os.city, '未知'), '-', -1), '市', ''), '省', '') AS region,
@@ -145,12 +236,12 @@ app.get('/hydrogen/overview', async (c) => {
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
WHERE b.is_deleted = 0 WHERE b.is_deleted = 0
AND b.hydrogen_time >= ? AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.hydrogen_time) = YEAR(CURDATE()) AND YEAR(b.${HYDROGEN_LOCAL}) = ?
) r ) r
GROUP BY region GROUP BY region
ORDER BY kg DESC`, ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE], [HYDROGEN_MIN_DATE, year],
); );
const totalKg = regionRows.reduce((sum, r) => sum + (Number(r.kg) || 0), 0) || 1; const totalKg = regionRows.reduce((sum, r) => sum + (Number(r.kg) || 0), 0) || 1;
const TOP_REGIONS = 8; const TOP_REGIONS = 8;
@@ -165,8 +256,67 @@ app.get('/hydrogen/overview', async (c) => {
...(restKg > 0 ? [{ region: '其他', kg: restKg, share: restKg / totalKg }] : []), ...(restKg > 0 ? [{ region: '其他', kg: restKg, share: restKg / totalKg }] : []),
]; ];
return { kpi, top5, regions }; // 月度趋势(指定年份内 12 个月,缺失月补 0含成本/收入/利润
}); // 利润 = 客户单收入 - 客户单成本(仅 cost_type = 2
const [monthRows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
ROUND(SUM(hydrogen_quantity), 2) AS kg,
ROUND(SUM(cost_expense), 2) AS fee,
ROUND(SUM(CASE WHEN cost_type = 2 THEN cost_expense ELSE 0 END), 2) AS customerCost,
ROUND(SUM(customer_expense), 2) AS revenue
FROM tab_energy_hydrogen_bill
WHERE is_deleted = 0
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
GROUP BY m
ORDER BY m`,
[HYDROGEN_MIN_DATE, year],
);
const monthMap = new Map<string, { kg: number; fee: number; revenue: number; customerCost: number }>();
for (const r of monthRows) {
monthMap.set(r.m as string, {
kg: Number(r.kg) || 0,
fee: Number(r.fee) || 0,
revenue: Number(r.revenue) || 0,
customerCost: Number(r.customerCost) || 0,
});
}
const lastMonth = isCurrentYear ? today.getMonth() + 1 : 12;
const monthly: { month: string; kg: number; fee: number; revenue: number; profit: number }[] = [];
for (let mi = 1; mi <= lastMonth; mi++) {
const key = `${year}-${String(mi).padStart(2, '0')}`;
const v = monthMap.get(key) || { kg: 0, fee: 0, revenue: 0, customerCost: 0 };
monthly.push({ month: key, kg: v.kg, fee: v.fee, revenue: v.revenue, profit: v.revenue - v.customerCost });
}
// 客户账单 Top指定年份按加氢量降序前 30
// payercost_type=2 → 客户承担cost_type=3 → 羚牛承担;其他 → 客户(默认)
const [customerRows] = await pool.query<RowDataPacket[]>(
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
CASE WHEN MAX(cost_type) = 3 AND MIN(cost_type) = 3 THEN 'lingniu'
ELSE 'customer' END AS payer,
SUM(hydrogen_quantity) AS kg,
SUM(cost_expense) AS cost,
SUM(customer_expense) AS revenue
FROM tab_energy_hydrogen_bill
WHERE is_deleted = 0
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
GROUP BY name
ORDER BY kg DESC
LIMIT 30`,
[HYDROGEN_MIN_DATE, year],
);
const customers = customerRows.map(r => ({
name: r.name as string,
payer: (r.payer as string) === 'lingniu' ? 'lingniu' as const : 'customer' as const,
kg: Number(r.kg) || 0,
cost: Number(r.cost) || 0,
revenue: Number(r.revenue) || 0,
}));
return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
}, { force });
return c.json(data); return c.json(data);
}); });
@@ -176,6 +326,7 @@ app.get('/hydrogen/overview', async (c) => {
app.get('/hydrogen/daily', async (c) => { app.get('/hydrogen/daily', async (c) => {
const range = (c.req.query('range') || 'last15') as Range; const range = (c.req.query('range') || 'last15') as Range;
const customer = (c.req.query('customer') || 'external') as CustomerKind; const customer = (c.req.query('customer') || 'external') as CustomerKind;
const force = c.req.query('force') === '1';
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => { const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
@@ -286,7 +437,7 @@ app.get('/hydrogen/daily', async (c) => {
// 按日期降序返回 // 按日期降序返回
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date)); const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
return result; return result;
}); }, { force });
return c.json(data); return c.json(data);
}); });
@@ -294,6 +445,7 @@ app.get('/hydrogen/daily', async (c) => {
// 电能 总览KPI + 本月每日柱图数据 —— 数据源bi_ele_charge_record // 电能 总览KPI + 本月每日柱图数据 —— 数据源bi_ele_charge_record
// ========================================================= // =========================================================
app.get('/electric/overview', async (c) => { app.get('/electric/overview', async (c) => {
const force = c.req.query('force') === '1';
const data = await cached('electric/overview', async () => { const data = await cached('electric/overview', async () => {
const [kpiRows] = await pool.query<RowDataPacket[]>( const [kpiRows] = await pool.query<RowDataPacket[]>(
`SELECT `SELECT
@@ -367,7 +519,7 @@ app.get('/electric/overview', async (c) => {
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct }, kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
trend: trendArr, trend: trendArr,
}; };
}); }, { force });
return c.json(data); return c.json(data);
}); });
@@ -379,6 +531,7 @@ app.get('/electric/overview', async (c) => {
app.get('/electric/monthly', async (c) => { app.get('/electric/monthly', async (c) => {
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind; const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
const range = (c.req.query('range') || 'last15') as Range; const range = (c.req.query('range') || 'last15') as Range;
const force = c.req.query('force') === '1';
const data = await cached(`electric/monthly?customer=${customer}&range=${range}`, async () => { const data = await cached(`electric/monthly?customer=${customer}&range=${range}`, async () => {
@@ -453,7 +606,7 @@ app.get('/electric/monthly', async (c) => {
}); });
return months; return months;
}); }, { force });
return c.json(data); return c.json(data);
}); });

View File

@@ -19,8 +19,10 @@ app.get('/', async (c) => {
if (plates.length === 0) return c.json([]); if (plates.length === 0) return c.json([]);
} }
// 单车日里程负值视为脏数据(里程表回滚 / 换 GPS 设备),不纳入统计
let sql = ` let sql = `
SELECT DATE_FORMAT(stat_date, '%m-%d') as date, SUM(daily_km) as mileage SELECT DATE_FORMAT(stat_date, '%m-%d') as date,
SUM(IF(daily_km < 0, 0, daily_km)) as mileage
FROM v_vehicle_daily_stats FROM v_vehicle_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE() WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE()
`; `;

View File

@@ -13,6 +13,9 @@ export const SCHEDULING_ACCESS_ROLES = ['BI-SCHEDULE-OPT'];
/** 反馈管理(管理员)访问角色 */ /** 反馈管理(管理员)访问角色 */
export const FEEDBACK_ADMIN_ROLES = ['BI-ADMIN-FEEDBACK']; export const FEEDBACK_ADMIN_ROLES = ['BI-ADMIN-FEEDBACK'];
/** 能源管理模块访问角色 */
export const ENERGY_ACCESS_ROLES = ['BI-LEADER-ENERGY'];
/** 用户是否可访问智能调度模块。仅 BI-SCHEDULE-OPT 角色允许访问。 */ /** 用户是否可访问智能调度模块。仅 BI-SCHEDULE-OPT 角色允许访问。 */
export function canAccessScheduling(roles: readonly string[] | null | undefined): boolean { export function canAccessScheduling(roles: readonly string[] | null | undefined): boolean {
if (!roles || roles.length === 0) return false; if (!roles || roles.length === 0) return false;
@@ -24,3 +27,10 @@ export function canManageFeedback(roles: readonly string[] | null | undefined):
if (!roles || roles.length === 0) return false; if (!roles || roles.length === 0) return false;
return roles.some(r => FEEDBACK_ADMIN_ROLES.includes(r) || FULL_ACCESS_ROLES.includes(r)); return roles.some(r => FEEDBACK_ADMIN_ROLES.includes(r) || FULL_ACCESS_ROLES.includes(r));
} }
/** 用户是否可访问能源管理模块。仅 BI-LEADER-ENERGY 或「所有权限」可访问。 */
const ENERGY_FULL_ACCESS = '所有权限';
export function canAccessEnergy(roles: readonly string[] | null | undefined): boolean {
if (!roles || roles.length === 0) return false;
return roles.some(r => ENERGY_ACCESS_ROLES.includes(r) || r === ENERGY_FULL_ACCESS);
}