Compare commits
11 Commits
4153f329b8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
433a75f9d1 | ||
|
|
0193e78f18 | ||
|
|
2a851fc243 | ||
|
|
6142af7617 | ||
|
|
26f7d7ab3f | ||
|
|
f06b0d21eb | ||
|
|
6ad4b5e2a4 | ||
|
|
ad8ec50038 | ||
|
|
dc6f541c8b | ||
|
|
034654265c | ||
|
|
5958bb581e |
15
src/App.tsx
15
src/App.tsx
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 => (
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 => (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -1,44 +1,135 @@
|
|||||||
/**
|
/**
|
||||||
* 简单 TTL 内存缓存。
|
* SWR 缓存:始终返回热数据,后台定时刷新。
|
||||||
* 命中:直接返回缓存值;过期或未命中:运行 loader、存入缓存。
|
*
|
||||||
* 同一 key 并发请求只会触发一次 loader(共享 in-flight Promise)。
|
* 工作机制:
|
||||||
|
* - 首次请求:阻塞等待 loader(cold start,3-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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
// payer:cost_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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user