Compare commits
59 Commits
3809e785c1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
433a75f9d1 | ||
|
|
0193e78f18 | ||
|
|
2a851fc243 | ||
|
|
6142af7617 | ||
|
|
26f7d7ab3f | ||
|
|
f06b0d21eb | ||
|
|
6ad4b5e2a4 | ||
|
|
ad8ec50038 | ||
|
|
dc6f541c8b | ||
|
|
034654265c | ||
|
|
5958bb581e | ||
|
|
4153f329b8 | ||
|
|
ee981639eb | ||
|
|
fe70ec389b | ||
|
|
c3b463d9ca | ||
|
|
f2acb73033 | ||
|
|
015ff9bc7e | ||
|
|
d24ce55a59 | ||
|
|
e0183986ee | ||
|
|
234b44ea03 | ||
|
|
8d861538af | ||
|
|
bdefb878a5 | ||
|
|
2aeff0c2f4 | ||
|
|
9bbd11cc86 | ||
|
|
1a3d48b2d1 | ||
|
|
c5541fbbf5 | ||
|
|
90b34b681e | ||
|
|
90b1266fe5 | ||
|
|
20ebb16e08 | ||
|
|
e8f1604c11 | ||
|
|
08f21b7e24 | ||
|
|
57207debfb | ||
|
|
d3fa2fd4d6 | ||
|
|
8b4fb6563f | ||
|
|
e187c0d02e | ||
|
|
3d4d862d73 | ||
|
|
23a7722583 | ||
|
|
213037c2ac | ||
|
|
3efa701395 | ||
|
|
e775acb8fe | ||
|
|
c788dd4577 | ||
|
|
3851335843 | ||
|
|
d0a644cf18 | ||
|
|
0d30ee2df5 | ||
|
|
9a20a7cb79 | ||
|
|
d1d79f1c7c | ||
|
|
5217e19b25 | ||
|
|
57fdd346cf | ||
|
|
d8189329ac | ||
|
|
e2d04db06d | ||
|
|
5493e27e49 | ||
|
|
355c45a2e4 | ||
|
|
66779a98e3 | ||
|
|
97ac92a0da | ||
|
|
7ca8ef24dc | ||
|
|
f9c6155ea7 | ||
|
|
cab86556f3 | ||
|
|
e0c609168e | ||
|
|
ebd82893bc |
938
package-lock.json
generated
938
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.0",
|
"@hono/node-server": "^1.13.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"ali-oss": "^6.23.0",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
"hono": "^4.7.0",
|
"hono": "^4.7.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@types/ali-oss": "^6.23.3",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
|||||||
44
src/App.tsx
44
src/App.tsx
@@ -1,33 +1,59 @@
|
|||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Truck, Route, Activity, Zap } from 'lucide-react';
|
import { Truck, Route, Activity, Zap } from 'lucide-react';
|
||||||
import { Shell, type ModuleConfig } from './components/Shell';
|
import { Shell, type ModuleConfig } from './components/Shell';
|
||||||
import AssetsModule from './modules/assets/AssetsModule';
|
import AssetsModule from './modules/assets/AssetsModule';
|
||||||
import MileageModule from './modules/mileage/MileageModule';
|
import MileageModule from './modules/mileage/MileageModule';
|
||||||
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
||||||
import EnergyModule from './modules/energy/EnergyModule';
|
import EnergyModule from './modules/energy/EnergyModule';
|
||||||
|
import EleImportPage from './modules/ele/EleImportPage';
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getRouteKey(): string {
|
||||||
|
if (typeof window === 'undefined') return '';
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (path === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') return 'ele/import';
|
||||||
|
if (path === '/admin/feedback' || hash === '#/admin/feedback' || hash === '#admin/feedback') return 'admin/feedback';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function AuthGate() {
|
function AuthGate() {
|
||||||
const { isLoading, isAuthenticated, error, user } = useAuth();
|
const { isLoading, isAuthenticated, error, user } = useAuth();
|
||||||
|
const [routeKey, setRouteKey] = useState(getRouteKey);
|
||||||
|
|
||||||
|
// 监听 hashchange / popstate,让 a href="#/..." 跳转能即时生效
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => setRouteKey(getRouteKey());
|
||||||
|
window.addEventListener('hashchange', update);
|
||||||
|
window.addEventListener('popstate', update);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('hashchange', update);
|
||||||
|
window.removeEventListener('popstate', update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
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) {
|
||||||
@@ -45,6 +71,10 @@ function AuthGate() {
|
|||||||
return <UnauthorizedPage message={error || undefined} />;
|
return <UnauthorizedPage message={error || undefined} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
||||||
|
if (routeKey === 'ele/import') return <EleImportPage />;
|
||||||
|
if (routeKey === 'admin/feedback') return <FeedbackAdminPage />;
|
||||||
|
|
||||||
return <Shell modules={modules} />;
|
return <Shell modules={modules} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
userName: '本地开发',
|
userName: '本地开发',
|
||||||
permissionLevel: 'full',
|
permissionLevel: 'full',
|
||||||
depName: '',
|
depName: '',
|
||||||
roles: ['所有权限', 'BI-SCHEDULE-OPT'],
|
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
|
||||||
},
|
},
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|||||||
511
src/components/FeedbackFab.tsx
Normal file
511
src/components/FeedbackFab.tsx
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import {
|
||||||
|
MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles,
|
||||||
|
ImagePlus, Loader2, Inbox, Lightbulb, Bug, Palette, NotebookPen, Settings2,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { fetchJson } from '../auth/api-client';
|
||||||
|
import { useAuth } from '../auth/useAuth';
|
||||||
|
import { canManageFeedback } from '../shared/auth/roles';
|
||||||
|
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
|
||||||
|
import RotatingFooterHint from './RotatingFooterHint';
|
||||||
|
|
||||||
|
const MAX_SCREENSHOTS = 6;
|
||||||
|
const MAX_IMG_SIZE_MB = 5;
|
||||||
|
|
||||||
|
interface UploadedImg {
|
||||||
|
url: string;
|
||||||
|
thumbDataUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImage(file: File): Promise<string> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const token = sessionStorage.getItem('bi_jwt');
|
||||||
|
const res = await fetch('/api/feedback/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`);
|
||||||
|
return json.url as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAsDataUrl(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = () => resolve(String(r.result || ''));
|
||||||
|
r.onerror = reject;
|
||||||
|
r.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
|
||||||
|
|
||||||
|
interface TypeOption {
|
||||||
|
key: FeedbackType;
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
sub: string;
|
||||||
|
iconBg: string;
|
||||||
|
iconFg: string;
|
||||||
|
ring: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_OPTIONS: TypeOption[] = [
|
||||||
|
{ key: 'dimension', icon: Lightbulb, label: '想看新的统计维度', sub: '比如按 XX 维度切片',
|
||||||
|
iconBg: 'bg-amber-50', iconFg: 'text-amber-500', ring: 'ring-amber-200' },
|
||||||
|
{ key: 'bug', icon: Bug, label: '报告一个 Bug', sub: '哪里看着不对劲',
|
||||||
|
iconBg: 'bg-rose-50', iconFg: 'text-rose-500', ring: 'ring-rose-200' },
|
||||||
|
{ key: 'ux', icon: Palette, label: '界面 / 体验建议', sub: '哪里能更顺手',
|
||||||
|
iconBg: 'bg-violet-50', iconFg: 'text-violet-500', ring: 'ring-violet-200' },
|
||||||
|
{ key: 'other', icon: NotebookPen, label: '其他想法', sub: '欢迎随便聊聊',
|
||||||
|
iconBg: 'bg-blue-50', iconFg: 'text-blue-500', ring: 'ring-blue-200' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODULE_LABELS: Record<string, string> = {
|
||||||
|
assets: '资产管理',
|
||||||
|
mileage: '里程管理',
|
||||||
|
energy: '能源管理',
|
||||||
|
scheduling: '智能调度',
|
||||||
|
ele: '充电导入',
|
||||||
|
'': '通用',
|
||||||
|
};
|
||||||
|
|
||||||
|
function detectModule(): string {
|
||||||
|
const hash = (window.location.hash || '').slice(1);
|
||||||
|
const path = window.location.pathname;
|
||||||
|
if (path.includes('/ele/')) return 'ele';
|
||||||
|
if (hash.includes('ele')) return 'ele';
|
||||||
|
if (hash.startsWith('/')) return hash.split('/')[1] || '';
|
||||||
|
return hash || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 显式覆盖当前模块(否则自动从 URL 检测) */
|
||||||
|
module?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdmin = canManageFeedback(user?.roles);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [step, setStep] = useState<1 | 2 | 3>(1); // 1=选类型, 2=写内容, 3=成功页
|
||||||
|
const [type, setType] = useState<FeedbackType | null>(null);
|
||||||
|
const [mod, setMod] = useState<string>('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [shots, setShots] = useState<UploadedImg[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const addFiles = async (files: FileList | File[]) => {
|
||||||
|
const list = Array.from(files).filter(f => f.type.startsWith('image/'));
|
||||||
|
if (list.length === 0) return;
|
||||||
|
setError(null);
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
for (const f of list) {
|
||||||
|
if (shots.length >= MAX_SCREENSHOTS) break;
|
||||||
|
if (f.size > MAX_IMG_SIZE_MB * 1024 * 1024) {
|
||||||
|
setError(`「${f.name}」超过 ${MAX_IMG_SIZE_MB}MB`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const thumbDataUrl = await readAsDataUrl(f);
|
||||||
|
const url = await uploadImage(f);
|
||||||
|
setShots(prev => prev.length >= MAX_SCREENSHOTS ? prev : [...prev, { url, thumbDataUrl }]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open: detect current module
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && step === 1) {
|
||||||
|
setMod(moduleProp ?? detectModule());
|
||||||
|
}
|
||||||
|
}, [open, step, moduleProp]);
|
||||||
|
|
||||||
|
// Lock scroll when open
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = prev; };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setStep(1);
|
||||||
|
setType(null);
|
||||||
|
setContent('');
|
||||||
|
setShots([]);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setOpen(false);
|
||||||
|
setTimeout(reset, 300); // 等动画完
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!type || !content.trim()) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await fetchJson('/api/feedback/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type,
|
||||||
|
module: mod || null,
|
||||||
|
content: content.trim(),
|
||||||
|
screenshots: shots.map(s => s.url),
|
||||||
|
userAgent: navigator.userAgent.slice(0, 500),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setStep(3);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
if (step === 1) {
|
||||||
|
if (!type) return;
|
||||||
|
setStep(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === 2) {
|
||||||
|
if (!content.trim()) { taRef.current?.focus(); return; }
|
||||||
|
submit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const back = () => setStep((Math.max(1, step - 1)) as typeof step);
|
||||||
|
|
||||||
|
const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true;
|
||||||
|
const progress = step >= 3 ? 100 : (step / 2) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Floating Action Button */}
|
||||||
|
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
|
||||||
|
<motion.button
|
||||||
|
initial={{ scale: 0, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
|
||||||
|
whileHover={{ scale: 1.08 }}
|
||||||
|
whileTap={{ scale: 0.92 }}
|
||||||
|
onClick={() => setMenuOpen(m => !m)}
|
||||||
|
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
|
||||||
|
aria-label="反馈"
|
||||||
|
title="提建议 / 我的反馈"
|
||||||
|
>
|
||||||
|
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{menuOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => { setMenuOpen(false); setOpen(true); }}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
|
||||||
|
>
|
||||||
|
<Sparkles size={14} className="text-blue-500" /> 提个建议
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
|
||||||
|
>
|
||||||
|
<Inbox size={14} className="text-emerald-500" /> 我的反馈
|
||||||
|
</button>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<div className="h-px bg-slate-100 my-0.5" />
|
||||||
|
<a
|
||||||
|
href="#/admin/feedback"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-violet-50 hover:text-violet-600"
|
||||||
|
>
|
||||||
|
<Settings2 size={14} className="text-violet-500" /> 反馈管理
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 点击外面关菜单 */}
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[-1]"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[90] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||||
|
// 不允许点击背景关闭:避免用户输入到一半误触遮罩丢失内容
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: '100%', opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: '100%', opacity: 0 }}
|
||||||
|
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
||||||
|
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
<div className="flex justify-center pt-2.5 pb-1">
|
||||||
|
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header + progress */}
|
||||||
|
<div className="px-4 pb-3 border-b border-slate-100">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
|
||||||
|
<Sparkles size={14} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-black text-slate-800 leading-tight">
|
||||||
|
{step === 3 ? '收到啦~' : '提个建议'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold">
|
||||||
|
{step === 1 && '第一步 / 共 2 步'}
|
||||||
|
{step === 2 && '第二步 / 共 2 步'}
|
||||||
|
{step === 3 && '感谢你的反馈'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={close} className="p-1.5 -mr-1 text-slate-400 hover:text-slate-700">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={false}
|
||||||
|
animate={{ width: `${progress}%` }}
|
||||||
|
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{step === 1 && (
|
||||||
|
<motion.div key="s1" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }}>
|
||||||
|
<p className="text-[13px] font-bold text-slate-700 mb-1.5">想反馈点什么?</p>
|
||||||
|
<RotatingFooterHint className="justify-start mb-4" />
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{TYPE_OPTIONS.map((opt, i) => {
|
||||||
|
const Icon = opt.icon;
|
||||||
|
const selected = type === opt.key;
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={opt.key}
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: i * 0.04, duration: 0.2 }}
|
||||||
|
whileHover={{ y: -1 }}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
onClick={() => { setType(opt.key); setStep(2); }}
|
||||||
|
className={`text-left p-3.5 rounded-2xl border bg-white transition-all flex items-center gap-3 group ${selected ? `ring-2 ${opt.ring} border-transparent shadow-sm` : 'border-slate-100 hover:border-slate-200 hover:shadow-sm'}`}
|
||||||
|
>
|
||||||
|
<div className={`w-10 h-10 rounded-xl ${opt.iconBg} flex items-center justify-center flex-shrink-0`}>
|
||||||
|
<Icon size={18} className={opt.iconFg} strokeWidth={2.2} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[13px] font-bold text-slate-800 leading-tight">{opt.label}</div>
|
||||||
|
<div className="text-[11px] text-slate-400 font-medium mt-0.5">{opt.sub}</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={15} className="text-slate-300 flex-shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<motion.div key="s2" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.2 }} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] font-bold text-slate-600 mb-2">说说具体内容</p>
|
||||||
|
<textarea
|
||||||
|
ref={taRef}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onPaste={(e) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
const imgs: File[] = [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const it = items[i];
|
||||||
|
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
||||||
|
const f = it.getAsFile();
|
||||||
|
if (f) imgs.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (imgs.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
addFiles(imgs);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
rows={5}
|
||||||
|
maxLength={1000}
|
||||||
|
placeholder={
|
||||||
|
type === 'dimension' ? '比如:希望按客户/区域/日期范围 等等切片看里程数据…'
|
||||||
|
: type === 'bug' ? '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)'
|
||||||
|
: type === 'ux' ? '比如:能不能把外部 tab 默认收起,加载快一点…'
|
||||||
|
: '随便聊聊你的想法'
|
||||||
|
}
|
||||||
|
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
|
||||||
|
/>
|
||||||
|
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 截图上传 */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase">截图(可选)</p>
|
||||||
|
<span className="text-[10px] text-slate-300 font-bold">{shots.length}/{MAX_SCREENSHOTS},可粘贴</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{shots.map((s, i) => (
|
||||||
|
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
|
||||||
|
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
<button
|
||||||
|
onClick={() => setShots(prev => prev.filter((_, idx) => idx !== i))}
|
||||||
|
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{shots.length < MAX_SCREENSHOTS && (
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
|
||||||
|
>
|
||||||
|
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
|
||||||
|
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">所在板块</p>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{Object.entries(MODULE_LABELS).map(([k, label]) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
onClick={() => setMod(k)}
|
||||||
|
className={`px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all ${mod === k ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-slate-200 text-slate-500 hover:border-slate-300'}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<motion.div key="s3" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.3 }} className="text-center py-6">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0, rotate: -180 }}
|
||||||
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
|
transition={{ type: 'spring', damping: 12, stiffness: 200, delay: 0.1 }}
|
||||||
|
className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-400 to-cyan-400 mx-auto flex items-center justify-center mb-3"
|
||||||
|
>
|
||||||
|
<Check size={28} strokeWidth={3} className="text-white" />
|
||||||
|
</motion.div>
|
||||||
|
<div className="text-base font-black text-slate-800 mb-1">谢谢你的反馈 ❤️</div>
|
||||||
|
<div className="text-[12px] text-slate-500 font-bold leading-relaxed">产品同学会认真看每一条<br />有进展可以在「我的反馈」里查看</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{step < 3 && (
|
||||||
|
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
|
||||||
|
{step > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={back}
|
||||||
|
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} /> 上一步
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
onClick={next}
|
||||||
|
disabled={!canNext || submitting}
|
||||||
|
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{submitting ? '提交中…' : step === 2 ? '提交' : '下一步'}
|
||||||
|
{!submitting && step !== 2 && <ChevronRight size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="px-4 py-3 border-t border-slate-100">
|
||||||
|
<button
|
||||||
|
onClick={close}
|
||||||
|
className="w-full py-2.5 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100"
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
src/components/FeedbackHistoryDrawer.tsx
Normal file
163
src/components/FeedbackHistoryDrawer.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import { X, MailOpen, Loader2, ArrowLeft } from 'lucide-react';
|
||||||
|
import { fetchJson } from '../auth/api-client';
|
||||||
|
|
||||||
|
interface FeedbackItem {
|
||||||
|
id: number;
|
||||||
|
type: 'dimension' | 'bug' | 'ux' | 'other';
|
||||||
|
module: string | null;
|
||||||
|
content: string;
|
||||||
|
contact: string | null;
|
||||||
|
screenshots: string[] | string | null;
|
||||||
|
status: 'open' | 'in_progress' | 'done' | 'rejected';
|
||||||
|
reply_content: string | null;
|
||||||
|
reply_user: string | null;
|
||||||
|
reply_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
|
dimension: '💡 新维度',
|
||||||
|
bug: '🐛 Bug',
|
||||||
|
ux: '🎨 体验',
|
||||||
|
other: '📝 其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
open: '待处理',
|
||||||
|
in_progress: '处理中',
|
||||||
|
done: '已完成',
|
||||||
|
rejected: '已忽略',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_STYLE: Record<string, string> = {
|
||||||
|
open: 'bg-slate-100 text-slate-500',
|
||||||
|
in_progress: 'bg-amber-100 text-amber-600',
|
||||||
|
done: 'bg-emerald-100 text-emerald-600',
|
||||||
|
rejected: 'bg-rose-100 text-rose-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onBack?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
|
||||||
|
if (!s) return [];
|
||||||
|
if (Array.isArray(s)) return s;
|
||||||
|
try { return JSON.parse(String(s)); } catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackHistoryDrawer({ open, onClose, onBack }: Props) {
|
||||||
|
const [items, setItems] = useState<FeedbackItem[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setItems(null);
|
||||||
|
setError(null);
|
||||||
|
fetchJson<{ items: FeedbackItem[] }>('/api/feedback/mine')
|
||||||
|
.then(d => setItems(d.items))
|
||||||
|
.catch(e => setError(e instanceof Error ? e.message : String(e)));
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[100] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: '100%', opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: '100%', opacity: 0 }}
|
||||||
|
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
||||||
|
className="bg-white w-full md:max-w-lg md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex justify-center pt-2.5 pb-1">
|
||||||
|
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-3 border-b border-slate-100 flex items-center gap-2">
|
||||||
|
{onBack && (
|
||||||
|
<button onClick={onBack} className="p-1 -ml-1 text-slate-500 hover:text-slate-700">
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center">
|
||||||
|
<MailOpen size={14} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-black text-slate-800 leading-tight">我的反馈</div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold">查看进展与回复</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-1.5 text-slate-400 hover:text-slate-700">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||||
|
{error ? (
|
||||||
|
<div className="bg-rose-50 text-rose-600 rounded-xl p-3 text-[12px] font-bold">{error}</div>
|
||||||
|
) : items === null ? (
|
||||||
|
<div className="py-10 text-center text-slate-400 text-[12px] font-bold flex items-center justify-center gap-1.5">
|
||||||
|
<Loader2 size={14} className="animate-spin" /> 加载中…
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="py-10 text-center text-slate-300 text-[12px] font-bold">还没有提过反馈</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{items.map(it => {
|
||||||
|
const shots = parseScreenshots(it.screenshots);
|
||||||
|
return (
|
||||||
|
<div key={it.id} className="bg-slate-50 rounded-xl p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span className="text-[10px] font-bold text-slate-500">{TYPE_LABEL[it.type] || it.type}</span>
|
||||||
|
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${STATUS_STYLE[it.status]}`}>
|
||||||
|
{STATUS_LABEL[it.status]}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 ml-auto">
|
||||||
|
{(it.created_at || '').replace('T', ' ').slice(0, 16)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">
|
||||||
|
{it.content}
|
||||||
|
</div>
|
||||||
|
{shots.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{shots.map((url, i) => (
|
||||||
|
<a key={i} href={url} target="_blank" rel="noreferrer" className="block w-12 h-12 rounded overflow-hidden border border-slate-200">
|
||||||
|
<img src={url} alt="" className="w-full h-full object-cover" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{it.reply_content && (
|
||||||
|
<div className="bg-blue-50 border border-blue-100 rounded-lg p-2.5 mt-1">
|
||||||
|
<div className="text-[10px] font-bold text-blue-500 mb-0.5">
|
||||||
|
{it.reply_user || '产品同学'} 回复
|
||||||
|
{it.reply_at && <span className="text-blue-300 ml-1">{(it.reply_at || '').replace('T', ' ').slice(0, 16)}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">
|
||||||
|
{it.reply_content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/RotatingFooterHint.tsx
Normal file
51
src/components/RotatingFooterHint.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const FOOTER_HINTS = [
|
||||||
|
'想看哪个角度的数据?告诉我们一下嘛',
|
||||||
|
'更多统计维度接入中,欢迎您的建议 ~',
|
||||||
|
'下一个图表,可能就是您建议的那个',
|
||||||
|
'数据科学家正在深夜挖掘新维度…',
|
||||||
|
'维度灵感正在路上,钉一下产品同学也行',
|
||||||
|
'数字背后还有故事,等下一次上线揭晓',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 自定义提示词集合,默认使用通用文案 */
|
||||||
|
hints?: string[];
|
||||||
|
/** 切换间隔,默认 4 秒 */
|
||||||
|
intervalMs?: number;
|
||||||
|
/** 额外类名 */
|
||||||
|
className?: string;
|
||||||
|
/** 点击时回调(一般用来打开反馈弹窗) */
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RotatingFooterHint({ hints = FOOTER_HINTS, intervalMs = 4000, className = '', onClick }: Props) {
|
||||||
|
const [idx, setIdx] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (hints.length <= 1) return;
|
||||||
|
const t = setInterval(() => setIdx(i => (i + 1) % hints.length), intervalMs);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [hints, intervalMs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1.5 text-[11px] text-slate-400 font-bold ${onClick ? 'cursor-pointer hover:text-blue-500 transition-colors' : ''} ${className || 'mt-1 justify-center'}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
style={{ animation: 'rotatingHintFade 0.5s ease' }}
|
||||||
|
>
|
||||||
|
{hints[idx]}
|
||||||
|
</span>
|
||||||
|
<style>{`
|
||||||
|
@keyframes rotatingHintFade {
|
||||||
|
from { opacity: 0; transform: translateY(2px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useMemo, type ComponentType } from 'react';
|
import { useState, useEffect, useMemo, type ComponentType } from 'react';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { DemoModeProvider } from './Blur';
|
import { DemoModeProvider } from './Blur';
|
||||||
|
import FeedbackFab from './FeedbackFab';
|
||||||
|
|
||||||
export interface ModuleConfig {
|
export interface ModuleConfig {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -106,6 +107,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
|||||||
{/* 内容区 */}
|
{/* 内容区 */}
|
||||||
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
|
<main className="flex-1 md:ml-16 pb-16 md:pb-0 min-w-0" style={{ overflowX: 'clip' }}>
|
||||||
{ActiveComponent && <ActiveComponent />}
|
{ActiveComponent && <ActiveComponent />}
|
||||||
|
<FeedbackFab module={activeModule} />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* 移动端底部导航 (md 以下) */}
|
{/* 移动端底部导航 (md 以下) */}
|
||||||
|
|||||||
341
src/modules/admin/FeedbackAdminPage.tsx
Normal file
341
src/modules/admin/FeedbackAdminPage.tsx
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react';
|
||||||
|
import { fetchJson } from '../../auth/api-client';
|
||||||
|
|
||||||
|
interface FeedbackItem {
|
||||||
|
id: number;
|
||||||
|
type: 'dimension' | 'bug' | 'ux' | 'other';
|
||||||
|
module: string | null;
|
||||||
|
content: string;
|
||||||
|
contact: string | null;
|
||||||
|
screenshots: string[] | string | null;
|
||||||
|
user_id: string | null;
|
||||||
|
user_name: string | null;
|
||||||
|
status: 'open' | 'in_progress' | 'done' | 'rejected';
|
||||||
|
reply_content: string | null;
|
||||||
|
reply_user: string | null;
|
||||||
|
reply_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
|
dimension: '💡 新维度',
|
||||||
|
bug: '🐛 Bug',
|
||||||
|
ux: '🎨 体验',
|
||||||
|
other: '📝 其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
|
||||||
|
{ key: 'open', label: '待处理', cls: 'bg-slate-100 text-slate-500 border-slate-200' },
|
||||||
|
{ key: 'in_progress', label: '处理中', cls: 'bg-amber-100 text-amber-600 border-amber-200' },
|
||||||
|
{ key: 'done', label: '已完成', cls: 'bg-emerald-100 text-emerald-600 border-emerald-200' },
|
||||||
|
{ key: 'rejected', label: '已忽略', cls: 'bg-rose-100 text-rose-500 border-rose-200' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODULE_LABELS: Record<string, string> = {
|
||||||
|
assets: '资产管理',
|
||||||
|
mileage: '里程管理',
|
||||||
|
energy: '能源管理',
|
||||||
|
scheduling: '智能调度',
|
||||||
|
ele: '充电导入',
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
|
||||||
|
if (!s) return [];
|
||||||
|
if (Array.isArray(s)) return s;
|
||||||
|
try { return JSON.parse(String(s)); } catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchItem(id: number, data: { status?: string; reply?: string }): Promise<void> {
|
||||||
|
const token = sessionStorage.getItem('bi_jwt');
|
||||||
|
const res = await fetch(`/api/feedback/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok || !json.ok) throw new Error(json.message || `更新失败 (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedbackAdminPage() {
|
||||||
|
const [items, setItems] = useState<FeedbackItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'' | FeedbackItem['status']>('');
|
||||||
|
const [active, setActive] = useState<FeedbackItem | null>(null);
|
||||||
|
const [replyDraft, setReplyDraft] = useState('');
|
||||||
|
const [replyStatus, setReplyStatus] = useState<FeedbackItem['status']>('done');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [hint, setHint] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (statusFilter) params.set('status', statusFilter);
|
||||||
|
params.set('limit', '200');
|
||||||
|
const d = await fetchJson<{ items: FeedbackItem[] }>(`/api/feedback/list?${params.toString()}`);
|
||||||
|
setItems(d.items);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [statusFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { reload(); }, [reload]);
|
||||||
|
|
||||||
|
const open = (it: FeedbackItem) => {
|
||||||
|
setActive(it);
|
||||||
|
setReplyDraft(it.reply_content || '');
|
||||||
|
setReplyStatus(it.status === 'open' ? 'done' : it.status);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!active) return;
|
||||||
|
setSaving(true);
|
||||||
|
setHint(null);
|
||||||
|
try {
|
||||||
|
await patchItem(active.id, { status: replyStatus, reply: replyDraft });
|
||||||
|
setHint('已保存');
|
||||||
|
setActive(null);
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
setHint(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
setTimeout(() => setHint(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatusOnly = async (it: FeedbackItem, status: FeedbackItem['status']) => {
|
||||||
|
try {
|
||||||
|
await patchItem(it.id, { status });
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
setHint(e instanceof Error ? e.message : String(e));
|
||||||
|
setTimeout(() => setHint(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const counters = items.reduce<Record<string, number>>((m, it) => {
|
||||||
|
m[it.status] = (m[it.status] || 0) + 1;
|
||||||
|
return m;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FB] p-4 md:p-8">
|
||||||
|
<div className="max-w-5xl mx-auto space-y-4">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// 优先 history.back(来自 SPA 内部跳转);否则回到主页
|
||||||
|
if (window.history.length > 1) window.history.back();
|
||||||
|
else { window.location.hash = '#mileage'; }
|
||||||
|
}}
|
||||||
|
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
|
||||||
|
title="返回"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Inbox size={18} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-lg font-black text-slate-900 leading-tight">用户反馈管理</h1>
|
||||||
|
<p className="text-[11px] font-bold text-slate-400">查看、回复、跟进用户提交的建议</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={reload} className="p-2 text-slate-400 hover:text-blue-500 flex-shrink-0" title="刷新">
|
||||||
|
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 状态过滤 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-2 flex items-center gap-1 overflow-x-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||||
|
>全部 {items.length}</button>
|
||||||
|
{STATUS_OPTIONS.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
onClick={() => setStatusFilter(statusFilter === o.key ? '' : o.key)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === o.key ? `${o.cls} border` : 'text-slate-500 hover:bg-slate-50'}`}
|
||||||
|
>
|
||||||
|
{o.label} {counters[o.key] ?? 0}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600">
|
||||||
|
<AlertCircle size={14} /> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AnimatePresence>
|
||||||
|
{hint && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
className="bg-emerald-50 border border-emerald-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-emerald-600"
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={14} /> {hint}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 列表 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{loading && items.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">加载中…</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">还没有反馈</div>
|
||||||
|
) : items.map(it => {
|
||||||
|
const shots = parseScreenshots(it.screenshots);
|
||||||
|
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={it.id}
|
||||||
|
className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 cursor-pointer hover:border-blue-200 transition-colors"
|
||||||
|
onClick={() => open(it)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap mb-1.5">
|
||||||
|
<span className="text-[11px] font-bold text-slate-500">{TYPE_LABEL[it.type] || it.type}</span>
|
||||||
|
{it.module && <span className="text-[10px] text-slate-400 font-bold">{MODULE_LABELS[it.module] || it.module}</span>}
|
||||||
|
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border ${statusOpt?.cls || 'bg-slate-50 text-slate-400 border-slate-200'}`}>
|
||||||
|
{statusOpt?.label || it.status}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 ml-auto">
|
||||||
|
{(it.user_name || it.user_id || '匿名')} · {(it.created_at || '').replace('T', ' ').slice(0, 16)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-slate-700 leading-relaxed line-clamp-2 break-words">{it.content}</div>
|
||||||
|
{(shots.length > 0 || it.contact) && (
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
|
||||||
|
{shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} 张</span>}
|
||||||
|
{it.contact && <span>📞 {it.contact}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{it.reply_content && (
|
||||||
|
<div className="bg-blue-50 border border-blue-100 rounded-lg px-2.5 py-1.5 mt-2 text-[11px] text-slate-600 line-clamp-1">
|
||||||
|
回复: {it.reply_content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{it.status === 'open' && (
|
||||||
|
<div className="flex gap-1 mt-2 pt-2 border-t border-slate-50" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button onClick={() => setStatusOnly(it, 'in_progress')} className="flex-1 px-2 py-1 rounded text-[10px] font-bold bg-amber-50 text-amber-600 hover:bg-amber-100">标记处理中</button>
|
||||||
|
<button onClick={() => setStatusOnly(it, 'rejected')} className="px-2 py-1 rounded text-[10px] font-bold bg-rose-50 text-rose-500 hover:bg-rose-100">忽略</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 详情 / 回复弹窗 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{active && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[80] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||||
|
onClick={() => setActive(null)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: '100%', opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: '100%', opacity: 0 }}
|
||||||
|
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
||||||
|
className="bg-white w-full md:max-w-xl md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex justify-center pt-2.5 pb-1">
|
||||||
|
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-3 border-b border-slate-100 flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-black text-slate-800 leading-tight">反馈详情 #{active.id}</div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-bold">
|
||||||
|
{active.user_name || active.user_id || '匿名'} · {(active.created_at || '').replace('T', ' ').slice(0, 16)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setActive(null)} className="p-1.5 text-slate-400 hover:text-slate-700">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
|
||||||
|
<div className="bg-slate-50 rounded-xl p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap text-[10px] font-bold">
|
||||||
|
<span className="text-slate-500">{TYPE_LABEL[active.type] || active.type}</span>
|
||||||
|
{active.module && <span className="text-slate-400">板块: {MODULE_LABELS[active.module] || active.module}</span>}
|
||||||
|
{active.contact && <span className="text-slate-400">联系: {active.contact}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">{active.content}</div>
|
||||||
|
{parseScreenshots(active.screenshots).length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
{parseScreenshots(active.screenshots).map((u, i) => (
|
||||||
|
<a key={i} href={u} target="_blank" rel="noreferrer" className="block w-20 h-20 rounded-lg overflow-hidden border border-slate-200">
|
||||||
|
<img src={u} alt="" className="w-full h-full object-cover" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">状态</p>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{STATUS_OPTIONS.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
onClick={() => setReplyStatus(o.key)}
|
||||||
|
className={`px-3 py-1 rounded-full text-[11px] font-bold border ${replyStatus === o.key ? o.cls : 'bg-white text-slate-500 border-slate-200 hover:border-slate-300'}`}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">回复内容</p>
|
||||||
|
<textarea
|
||||||
|
value={replyDraft}
|
||||||
|
onChange={(e) => setReplyDraft(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder="给用户的回复(用户在「我的反馈」里能看到)"
|
||||||
|
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
|
||||||
|
/>
|
||||||
|
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{replyDraft.length} / 2000</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
onClick={() => setActive(null)}
|
||||||
|
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50"
|
||||||
|
>取消</button>
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Send size={13} />}
|
||||||
|
{saving ? '保存中…' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import type { WeeklyDetailItem } from './api';
|
|||||||
import { SearchSelect } from '../../components/SearchSelect';
|
import { SearchSelect } from '../../components/SearchSelect';
|
||||||
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
|
|
||||||
// --- Constants ---
|
// --- Constants ---
|
||||||
@@ -2189,7 +2190,7 @@ export default function AssetsModule() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-2 text-center text-gray-500 cursor-pointer hover:underline" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type}` })}>{tb.total}</td>
|
<td className="p-2 text-center text-gray-500 cursor-pointer hover:underline" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type}` })}>{tb.total}</td>
|
||||||
<td className="p-2 text-center text-green-500 cursor-pointer hover:underline" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Operating', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 正在运营` })}>{tb.operating}</td>
|
<td className="p-2 text-center text-green-500 cursor-pointer hover:underline" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Operating', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 正在运营` })}>{tb.operating}</td>
|
||||||
<td className="p-2 text-center text-orange-500 cursor-pointer hover:underline" onClick={() => { setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Inventory', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 库存` }); }}>{tb.inventory}</td>
|
<td className="p-2 text-center text-orange-500 cursor-pointer hover:underline" onClick={() => { setShowPlateNumbers({ batch: 'All', model: 'All', location: city.city, vehicleType: tb.type, category: 'Pending', source: 'region', title: `区域运营统计 - ${city.city} - ${tb.type} - 待交车` }); }}>{tb.pending}</td>
|
||||||
<td className="p-2 text-center text-gray-400 text-[10px] italic">{tb.customers.slice(0, 2).join(', ')}</td>
|
<td className="p-2 text-center text-gray-400 text-[10px] italic">{tb.customers.slice(0, 2).join(', ')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -2251,9 +2252,9 @@ export default function AssetsModule() {
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="font-bold text-orange-600 cursor-pointer"
|
className="font-bold text-orange-600 cursor-pointer"
|
||||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, vehicleType: tb.type, category: 'Inventory', source: 'region', title: `区域运营统计 - ${r.region} - ${tb.type} - 库存` })}
|
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: r.region, vehicleType: tb.type, category: 'Pending', source: 'region', title: `区域运营统计 - ${r.region} - ${tb.type} - 待交车` })}
|
||||||
>
|
>
|
||||||
待:{tb.inventory}
|
待:{tb.pending}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2838,7 +2839,7 @@ export default function AssetsModule() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<RotatingFooterHint className="pb-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export interface RegionTypeBreakdown {
|
|||||||
total: number;
|
total: number;
|
||||||
operating: number;
|
operating: number;
|
||||||
inventory: number;
|
inventory: number;
|
||||||
|
pending: number;
|
||||||
customers: string[];
|
customers: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
392
src/modules/ele/EleImportPage.tsx
Normal file
392
src/modules/ele/EleImportPage.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
import {
|
||||||
|
Upload, FileSpreadsheet, RotateCcw, CheckCircle2, AlertCircle,
|
||||||
|
Truck, ExternalLink, Layers, Zap, ArrowLeft,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { fetchJson } from '../../auth/api-client';
|
||||||
|
import { useAuth } from '../../auth/useAuth';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
import FeedbackFab from '../../components/FeedbackFab';
|
||||||
|
|
||||||
|
function getJwt(): string | null {
|
||||||
|
return sessionStorage.getItem('bi_jwt');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadResult {
|
||||||
|
ok: boolean;
|
||||||
|
filename: string;
|
||||||
|
batchId: string;
|
||||||
|
parsed: number;
|
||||||
|
fileDuplicates: number;
|
||||||
|
inserted: number;
|
||||||
|
dbDuplicates: number;
|
||||||
|
breakdown: { internal: number; external: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListItem {
|
||||||
|
id: number;
|
||||||
|
order_no: string;
|
||||||
|
station_name: string | null;
|
||||||
|
terminal_name: string | null;
|
||||||
|
region: string | null;
|
||||||
|
city: string | null;
|
||||||
|
start_time: string | null;
|
||||||
|
end_time: string | null;
|
||||||
|
duration_min: number | null;
|
||||||
|
kwh: number | null;
|
||||||
|
fee: number | null;
|
||||||
|
e_fee: number | null;
|
||||||
|
service_fee: number | null;
|
||||||
|
plate: string | null;
|
||||||
|
judged_plate: string | null;
|
||||||
|
customer_name: string | null;
|
||||||
|
vehicle_kind: 'internal' | 'external' | 'unknown';
|
||||||
|
batch_id: string;
|
||||||
|
imported_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverallRow { vehicle_kind: 'internal' | 'external'; records: number; total_kwh: number; total_fee: number; }
|
||||||
|
interface BatchRow { batch_id: string; imported_at: string; records: number; internal_count: number; external_count: number; total_kwh: number; total_fee: number; }
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<string, string> = {
|
||||||
|
internal: '内部',
|
||||||
|
external: '外部',
|
||||||
|
};
|
||||||
|
const KIND_STYLE: Record<string, string> = {
|
||||||
|
internal: 'bg-blue-50 text-blue-600 border-blue-200',
|
||||||
|
external: 'bg-amber-50 text-amber-600 border-amber-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function uploadFile(file: File): Promise<UploadResult> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const token = getJwt();
|
||||||
|
const res = await fetch('/api/ele/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`);
|
||||||
|
return json as UploadResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EleImportPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [result, setResult] = useState<UploadResult | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [items, setItems] = useState<ListItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [overall, setOverall] = useState<OverallRow[]>([]);
|
||||||
|
const [batches, setBatches] = useState<BatchRow[]>([]);
|
||||||
|
const [filter, setFilter] = useState<'' | 'internal' | 'external'>('');
|
||||||
|
const [batchFilter, setBatchFilter] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
const params = new URLSearchParams({ page: '1', limit: '50' });
|
||||||
|
if (filter) params.set('kind', filter);
|
||||||
|
if (batchFilter) params.set('batchId', batchFilter);
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
const [list, agg, b] = await Promise.all([
|
||||||
|
fetchJson<{ items: ListItem[]; total: number }>(`/api/ele/list?${params.toString()}`),
|
||||||
|
fetchJson<{ overall: OverallRow[] }>(`/api/ele/aggregate`),
|
||||||
|
fetchJson<{ items: BatchRow[] }>(`/api/ele/batches`),
|
||||||
|
]);
|
||||||
|
setItems(list.items);
|
||||||
|
setTotal(list.total);
|
||||||
|
setOverall(agg.overall);
|
||||||
|
setBatches(b.items);
|
||||||
|
}, [filter, batchFilter, search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload().catch(e => console.error(e));
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
const handleUpload = async (f: File) => {
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const r = await uploadFile(f);
|
||||||
|
setResult(r);
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPick = (f: File | null) => {
|
||||||
|
setFile(f);
|
||||||
|
if (f) handleUpload(f);
|
||||||
|
};
|
||||||
|
|
||||||
|
const overallMap = new Map(overall.map(o => [o.vehicle_kind, o]));
|
||||||
|
const totalRecords = overall.reduce((s, o) => s + Number(o.records || 0), 0);
|
||||||
|
const totalKwh = overall.reduce((s, o) => s + Number(o.total_kwh || 0), 0);
|
||||||
|
const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 p-4 md:p-8">
|
||||||
|
<div className="max-w-6xl mx-auto space-y-4">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.history.length > 1) window.history.back();
|
||||||
|
else { window.location.hash = '#mileage'; }
|
||||||
|
}}
|
||||||
|
className="w-9 h-9 rounded-xl bg-white border border-slate-100 hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600 text-slate-500 flex items-center justify-center transition-colors flex-shrink-0"
|
||||||
|
title="返回"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Zap size={18} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-lg font-black text-slate-900 leading-tight">充电记录导入</h1>
|
||||||
|
<p className="text-[11px] font-bold text-slate-400">每日上传 xlsx · 订单编号去重 · 系统车辆自动匹配</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 flex-shrink-0">{user?.userName || ''}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 上传区 */}
|
||||||
|
<section
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const f = e.dataTransfer.files?.[0];
|
||||||
|
if (f) onPick(f);
|
||||||
|
}}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className={`bg-white rounded-2xl border-2 border-dashed shadow-sm cursor-pointer transition-all ${
|
||||||
|
dragOver ? 'border-blue-400 bg-blue-50/40' : uploading ? 'border-slate-200' : 'border-slate-200 hover:border-blue-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => onPick(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
<div className="px-6 py-10 flex flex-col items-center text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3">
|
||||||
|
{uploading ? <RotateCcw size={22} className="text-blue-500 animate-spin" /> : <Upload size={22} className="text-blue-500" />}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-slate-700 mb-1">
|
||||||
|
{uploading ? '正在解析...' : file ? file.name : '点击或拖拽 xlsx 文件到此处'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 max-w-md leading-relaxed">
|
||||||
|
支持「充电成功记录明细」格式;订单编号已存在的会自动跳过
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 上传结果提示 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{result && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
className="bg-white rounded-2xl border border-emerald-100 shadow-sm p-4 flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={18} className="text-emerald-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[12px] font-bold text-slate-700 mb-1">
|
||||||
|
上传成功:<span className="text-slate-500 font-mono">{result.filename}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-[11px]">
|
||||||
|
<Stat label="解析" value={result.parsed} color="text-slate-700" />
|
||||||
|
<Stat label="新增" value={result.inserted} color="text-blue-600" />
|
||||||
|
<Stat label="重复跳过" value={result.fileDuplicates + result.dbDuplicates} color="text-slate-500" />
|
||||||
|
<Stat label="内部" value={result.breakdown.internal} color="text-blue-600" />
|
||||||
|
<Stat label="外部(含无车牌)" value={result.breakdown.external} color="text-amber-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setResult(null)} className="text-slate-300 hover:text-slate-600 text-[10px] font-bold">关闭</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<AnimatePresence>
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
className="bg-red-50 rounded-2xl border border-red-200 shadow-sm p-4 flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<AlertCircle size={18} className="text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 text-[12px] font-bold text-red-700">{error}</div>
|
||||||
|
<button onClick={() => setError(null)} className="text-red-300 hover:text-red-600 text-[10px] font-bold">关闭</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 聚合卡 */}
|
||||||
|
<section className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
<KpiCard icon={<Layers size={14} />} label="总记录" value={totalRecords.toLocaleString()} />
|
||||||
|
<KpiCard icon={<Truck size={14} />} label="内部记录" value={(overallMap.get('internal')?.records ?? 0).toLocaleString()} accent="blue" />
|
||||||
|
<KpiCard icon={<ExternalLink size={14} />} label="外部记录" value={(overallMap.get('external')?.records ?? 0).toLocaleString()} accent="amber" />
|
||||||
|
<KpiCard icon={<Zap size={14} />} label="累计电量" value={`${totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} />
|
||||||
|
<KpiCard icon={<Zap size={14} />} label="内部电量" value={`${(overallMap.get('internal')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="blue" />
|
||||||
|
<KpiCard icon={<Zap size={14} />} label="外部电量" value={`${(overallMap.get('external')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="amber" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 批次 */}
|
||||||
|
{batches.length > 0 && (
|
||||||
|
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||||
|
<div className="px-3 py-2 bg-slate-50 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-bold text-slate-500">最近上传批次</span>
|
||||||
|
{batchFilter && (
|
||||||
|
<button onClick={() => setBatchFilter('')} className="text-[10px] font-bold text-blue-500">取消筛选</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="text-slate-400 font-bold bg-slate-50/40">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left">导入时间</th>
|
||||||
|
<th className="px-3 py-2 text-right">总数</th>
|
||||||
|
<th className="px-3 py-2 text-right">内部</th>
|
||||||
|
<th className="px-3 py-2 text-right">外部</th>
|
||||||
|
<th className="px-3 py-2 text-right">电量(度)</th>
|
||||||
|
<th className="px-3 py-2 text-right">费用(元)</th>
|
||||||
|
<th className="px-3 py-2 text-right">批次</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{batches.map(b => (
|
||||||
|
<tr
|
||||||
|
key={b.batch_id}
|
||||||
|
onClick={() => setBatchFilter(batchFilter === b.batch_id ? '' : b.batch_id)}
|
||||||
|
className={`border-t border-slate-100 cursor-pointer transition-colors ${batchFilter === b.batch_id ? 'bg-blue-50/40' : 'hover:bg-slate-50/60'}`}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 text-slate-600 whitespace-nowrap">{(b.imported_at || '').replace('T', ' ').slice(0, 19)}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-bold text-slate-700">{Number(b.records).toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-blue-600 font-bold">{Number(b.internal_count).toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-amber-600 font-bold">{Number(b.external_count).toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">{Number(b.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">¥{Number(b.total_fee ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-slate-300 font-mono text-[10px]">{b.batch_id.slice(0, 12)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 列表 */}
|
||||||
|
<section className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||||
|
<div className="px-3 py-2 bg-slate-50 flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-[11px] font-bold text-slate-500">最新记录</span>
|
||||||
|
<span className="text-[10px] font-bold text-slate-400">共 {total.toLocaleString()} 条</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }}
|
||||||
|
placeholder="搜索订单/车牌/电站"
|
||||||
|
className="bg-white border border-slate-200 rounded-lg px-2 py-1 text-[11px] outline-none focus:ring-1 focus:ring-blue-500/20 w-44"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1 bg-white p-0.5 rounded-lg border border-slate-200">
|
||||||
|
{([['', '全部'], ['internal', '内部'], ['external', '外部']] as const).map(([k, label]) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
onClick={() => setFilter(k as typeof filter)}
|
||||||
|
className={`px-2 py-0.5 rounded text-[10px] font-bold transition-colors ${filter === k ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||||
|
>{label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<thead className="bg-slate-50/40 text-slate-400 font-bold">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left whitespace-nowrap">充电时间</th>
|
||||||
|
<th className="px-3 py-2 text-left whitespace-nowrap">车牌</th>
|
||||||
|
<th className="px-3 py-2 text-center whitespace-nowrap">分类</th>
|
||||||
|
<th className="px-3 py-2 text-left">电站 / 终端</th>
|
||||||
|
<th className="px-3 py-2 text-right whitespace-nowrap">电量(度)</th>
|
||||||
|
<th className="px-3 py-2 text-right whitespace-nowrap">费用(元)</th>
|
||||||
|
<th className="px-3 py-2 text-right whitespace-nowrap">时长(分)</th>
|
||||||
|
<th className="px-3 py-2 text-left whitespace-nowrap">订单编号</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map(it => (
|
||||||
|
<tr key={it.id} className="border-t border-slate-100 hover:bg-slate-50/60">
|
||||||
|
<td className="px-3 py-2 text-slate-600 whitespace-nowrap font-mono">{(it.start_time || '').replace('T', ' ').slice(0, 16)}</td>
|
||||||
|
<td className="px-3 py-2 font-bold text-slate-700 font-mono whitespace-nowrap">{it.plate || it.judged_plate || <span className="text-slate-300">—</span>}</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold border ${KIND_STYLE[it.vehicle_kind]}`}>
|
||||||
|
{KIND_LABEL[it.vehicle_kind]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-slate-600 truncate max-w-xs">{it.station_name || '—'}{it.terminal_name ? ` · ${it.terminal_name}` : ''}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.kwh ?? 0).toFixed(2)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-slate-700 font-bold tabular-nums">{Number(it.fee ?? 0).toFixed(2)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-slate-500 tabular-nums">{it.duration_min ?? '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-slate-400 font-mono text-[10px]">{it.order_no}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-3 py-8 text-center text-slate-300 text-[11px] font-bold">
|
||||||
|
<FileSpreadsheet size={18} className="mx-auto mb-2 text-slate-200" />
|
||||||
|
尚无记录,先上传一份 xlsx 试试
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<RotatingFooterHint className="pb-4" />
|
||||||
|
</div>
|
||||||
|
<FeedbackFab module="ele" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
|
||||||
|
<div className="text-[9px] text-slate-400 uppercase font-bold">{label}</div>
|
||||||
|
<div className={`text-sm font-black tabular-nums ${color}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ icon, label, value, accent = 'slate' }: { icon: React.ReactNode; label: string; value: string; accent?: 'slate' | 'blue' | 'amber' }) {
|
||||||
|
const accentMap: Record<string, string> = {
|
||||||
|
slate: 'text-slate-700',
|
||||||
|
blue: 'text-blue-600',
|
||||||
|
amber: 'text-amber-600',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-100 shadow-sm p-3">
|
||||||
|
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-400 uppercase">
|
||||||
|
<span className={accentMap[accent]}>{icon}</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`text-base font-black tabular-nums leading-tight mt-0.5 ${accentMap[accent]}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/modules/energy/ETCView.tsx
Normal file
79
src/modules/energy/ETCView.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { Construction, Hammer } from 'lucide-react';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
|
const ETC_HINTS = [
|
||||||
|
'ETC 通行费数据正在与发卡方系统打通…',
|
||||||
|
'工人 GG 正在搭脚手架,敬请期待 ~',
|
||||||
|
'马上能看到每月通行费明细啦',
|
||||||
|
'想看哪个维度的 ETC?反馈一下嘛',
|
||||||
|
'上线时机:等数据接通的那一天',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ETCView() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
||||||
|
>
|
||||||
|
<div className="relative w-20 h-20 mb-4">
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: [0, -8, 8, -4, 4, 0] }}
|
||||||
|
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
className="absolute inset-0 rounded-3xl bg-gradient-to-br from-amber-50 to-orange-50 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Construction size={36} className="text-amber-500" strokeWidth={2.2} />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: [0, 18, -10, 0], y: [0, -2, 1, 0] }}
|
||||||
|
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
className="absolute -top-1 -right-1 w-9 h-9 rounded-2xl bg-white border border-amber-100 shadow-sm flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Hammer size={16} className="text-amber-500" strokeWidth={2.2} />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-base font-black text-slate-800 mb-1.5">ETC 模块建设中</div>
|
||||||
|
<div className="text-[12px] text-slate-500 font-bold leading-relaxed max-w-[280px]">
|
||||||
|
通行费明细、按车按月统计、运营成本拆分
|
||||||
|
<br />
|
||||||
|
这些数据都在路上啦
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 简单的里程碑进度感 */}
|
||||||
|
<div className="mt-6 w-full max-w-xs space-y-2">
|
||||||
|
{[
|
||||||
|
{ label: '需求评审', done: true },
|
||||||
|
{ label: '数据对接', done: true },
|
||||||
|
{ label: '页面开发', done: false, current: true },
|
||||||
|
{ label: '正式上线', done: false },
|
||||||
|
].map((m, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, x: -8 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.1 + i * 0.08, duration: 0.3 }}
|
||||||
|
className="flex items-center gap-2.5 text-[11px]"
|
||||||
|
>
|
||||||
|
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${
|
||||||
|
m.done ? 'bg-emerald-400'
|
||||||
|
: m.current ? 'bg-amber-400 ring-4 ring-amber-100 animate-pulse'
|
||||||
|
: 'bg-slate-200'
|
||||||
|
}`} />
|
||||||
|
<span className={`font-bold ${m.done ? 'text-slate-500' : m.current ? 'text-amber-600' : 'text-slate-300'}`}>
|
||||||
|
{m.label}
|
||||||
|
</span>
|
||||||
|
{m.done && <span className="text-[10px] text-emerald-500 font-bold ml-auto">已完成</span>}
|
||||||
|
{m.current && <span className="text-[10px] text-amber-500 font-bold ml-auto">进行中</span>}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<RotatingFooterHint hints={ETC_HINTS} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight, Plug } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import TrendBadge from './TrendBadge';
|
import TrendBadge from './TrendBadge';
|
||||||
import { fetchElectricMonthly } from './api';
|
import { fetchElectricMonthly } from './api';
|
||||||
import type { CustomerType, ElectricMonthGroup } from './types';
|
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
|
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||||
|
{ id: 'thisWeek', label: '本周' },
|
||||||
|
{ id: 'thisMonth', label: '本月' },
|
||||||
|
{ id: 'last15', label: '近 15 天' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function ElectricDaily() {
|
export default function ElectricDaily() {
|
||||||
const [customer, setCustomer] = useState<CustomerType>('external');
|
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);
|
||||||
@@ -14,7 +22,7 @@ export default function ElectricDaily() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
fetchElectricMonthly(customer)
|
fetchElectricMonthly(customer, pick)
|
||||||
.then(m => {
|
.then(m => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setMonths(m);
|
setMonths(m);
|
||||||
@@ -23,7 +31,7 @@ export default function ElectricDaily() {
|
|||||||
})
|
})
|
||||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [customer]);
|
}, [customer, pick]);
|
||||||
|
|
||||||
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -31,11 +39,31 @@ export default function ElectricDaily() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
|
||||||
|
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
||||||
|
|
||||||
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">
|
||||||
{(['external', 'lingniu'] as const).map(c => (
|
{(['lingniu', 'external'] as const).map(c => (
|
||||||
<button
|
<button
|
||||||
key={c}
|
key={c}
|
||||||
onClick={() => setCustomer(c)}
|
onClick={() => setCustomer(c)}
|
||||||
@@ -43,18 +71,38 @@ export default function ElectricDaily() {
|
|||||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{c === 'external' ? '外部' : '羚牛'}
|
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 外部车辆 数据未就绪 */}
|
||||||
|
{showExternalEmpty && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
||||||
|
>
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
|
||||||
|
<Plug size={22} className="text-blue-500" />
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
|
||||||
|
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
|
||||||
|
新系统的外部车辆充电数据还在准备中
|
||||||
|
<br />
|
||||||
|
上线后此处将展示完整明细
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 月份分组表 */}
|
{/* 月份分组表 */}
|
||||||
|
{!showExternalEmpty && (
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||||
<div className="grid grid-cols-[1fr_auto_auto_auto] md:grid-cols-[1fr_120px_140px_120px] gap-2 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
<div className="grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||||||
<span>月份 / 日期</span>
|
<span>月份 / 日期</span>
|
||||||
<span className="hidden md:block text-right">充电电量</span>
|
<span className="text-right">充电量 (度)</span>
|
||||||
<span className="text-right md:hidden">度</span>
|
|
||||||
<span className="text-right">充电费用(元)</span>
|
|
||||||
<span className="text-right">环比</span>
|
<span className="text-right">环比</span>
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -69,7 +117,7 @@ export default function ElectricDaily() {
|
|||||||
<div key={m.month} className="border-t border-slate-100 first:border-t-0">
|
<div key={m.month} className="border-t border-slate-100 first:border-t-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleMonth(m.month)}
|
onClick={() => toggleMonth(m.month)}
|
||||||
className={`w-full grid grid-cols-[1fr_auto_auto_auto] md:grid-cols-[1fr_120px_140px_120px] gap-2 px-3 py-2.5 text-left transition-colors ${
|
className={`w-full grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2.5 text-left transition-colors ${
|
||||||
open ? 'bg-blue-50/30' : 'hover:bg-slate-50'
|
open ? 'bg-blue-50/30' : 'hover:bg-slate-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -80,9 +128,6 @@ export default function ElectricDaily() {
|
|||||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||||
{m.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
{m.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
|
||||||
{m.fee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
|
||||||
</span>
|
|
||||||
<span />
|
<span />
|
||||||
</button>
|
</button>
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
@@ -102,15 +147,12 @@ export default function ElectricDaily() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={d.date}
|
key={d.date}
|
||||||
className={`grid grid-cols-[1fr_auto_auto_auto] md:grid-cols-[1fr_120px_140px_120px] gap-2 px-3 py-2 pl-9 border-t border-slate-100 ${abnormalBg}`}
|
className={`grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 pl-9 border-t border-slate-100 ${abnormalBg}`}
|
||||||
>
|
>
|
||||||
<span className="text-[12px] text-slate-600">{d.date.slice(5)}</span>
|
<span className="text-[12px] text-slate-600">{d.date.slice(5)}</span>
|
||||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||||
{d.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
{d.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
|
||||||
{d.fee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
|
||||||
</span>
|
|
||||||
<span className="text-right"><TrendBadge value={d.chainPct} /></span>
|
<span className="text-right"><TrendBadge value={d.chainPct} /></span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -122,6 +164,8 @@ export default function ElectricDaily() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<RotatingFooterHint />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Wallet, BatteryCharging, CalendarClock } from 'lucide-react';
|
import { Wallet, CalendarClock } from 'lucide-react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
||||||
import TrendBadge from './TrendBadge';
|
|
||||||
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
function fmtYuan(yuan: number) {
|
function fmtYuan(yuan: number) {
|
||||||
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||||
@@ -40,8 +40,11 @@ export default function ElectricOverview() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
|
||||||
|
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
||||||
|
</div>
|
||||||
{/* 横向 mini KPI 头 */}
|
{/* 横向 mini KPI 头 */}
|
||||||
<div className="grid grid-cols-3 gap-2 md:gap-3">
|
<div className="grid grid-cols-2 gap-2 md:gap-3">
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||||
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
|
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
|
||||||
<Wallet size={11} className="text-blue-600" />累计
|
<Wallet size={11} className="text-blue-600" />累计
|
||||||
@@ -56,14 +59,6 @@ export default function ElectricOverview() {
|
|||||||
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.monthFee)}</div>
|
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.monthFee)}</div>
|
||||||
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.monthKwh)}</div>
|
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.monthKwh)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 relative">
|
|
||||||
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
|
|
||||||
<BatteryCharging size={11} className="text-blue-600" />今日
|
|
||||||
</div>
|
|
||||||
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.todayFee)}</div>
|
|
||||||
<div className="text-[11px] text-slate-500 font-bold mt-0.5 whitespace-nowrap">{fmtKwh(k.todayKwh)}</div>
|
|
||||||
<TrendBadge value={k.todayChainPct} className="absolute top-2 right-2 md:top-3 md:right-3" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 本月每日充电柱图 */}
|
{/* 本月每日充电柱图 */}
|
||||||
@@ -104,6 +99,7 @@ export default function ElectricOverview() {
|
|||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
<RotatingFooterHint />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import ElectricOverview from './ElectricOverview';
|
import ElectricOverview from './ElectricOverview';
|
||||||
import ElectricDaily from './ElectricDaily';
|
import ElectricDaily from './ElectricDaily';
|
||||||
|
|
||||||
export default function ElectricView() {
|
export type ElectricSubTab = 'daily' | 'overview';
|
||||||
return (
|
|
||||||
<>
|
interface Props {
|
||||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
|
sub: ElectricSubTab;
|
||||||
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
}
|
||||||
</div>
|
|
||||||
<ElectricOverview />
|
export default function ElectricView({ sub }: Props) {
|
||||||
<ElectricDaily />
|
return sub === 'overview' ? <ElectricOverview /> : <ElectricDaily />;
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,85 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Fuel, BatteryCharging } from 'lucide-react';
|
import { Fuel, BatteryCharging, Receipt, LayoutDashboard, CalendarDays } from 'lucide-react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import HydrogenView from './HydrogenView';
|
import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
||||||
import ElectricView from './ElectricView';
|
import ElectricView, { type ElectricSubTab } from './ElectricView';
|
||||||
|
import ETCView from './ETCView';
|
||||||
|
|
||||||
type TopTab = 'hydrogen' | 'electric';
|
type TopTab = 'hydrogen' | 'electric' | 'etc';
|
||||||
|
type SubTabId = HydrogenSubTab | ElectricSubTab; // 'daily' | 'overview'
|
||||||
|
|
||||||
|
const TABS: { key: TopTab; label: string; icon: typeof Fuel }[] = [
|
||||||
|
{ key: 'hydrogen', label: '氢能', icon: Fuel },
|
||||||
|
{ key: 'electric', label: '电能', icon: BatteryCharging },
|
||||||
|
{ key: 'etc', label: 'ETC', icon: Receipt },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SUB_TABS: { id: SubTabId; label: string; icon: typeof LayoutDashboard }[] = [
|
||||||
|
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||||
|
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
||||||
|
];
|
||||||
|
|
||||||
export default function EnergyModule() {
|
export default function EnergyModule() {
|
||||||
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
|
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
|
||||||
|
const [hydroSub, setHydroSub] = useState<HydrogenSubTab>('daily');
|
||||||
|
const [electricSub, setElectricSub] = useState<ElectricSubTab>('daily');
|
||||||
|
const showSubTabs = activeTab === 'hydrogen' || activeTab === 'electric';
|
||||||
|
const currentSub: SubTabId = activeTab === 'electric' ? electricSub : hydroSub;
|
||||||
|
const setSub = (id: SubTabId) => activeTab === 'electric' ? setElectricSub(id) : setHydroSub(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">
|
||||||
<div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm flex items-center gap-6 sticky top-0 z-30">
|
|
||||||
<button
|
{/* 统一 sticky 头部:top tab + (氢能时) 子 tab;同一张卡片,无间隙 */}
|
||||||
onClick={() => setActiveTab('hydrogen')}
|
{/* pb-4 留一点底部缓冲,避免下方快捷选按钮在滚动时贴着 sticky 半截露脸 */}
|
||||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeTab === 'hydrogen' ? 'text-blue-600' : 'text-slate-400'}`}
|
<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">
|
||||||
<Fuel size={14} />
|
{/* 顶部 tab:氢能 / 电能 / ETC */}
|
||||||
<span className="text-[11px] font-bold">氢能</span>
|
<div className={`px-4 py-2 flex items-center gap-6 ${showSubTabs ? 'border-b border-slate-50' : ''}`}>
|
||||||
{activeTab === 'hydrogen' && (
|
{TABS.map(tab => {
|
||||||
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
const Icon = tab.icon;
|
||||||
|
const active = activeTab === tab.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`flex items-center gap-2 py-1 transition-colors relative ${active ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'}`}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
<span className="text-[11px] font-bold">{tab.label}</span>
|
||||||
|
{active && (
|
||||||
|
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* 子 tab:氢能 / 电能 都显示 每日 / 总览 */}
|
||||||
|
{showSubTabs && (
|
||||||
|
<div className="p-1 flex gap-1">
|
||||||
|
{SUB_TABS.map(({ id, label, icon: Icon }) => {
|
||||||
|
const active = currentSub === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setSub(id)}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
|
||||||
|
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('electric')}
|
|
||||||
className={`flex items-center gap-2 py-1 transition-all relative ${activeTab === 'electric' ? 'text-blue-600' : 'text-slate-400'}`}
|
|
||||||
>
|
|
||||||
<BatteryCharging size={14} />
|
|
||||||
<span className="text-[11px] font-bold">电能</span>
|
|
||||||
{activeTab === 'electric' && (
|
|
||||||
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{activeTab === 'hydrogen' ? <HydrogenView /> : <ElectricView />}
|
|
||||||
|
{activeTab === 'hydrogen' && <HydrogenView sub={hydroSub} />}
|
||||||
|
{activeTab === 'electric' && <ElectricView sub={electricSub} />}
|
||||||
|
{activeTab === 'etc' && <ETCView />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight, Plug } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
||||||
import TrendBadge from './TrendBadge';
|
import TrendBadge from './TrendBadge';
|
||||||
import { fetchHydrogenDaily } from './api';
|
import { fetchHydrogenDaily } from './api';
|
||||||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||||
{ id: 'today', label: '当天' },
|
{ id: 'thisWeek', label: '本周' },
|
||||||
{ id: 'thisWeek', label: '本周' },
|
{ id: 'thisMonth', label: '本月' },
|
||||||
{ id: 'thisMonth', label: '本月' },
|
{ id: 'last15', label: '近 15 天' },
|
||||||
{ id: 'thisQuarter', label: '本季度' },
|
|
||||||
{ id: 'last7', label: '最近7天' },
|
|
||||||
{ id: 'last30', label: '最近30天' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function HydrogenDaily() {
|
export default function HydrogenDaily() {
|
||||||
const [pick, setPick] = useState<DateQuickPick>('last30');
|
const [pick, setPick] = useState<DateQuickPick>('last15');
|
||||||
const [customer, setCustomer] = useState<CustomerType>('external');
|
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);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -62,7 +60,7 @@ export default function HydrogenDaily() {
|
|||||||
|
|
||||||
{/* 客户类型 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">
|
||||||
{(['external', 'lingniu'] as const).map(c => (
|
{(['lingniu', 'external'] as const).map(c => (
|
||||||
<button
|
<button
|
||||||
key={c}
|
key={c}
|
||||||
onClick={() => setCustomer(c)}
|
onClick={() => setCustomer(c)}
|
||||||
@@ -70,16 +68,37 @@ export default function HydrogenDaily() {
|
|||||||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{c === 'external' ? '外部' : '羚牛'}
|
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 时段加氢量柱图 */}
|
{/* 外部车辆:新系统数据还没准备好 */}
|
||||||
{trendData.length > 0 && (
|
{customer === 'external' && rows !== null && totalKg === 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
|
||||||
|
>
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
|
||||||
|
<Plug size={22} className="text-blue-500" />
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
|
||||||
|
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
|
||||||
|
新系统的外部车辆加氢数据还在准备中
|
||||||
|
<br />
|
||||||
|
上线后此处将展示完整明细
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
||||||
|
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
|
||||||
<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-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-sm font-bold text-slate-700">时段每日加氢量</span>
|
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
|
||||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={160}>
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
@@ -116,17 +135,18 @@ export default function HydrogenDaily() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 表格 */}
|
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
||||||
|
{!(customer === 'external' && rows !== null && totalKg === 0) && (
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||||
{/* 表头 */}
|
{/* 表头 */}
|
||||||
<div className="grid grid-cols-[1fr_auto_auto] md:grid-cols-[1fr_140px_120px_120px] gap-2 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||||||
<span>日期 / 加氢站带价格</span>
|
<span>日期 / 加氢站</span>
|
||||||
<span className="hidden md:block text-right">单价</span>
|
<span className="hidden md:block text-right">单价 (元/Kg)</span>
|
||||||
<span className="text-right">加氢量(Kg)</span>
|
<span className="text-right">加氢量 (Kg)</span>
|
||||||
<span className="text-right">环比</span>
|
<span className="text-right">环比</span>
|
||||||
</div>
|
</div>
|
||||||
{/* 合计行 */}
|
{/* 合计行 */}
|
||||||
<div className="grid grid-cols-[1fr_auto_auto] md:grid-cols-[1fr_140px_120px_120px] gap-2 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
|
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
|
||||||
<span>合计</span>
|
<span>合计</span>
|
||||||
<span className="hidden md:block" />
|
<span className="hidden md:block" />
|
||||||
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
|
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
|
||||||
@@ -149,13 +169,13 @@ export default function HydrogenDaily() {
|
|||||||
<div key={r.date} className={`border-t border-slate-100 ${abnormalBg}`}>
|
<div key={r.date} className={`border-t border-slate-100 ${abnormalBg}`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggle(r.date)}
|
onClick={() => toggle(r.date)}
|
||||||
className="w-full grid grid-cols-[1fr_auto_auto] md:grid-cols-[1fr_140px_120px_120px] gap-2 px-3 py-2.5 text-left hover:bg-slate-50/60 transition-colors"
|
className="w-full grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2.5 text-left hover:bg-slate-50/60 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
|
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
|
||||||
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
|
||||||
{r.date}
|
{r.date}
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden md:block text-right text-[12px] text-slate-400">—</span>
|
<span className="hidden md:block text-right text-[12px] text-slate-300">—</span>
|
||||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||||
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||||
</span>
|
</span>
|
||||||
@@ -173,13 +193,21 @@ export default function HydrogenDaily() {
|
|||||||
{r.stations.map(s => (
|
{r.stations.map(s => (
|
||||||
<div
|
<div
|
||||||
key={s.name}
|
key={s.name}
|
||||||
className="grid grid-cols-[1fr_auto_auto] md:grid-cols-[1fr_140px_120px_120px] gap-2 px-3 py-2 pl-9 md:pl-9 border-t border-slate-100 first:border-t-0"
|
className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 pl-6 md:pl-9 border-t border-slate-100 first:border-t-0 items-start"
|
||||||
>
|
>
|
||||||
<span className="text-[12px] text-slate-600 truncate">
|
<div className="min-w-0">
|
||||||
{s.name}
|
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
|
||||||
<span className="md:hidden text-slate-400"> · {s.pricePerKg} 元/Kg</span>
|
{s.name}
|
||||||
</span>
|
</div>
|
||||||
<span className="hidden md:block text-right text-[12px] text-slate-500 font-bold">{s.pricePerKg} 元/Kg</span>
|
{s.pricePerKg > 0 && (
|
||||||
|
<div className="md:hidden mt-1">
|
||||||
|
<span className="inline-flex items-center text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded font-bold whitespace-nowrap">
|
||||||
|
单价 {s.pricePerKg} 元/Kg
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="hidden md:block text-right text-[12px] text-slate-500 font-bold tabular-nums">{s.pricePerKg > 0 ? s.pricePerKg : '—'}</span>
|
||||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||||
{s.kg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
{s.kg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||||
</span>
|
</span>
|
||||||
@@ -193,6 +221,8 @@ export default function HydrogenDaily() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<RotatingFooterHint />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Fuel, Wallet, Coins, CalendarClock } from 'lucide-react';
|
import {
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList } from 'recharts';
|
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';
|
||||||
|
|
||||||
|
const REGION_COLORS = [
|
||||||
|
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||||
|
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
||||||
|
'#94a3b8',
|
||||||
|
];
|
||||||
|
|
||||||
interface YAxisTickProps {
|
interface YAxisTickProps {
|
||||||
x?: number;
|
x?: number;
|
||||||
@@ -13,125 +23,322 @@ interface YAxisTickProps {
|
|||||||
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
||||||
return (
|
return (
|
||||||
<g transform={`translate(${x},${y})`}>
|
<g transform={`translate(${x},${y})`}>
|
||||||
<circle cx={-158} cy={0} r={9} fill="#3b82f6" />
|
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
|
||||||
<text x={-158} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</text>
|
</text>
|
||||||
<text x={-144} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
||||||
{payload?.value}
|
{payload?.value}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 fmtKg(kg: number) {
|
|
||||||
if (kg >= 1000) return `${(kg / 1000).toFixed(2)}T`;
|
|
||||||
return `${kg.toFixed(2)}Kg`;
|
|
||||||
}
|
}
|
||||||
function fmtYuanWan(yuan: number) {
|
function fmtYuan(yuan: number): { value: string; unit: string } {
|
||||||
return `¥${(yuan / 10_000).toFixed(2)}万`;
|
const abs = Math.abs(yuan);
|
||||||
}
|
if (abs >= 100_000_000) return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
|
||||||
function fmtYuan(yuan: number) {
|
if (abs >= 10_000) {
|
||||||
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
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) {
|
||||||
return <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm">加载中…</div>;
|
return <HydrogenOverviewSkeleton />;
|
||||||
}
|
}
|
||||||
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">
|
||||||
</div>
|
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{/* 卡 1:年加氢量 */}
|
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
||||||
<div className="bg-gradient-to-br from-cyan-50 to-blue-50 rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-2">
|
{availableYears.map(y => {
|
||||||
<div className="flex items-center justify-between text-[11px] text-slate-500">
|
const active = y === activeYear;
|
||||||
<span className="flex items-center gap-1 font-bold"><Fuel size={12} className="text-cyan-600" />年加氢量</span>
|
return (
|
||||||
</div>
|
<button
|
||||||
<div className="text-2xl lg:text-3xl font-bold text-slate-800 leading-tight">{fmtKg(k.yearKg)}</div>
|
key={y}
|
||||||
<div className="text-[11px] text-slate-500 font-bold space-y-0.5">
|
onClick={() => setYear(y)}
|
||||||
<div>我方 <span className="text-slate-700">{fmtKg(k.ourYearKg)}</span></div>
|
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
|
||||||
<div>客户产生 <span className="text-slate-700">{fmtKg(k.customerYearKg)}</span></div>
|
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
|
||||||
</div>
|
}`}
|
||||||
</div>
|
>
|
||||||
{/* 卡 2:年加氢费 */}
|
{y}
|
||||||
<div className="bg-gradient-to-br from-blue-50 to-violet-50 rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-2">
|
</button>
|
||||||
<div className="flex items-center justify-between text-[11px] text-slate-500">
|
);
|
||||||
<span className="flex items-center gap-1 font-bold"><Wallet size={12} className="text-blue-600" />年加氢费</span>
|
})}
|
||||||
</div>
|
|
||||||
<div className="text-2xl lg:text-3xl font-bold text-slate-800 leading-tight">{fmtYuanWan(k.yearFee)}</div>
|
|
||||||
<div className="text-[11px] text-slate-500 font-bold">
|
|
||||||
<div>我方 <span className="text-slate-700">{fmtYuanWan(k.ourYearFee)}</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 卡 3:累计羚牛承担 */}
|
|
||||||
<div className="bg-gradient-to-br from-amber-50 to-orange-50 rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between text-[11px] text-slate-500">
|
|
||||||
<span className="flex items-center gap-1 font-bold"><Coins size={12} className="text-amber-600" />累计羚牛承担</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl lg:text-3xl font-bold text-slate-800 leading-tight">{fmtYuanWan(k.lingniuBornFee)}</div>
|
|
||||||
<div className="text-[11px] text-slate-500 font-bold space-y-0.5">
|
|
||||||
<div>量 <span className="text-slate-700">{fmtKg(k.lingniuBornKg)}</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 卡 4:本月 / 今日 */}
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between text-[11px] text-slate-500">
|
|
||||||
<span className="flex items-center gap-1 font-bold"><CalendarClock size={12} className="text-slate-500" />本月 / 今日</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-[10px] text-slate-400 font-bold">本月</div>
|
|
||||||
<div className="text-base md:text-lg font-bold text-slate-800">{fmtKg(k.monthKg)}</div>
|
|
||||||
<div className="text-[11px] text-slate-500 font-bold">{fmtYuanWan(k.monthFee)}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-[10px] text-slate-400 font-bold">今日</div>
|
|
||||||
<div className="text-base md:text-lg font-bold text-slate-800">{fmtKg(k.todayKg)}</div>
|
|
||||||
<div className="text-[11px] text-slate-500 font-bold">{fmtYuan(k.todayFee)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 0 }}>
|
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
|
||||||
<XAxis type="number" hide />
|
<XAxis type="number" hide />
|
||||||
<YAxis
|
<YAxis
|
||||||
type="category"
|
type="category"
|
||||||
dataKey="name"
|
dataKey="name"
|
||||||
width={170}
|
width={188}
|
||||||
tick={<RankYAxisTick />}
|
tick={<RankYAxisTick />}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
@@ -142,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"
|
||||||
@@ -162,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]">
|
||||||
@@ -192,15 +400,242 @@ 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 />
|
||||||
|
|
||||||
|
{/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 animate-pulse">
|
||||||
|
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
|
||||||
|
<div className="h-3 w-44 bg-slate-100 rounded" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5 卡占位 */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded-xl bg-slate-100" />
|
||||||
|
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-7 w-24 bg-slate-200 rounded" />
|
||||||
|
<div className="space-y-1.5 pt-1 border-t border-slate-50">
|
||||||
|
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||||||
|
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 月度柱图占位 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||||
|
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2 h-[120px]">
|
||||||
|
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
|
||||||
|
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||||||
|
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[100, 78, 56, 40, 28].map((w, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-slate-200" />
|
||||||
|
<div className="h-3 w-32 bg-slate-100 rounded" />
|
||||||
|
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
|
||||||
|
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
|
||||||
|
<div className="h-4 w-28 bg-slate-100 rounded" />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-1/2 h-[200px] flex items-center justify-center">
|
||||||
|
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-slate-200" />
|
||||||
|
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||||||
|
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
|
||||||
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||||
|
正在加载氢能总览…
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,12 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { LayoutDashboard, CalendarDays } from 'lucide-react';
|
|
||||||
import HydrogenOverview from './HydrogenOverview';
|
import HydrogenOverview from './HydrogenOverview';
|
||||||
import HydrogenDaily from './HydrogenDaily';
|
import HydrogenDaily from './HydrogenDaily';
|
||||||
|
|
||||||
type SubTab = 'daily' | 'overview';
|
export type HydrogenSubTab = 'daily' | 'overview';
|
||||||
|
|
||||||
const SUB_TABS: Array<{ id: SubTab; label: string; icon: typeof LayoutDashboard }> = [
|
interface Props {
|
||||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
sub: HydrogenSubTab;
|
||||||
{ id: 'overview', label: '总览', icon: LayoutDashboard },
|
}
|
||||||
];
|
|
||||||
|
export default function HydrogenView({ sub }: Props) {
|
||||||
export default function HydrogenView() {
|
return sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily />;
|
||||||
const [sub, setSub] = useState<SubTab>('daily');
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-1 sticky top-[58px] z-20 flex gap-1">
|
|
||||||
{SUB_TABS.map(({ id, label, icon: Icon }) => {
|
|
||||||
const active = sub === id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
onClick={() => setSub(id)}
|
|
||||||
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
|
|
||||||
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
<span>{label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{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[]> {
|
||||||
@@ -31,7 +41,7 @@ export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
|||||||
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchElectricMonthly(customer: CustomerType): Promise<ElectricMonthGroup[]> {
|
export function fetchElectricMonthly(customer: CustomerType, range: DateQuickPick = 'last15'): Promise<ElectricMonthGroup[]> {
|
||||||
const q = new URLSearchParams({ customer });
|
const q = new URLSearchParams({ customer, range });
|
||||||
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
export type CustomerType = 'external' | 'lingniu';
|
export type CustomerType = 'external' | 'lingniu';
|
||||||
export type DateQuickPick = 'today' | 'thisWeek' | 'thisMonth' | 'thisQuarter' | 'last7' | 'last30';
|
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;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { motion } from 'motion/react';
|
|||||||
import MonitoringView from './MonitoringView';
|
import MonitoringView from './MonitoringView';
|
||||||
import StatisticsView from './StatisticsView';
|
import StatisticsView from './StatisticsView';
|
||||||
import DailyReportView from './DailyReportView';
|
import DailyReportView from './DailyReportView';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
export default function MileageModule() {
|
export default function MileageModule() {
|
||||||
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
|
const [activeSubTab, setActiveSubTab] = useState<'monitoring' | 'statistics' | 'report'>('monitoring');
|
||||||
@@ -52,6 +53,7 @@ export default function MileageModule() {
|
|||||||
) : (
|
) : (
|
||||||
<DailyReportView />
|
<DailyReportView />
|
||||||
)}
|
)}
|
||||||
|
<RotatingFooterHint />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { fetchMonitoring } from './api';
|
|||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
import PlateMultiSelect from './PlateMultiSelect';
|
import PlateMultiSelect from './PlateMultiSelect';
|
||||||
import { exportMileageXlsx } from './xlsx-export';
|
import { exportMileageXlsx } from './xlsx-export';
|
||||||
|
import VehicleDetailModal from './VehicleDetailModal';
|
||||||
|
|
||||||
const SearchableSelect = ({
|
const SearchableSelect = ({
|
||||||
options,
|
options,
|
||||||
@@ -115,6 +116,7 @@ export default function MonitoringView() {
|
|||||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
||||||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
|
||||||
const [filterDate, setFilterDate] = useState(() => {
|
const [filterDate, setFilterDate] = useState(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
|
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
|
||||||
@@ -201,6 +203,14 @@ export default function MonitoringView() {
|
|||||||
loadFirstPage();
|
loadFirstPage();
|
||||||
}, [loadFirstPage]);
|
}, [loadFirstPage]);
|
||||||
|
|
||||||
|
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌
|
||||||
|
useEffect(() => {
|
||||||
|
if (filterPlates.length === 0) return;
|
||||||
|
const valid = new Set(filterOptions.plates);
|
||||||
|
const next = filterPlates.filter(p => valid.has(p));
|
||||||
|
if (next.length !== filterPlates.length) setFilterPlates(next);
|
||||||
|
}, [filterOptions.plates, filterPlates]);
|
||||||
|
|
||||||
// 下载当前筛选结果为 xlsx
|
// 下载当前筛选结果为 xlsx
|
||||||
const handleDownload = useCallback(async () => {
|
const handleDownload = useCallback(async () => {
|
||||||
if (exporting) return;
|
if (exporting) return;
|
||||||
@@ -891,10 +901,8 @@ export default function MonitoringView() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
key={v.plate}
|
key={v.plate}
|
||||||
className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 transition-all"
|
className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 cursor-pointer transition-all"
|
||||||
onClick={() => {
|
onClick={() => setDetailVehicle(v)}
|
||||||
navigator.clipboard.writeText(v.plate);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
@@ -958,6 +966,8 @@ export default function MonitoringView() {
|
|||||||
<div ref={sentinelRef} className="h-1" />
|
<div ref={sentinelRef} className="h-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<VehicleDetailModal vehicle={detailVehicle} onClose={() => setDetailVehicle(null)} />
|
||||||
|
|
||||||
{/* 回到顶部按钮 */}
|
{/* 回到顶部按钮 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showBackToTop && (
|
{showBackToTop && (
|
||||||
|
|||||||
@@ -103,23 +103,22 @@ export default function PlateMultiSelect({ allPlates, selected, onChange, placeh
|
|||||||
initial={{ opacity: 0, y: -5 }}
|
initial={{ opacity: 0, y: -5 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -5 }}
|
exit={{ opacity: 0, y: -5 }}
|
||||||
className="absolute z-50 left-0 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl w-[280px] max-w-[calc(100vw-32px)]"
|
className="absolute z-50 right-0 mt-1 bg-white border border-slate-100 rounded-xl shadow-xl"
|
||||||
style={{ minWidth: '100%' }}
|
style={{ width: 'min(280px, calc(100vw - 24px))', minWidth: '100%' }}
|
||||||
>
|
>
|
||||||
<div className="p-2 space-y-2">
|
<div className="p-2 space-y-2">
|
||||||
<textarea
|
<textarea
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
onPaste={(e) => {
|
placeholder="粘贴或输入车牌 支持换行/逗号/空格分隔,回车或点添加确认"
|
||||||
const pasted = e.clipboardData.getData('text');
|
|
||||||
if (pasted && /[\s,;,;、]/.test(pasted)) {
|
|
||||||
e.preventDefault();
|
|
||||||
apply(text + (text ? ' ' : '') + pasted);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="粘贴或输入车牌 支持换行/逗号/空格分隔"
|
|
||||||
className="w-full bg-slate-50 border-none rounded-lg p-2 text-[11px] text-slate-700 outline-none focus:ring-1 focus:ring-blue-500/30 resize-none"
|
className="w-full bg-slate-50 border-none rounded-lg p-2 text-[11px] text-slate-700 outline-none focus:ring-1 focus:ring-blue-500/30 resize-none"
|
||||||
rows={3}
|
rows={3}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
apply(text);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-[10px] text-slate-400">已选 <span className="font-bold text-blue-600">{selected.length}</span> 个</span>
|
<span className="text-[10px] text-slate-400">已选 <span className="font-bold text-blue-600">{selected.length}</span> 个</span>
|
||||||
|
|||||||
342
src/modules/mileage/VehicleDetailModal.tsx
Normal file
342
src/modules/mileage/VehicleDetailModal.tsx
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence, useDragControls } from 'motion/react';
|
||||||
|
import { X, Truck } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
||||||
|
} from 'recharts';
|
||||||
|
import type { MonitoringVehicle } from './types';
|
||||||
|
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
||||||
|
import Blur from '../../components/Blur';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
vehicle: MonitoringVehicle | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RangeKey = 'last15' | 'month' | 'quarter';
|
||||||
|
|
||||||
|
const RANGE_TABS: { key: RangeKey; label: string }[] = [
|
||||||
|
{ key: 'last15', label: '近 15 天' },
|
||||||
|
{ key: 'month', label: '本月' },
|
||||||
|
{ key: 'quarter', label: '本季度' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtYmd(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeFor(key: RangeKey): { start: string; end: string; rangeLabel: string } {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const end = fmtYmd(today);
|
||||||
|
if (key === 'last15') {
|
||||||
|
const start = new Date(today);
|
||||||
|
start.setDate(today.getDate() - 14);
|
||||||
|
return { start: fmtYmd(start), end, rangeLabel: '近 15 天' };
|
||||||
|
}
|
||||||
|
if (key === 'month') {
|
||||||
|
const start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
return { start: fmtYmd(start), end, rangeLabel: '本月' };
|
||||||
|
}
|
||||||
|
const q = Math.floor(today.getMonth() / 3);
|
||||||
|
const start = new Date(today.getFullYear(), q * 3, 1);
|
||||||
|
return { start: fmtYmd(start), end, rangeLabel: '本季度' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToday(date: string): boolean {
|
||||||
|
return date === fmtYmd(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLabel(date: string, key: RangeKey): string {
|
||||||
|
// YYYY-MM-DD → MM-DD(季度时仍展示 MM-DD)
|
||||||
|
void key;
|
||||||
|
return date.slice(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
||||||
|
const [days, setDays] = useState<VehicleRecentDay[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [range, setRange] = useState<RangeKey>('last15');
|
||||||
|
const dragControls = useDragControls();
|
||||||
|
|
||||||
|
// 切换车辆时重置区间为默认
|
||||||
|
useEffect(() => {
|
||||||
|
if (vehicle) setRange('last15');
|
||||||
|
}, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 拉取数据(车辆或区间变化)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vehicle) return;
|
||||||
|
const { start, end } = rangeFor(range);
|
||||||
|
setLoading(true);
|
||||||
|
setDays([]);
|
||||||
|
let cancelled = false;
|
||||||
|
fetchVehicleRecent(vehicle.plate, { start, end })
|
||||||
|
.then(d => { if (!cancelled) setDays(d.days); })
|
||||||
|
.catch(() => { if (!cancelled) setDays([]); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [vehicle?.plate, range]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 锁滚动
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vehicle) return;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = ''; };
|
||||||
|
}, [vehicle]);
|
||||||
|
|
||||||
|
// 排除"今日"列(数据未到位时易引起误读)
|
||||||
|
const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]);
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
|
||||||
|
const synced = historyDays.filter(d => d.isDataSynced).length;
|
||||||
|
const avg = synced > 0 ? totalKm / synced : 0;
|
||||||
|
const max = Math.max(1, ...historyDays.map(d => d.dailyKm));
|
||||||
|
return { totalKm, synced, avg, max, totalDays: historyDays.length };
|
||||||
|
}, [historyDays]);
|
||||||
|
|
||||||
|
// 骨架天数:根据区间预估
|
||||||
|
const skeletonCount = useMemo(() => {
|
||||||
|
if (range === 'last15') return 15;
|
||||||
|
const { start, end } = rangeFor(range);
|
||||||
|
const s = new Date(start);
|
||||||
|
const e = new Date(end);
|
||||||
|
return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000));
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{vehicle && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[80] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: '100%', opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: '100%', opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
y: { type: 'spring', damping: 32, stiffness: 320 },
|
||||||
|
opacity: { duration: 0.18 },
|
||||||
|
}}
|
||||||
|
drag="y"
|
||||||
|
dragControls={dragControls}
|
||||||
|
dragListener={false}
|
||||||
|
dragConstraints={{ top: 0, bottom: 0 }}
|
||||||
|
dragElastic={{ top: 0, bottom: 0.6 }}
|
||||||
|
onDragEnd={(_, info) => {
|
||||||
|
if (info.offset.y > 100 || info.velocity.y > 600) onClose();
|
||||||
|
}}
|
||||||
|
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col touch-pan-y"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* iOS 风格 drag handle —— 长按下滑可关闭 */}
|
||||||
|
<div
|
||||||
|
className="flex justify-center pt-2.5 pb-1.5 cursor-grab active:cursor-grabbing select-none"
|
||||||
|
onPointerDown={(e) => dragControls.start(e)}
|
||||||
|
style={{ touchAction: 'none' }}
|
||||||
|
>
|
||||||
|
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 pb-2 border-b border-slate-100">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-blue-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Truck size={16} className="text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm font-black text-slate-900 font-mono truncate"><Blur>{vehicle.plate}</Blur></span>
|
||||||
|
<span className={`text-[8px] px-1 rounded font-bold ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'}`}>
|
||||||
|
{vehicle.isOnline ? '在线' : '离线'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-bold text-slate-400 truncate">
|
||||||
|
{vehicle.rentStatus || ''}
|
||||||
|
{vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''}
|
||||||
|
{vehicle.customer ? ` · ` : ''}
|
||||||
|
{vehicle.customer && <Blur>{vehicle.customer}</Blur>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 时间范围切换 */}
|
||||||
|
<div className="px-4 pt-3">
|
||||||
|
<div className="relative inline-flex bg-slate-100 p-0.5 rounded-lg">
|
||||||
|
{RANGE_TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setRange(tab.key)}
|
||||||
|
className={`relative px-3 py-1 text-[10px] font-bold rounded-md transition-colors ${range === tab.key ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'}`}
|
||||||
|
>
|
||||||
|
{range === tab.key && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="rangeTabBg"
|
||||||
|
className="absolute inset-0 bg-white shadow-sm rounded-md"
|
||||||
|
transition={{ type: 'spring', damping: 30, stiffness: 350 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative">{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI cards */}
|
||||||
|
<div className="px-4 py-3 grid grid-cols-3 gap-2">
|
||||||
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
|
<div className="text-[9px] font-bold text-slate-400 uppercase">区间合计</div>
|
||||||
|
<div className="text-base font-black text-slate-900 leading-tight">
|
||||||
|
{loading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
|
||||||
|
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
|
<div className="text-[9px] font-bold text-slate-400 uppercase">日均</div>
|
||||||
|
<div className="text-base font-black text-slate-900 leading-tight">
|
||||||
|
{loading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
|
||||||
|
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
|
<div className="text-[9px] font-bold text-slate-400 uppercase">有数据天</div>
|
||||||
|
<div className="text-base font-black text-slate-900 leading-tight">
|
||||||
|
{loading ? <span className="inline-block h-4 w-12 bg-slate-200 rounded animate-pulse align-middle" />
|
||||||
|
: <>{stats.synced}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{stats.totalDays}</span></>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bar chart */}
|
||||||
|
<div className="px-4 pb-2">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-[10px] font-bold text-slate-500">行驶里程</span>
|
||||||
|
<span className="text-[9px] font-bold text-slate-300">单位 km</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-slate-50">
|
||||||
|
<div className="h-[140px]">
|
||||||
|
{loading ? (
|
||||||
|
<SkeletonBars count={Math.min(skeletonCount, 30)} />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(d) => formatLabel(d, range)}
|
||||||
|
tick={{ fontSize: 9, fill: '#94a3b8' }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
minTickGap={6}
|
||||||
|
/>
|
||||||
|
<YAxis hide />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(v) => [`${Math.round(Number(v) || 0).toLocaleString()} km`, '行驶里程']}
|
||||||
|
labelFormatter={(d) => `日期 ${d}`}
|
||||||
|
contentStyle={{ borderRadius: 12, fontSize: 11, padding: '4px 8px' }}
|
||||||
|
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="dailyKm" radius={[3, 3, 0, 0]} animationDuration={500}>
|
||||||
|
{historyDays.map((d, i) => (
|
||||||
|
<Cell key={i} fill={d.isDataSynced ? '#3b82f6' : '#e2e8f0'} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 每日明细 */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||||
|
<div className="text-[10px] font-bold text-slate-500 mb-1.5">每日明细</div>
|
||||||
|
{loading ? (
|
||||||
|
<SkeletonList count={Math.min(skeletonCount, 15)} />
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key={range}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="space-y-1"
|
||||||
|
>
|
||||||
|
{historyDays.slice().reverse().map((d, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={d.date}
|
||||||
|
initial={{ opacity: 0, x: -8 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }}
|
||||||
|
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<span className="text-[11px] font-mono font-bold text-slate-600">{d.date}</span>
|
||||||
|
<div className="flex items-center gap-2 flex-1 ml-3">
|
||||||
|
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${(d.dailyKm / stats.max) * 100}%` }}
|
||||||
|
transition={{ delay: Math.min(i * 0.012, 0.4) + 0.1, duration: 0.4 }}
|
||||||
|
className={`h-full rounded-full ${d.isDataSynced ? 'bg-blue-500' : 'bg-slate-200'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={`text-[11px] font-mono font-bold w-20 text-right ${d.isDataSynced ? 'text-slate-700' : 'text-amber-500/60'}`}>
|
||||||
|
{d.isDataSynced
|
||||||
|
? <>{Math.round(d.dailyKm).toLocaleString()} <span className="text-[9px] text-slate-400">km</span></>
|
||||||
|
: <span className="text-[9px]">未对接</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonBars({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-1 h-full px-2 pb-2 pt-2">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-1 bg-slate-100 rounded-t animate-pulse"
|
||||||
|
style={{
|
||||||
|
height: `${30 + Math.sin(i * 0.7) * 25 + Math.cos(i * 0.4) * 15 + 30}%`,
|
||||||
|
animationDelay: `${i * 40}ms`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonList({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between py-1.5 px-2 animate-pulse" style={{ animationDelay: `${i * 30}ms` }}>
|
||||||
|
<div className="h-3 w-20 bg-slate-100 rounded" />
|
||||||
|
<div className="flex items-center gap-2 flex-1 ml-3">
|
||||||
|
<div className="flex-1 h-1.5 bg-slate-100 rounded-full" />
|
||||||
|
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,3 +61,29 @@ export async function fetchTrend(targetId?: number, days = 7): Promise<TrendPoin
|
|||||||
params.set('days', String(days));
|
params.set('days', String(days));
|
||||||
return fetchJson<TrendPoint[]>(`${BASE}/trend?${params.toString()}`);
|
return fetchJson<TrendPoint[]>(`${BASE}/trend?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VehicleRecentDay {
|
||||||
|
date: string;
|
||||||
|
dailyKm: number;
|
||||||
|
isDataSynced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehicleRecentResponse {
|
||||||
|
plate: string;
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
days: VehicleRecentDay[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVehicleRecent(
|
||||||
|
plate: string,
|
||||||
|
range: { days?: number; start?: string; end?: string } = { days: 15 },
|
||||||
|
): Promise<VehicleRecentResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (range.start) params.set('start', range.start);
|
||||||
|
if (range.end) params.set('end', range.end);
|
||||||
|
if (range.days != null) params.set('days', String(range.days));
|
||||||
|
return fetchJson<VehicleRecentResponse>(
|
||||||
|
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import SuggestionDetail from './SuggestionDetail';
|
|||||||
import NotificationHistory from './NotificationHistory';
|
import NotificationHistory from './NotificationHistory';
|
||||||
import { exportSuggestionsCsv } from './csv-export';
|
import { exportSuggestionsCsv } from './csv-export';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
type TypeFilter = 'all' | 'qualified' | 'hopeless';
|
||||||
|
|
||||||
@@ -632,6 +633,7 @@ export default function SchedulingModule() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<RotatingFooterHint className="pb-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export async function authMiddleware(c: Context, next: Next) {
|
|||||||
depCode: '',
|
depCode: '',
|
||||||
depName: '',
|
depName: '',
|
||||||
permissionLevel: 'full',
|
permissionLevel: 'full',
|
||||||
roles: ['所有权限', 'BI-SCHEDULE-OPT'],
|
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
|
||||||
};
|
};
|
||||||
c.set('user', devUser);
|
c.set('user', devUser);
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -28,5 +28,9 @@ export {
|
|||||||
FULL_ACCESS_ROLES,
|
FULL_ACCESS_ROLES,
|
||||||
DEPT_ACCESS_ROLES,
|
DEPT_ACCESS_ROLES,
|
||||||
SCHEDULING_ACCESS_ROLES,
|
SCHEDULING_ACCESS_ROLES,
|
||||||
|
FEEDBACK_ADMIN_ROLES,
|
||||||
|
ENERGY_ACCESS_ROLES,
|
||||||
canAccessScheduling,
|
canAccessScheduling,
|
||||||
|
canManageFeedback,
|
||||||
|
canAccessEnergy,
|
||||||
} from '../../shared/auth/roles.js';
|
} from '../../shared/auth/roles.js';
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import vehiclesRouter from './routes/vehicles.js';
|
|||||||
import mileageRouter from './routes/mileage/index.js';
|
import mileageRouter from './routes/mileage/index.js';
|
||||||
import schedulingRouter from './routes/scheduling/index.js';
|
import schedulingRouter from './routes/scheduling/index.js';
|
||||||
import energyRouter from './routes/energy/index.js';
|
import energyRouter from './routes/energy/index.js';
|
||||||
|
import eleRouter from './routes/ele/index.js';
|
||||||
|
import feedbackRouter from './routes/feedback/index.js';
|
||||||
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
|
||||||
import authRouter from './auth/login.js';
|
import authRouter from './auth/login.js';
|
||||||
import { authMiddleware } from './auth/middleware.js';
|
import { authMiddleware } from './auth/middleware.js';
|
||||||
@@ -27,6 +29,8 @@ app.route('/api/vehicles', vehiclesRouter);
|
|||||||
app.route('/api/mileage', mileageRouter);
|
app.route('/api/mileage', mileageRouter);
|
||||||
app.route('/api/scheduling', schedulingRouter);
|
app.route('/api/scheduling', schedulingRouter);
|
||||||
app.route('/api/energy', energyRouter);
|
app.route('/api/energy', energyRouter);
|
||||||
|
app.route('/api/ele', eleRouter);
|
||||||
|
app.route('/api/feedback', feedbackRouter);
|
||||||
|
|
||||||
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
|
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
|
||||||
|
|
||||||
|
|||||||
355
src/server/routes/ele/index.ts
Normal file
355
src/server/routes/ele/index.ts
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { RowDataPacket, ResultSetHeader } from 'mysql2';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import pool from '../../db.js';
|
||||||
|
import { ensureChargeRecordTable } from './migration.js';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
// 与 xlsx 列名对齐
|
||||||
|
const COL = {
|
||||||
|
orderNo: '订单编号',
|
||||||
|
stationNo: '电站编号',
|
||||||
|
stationName: '电站名称',
|
||||||
|
terminalName: '终端名称',
|
||||||
|
region: '所属大区',
|
||||||
|
city: '所属城市',
|
||||||
|
district: '市区名称',
|
||||||
|
operatingCompany:'运营公司',
|
||||||
|
stationType: '电站类型',
|
||||||
|
orderStatus: '订单状态',
|
||||||
|
chargeForm: '充电形式',
|
||||||
|
startTime: '充电开始时间',
|
||||||
|
endTime: '充电结束时间',
|
||||||
|
duration: '充电时长(分钟)',
|
||||||
|
kwh: '充电电量(度)',
|
||||||
|
eFee: '充电电费(元)',
|
||||||
|
serviceFee: '充电服务费(元)',
|
||||||
|
fee: '充电费用(元)',
|
||||||
|
plate: '车牌号',
|
||||||
|
judgedPlate: '判定车牌号',
|
||||||
|
vin: '车架号',
|
||||||
|
customerName: '真实姓名',
|
||||||
|
customerPhone: '手机号',
|
||||||
|
enterpriseName: '企业名称',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function safeStr(v: unknown, max = 250): string | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
const s = String(v).trim();
|
||||||
|
if (!s) return null;
|
||||||
|
return s.slice(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeNum(v: unknown): number | null {
|
||||||
|
if (v == null || v === '') return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDt(v: unknown): string | null {
|
||||||
|
const s = safeStr(v);
|
||||||
|
if (!s) return null;
|
||||||
|
// Excel 文本化日期 "2026-04-29 16:24:05" 直接传给 MySQL DATETIME 是 OK 的
|
||||||
|
// 简单校验
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(s)) return null;
|
||||||
|
return s.length === 10 ? `${s} 00:00:00` : (s.length === 16 ? `${s}:00` : s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlate(p: unknown): string | null {
|
||||||
|
const s = safeStr(p, 32);
|
||||||
|
if (!s) return null;
|
||||||
|
// 去掉所有空白字符
|
||||||
|
const trimmed = s.replace(/\s+/g, '').toUpperCase();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findHeaderRow(rows: unknown[][]): { headerIdx: number; header: string[] } | null {
|
||||||
|
// 寻找含"订单编号"和"车牌号"的那一行
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
if (!Array.isArray(row)) continue;
|
||||||
|
const cells = row.map(c => (c == null ? '' : String(c)));
|
||||||
|
if (cells.includes(COL.orderNo) && cells.includes(COL.plate)) {
|
||||||
|
return { headerIdx: i, header: cells };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedRow {
|
||||||
|
orderNo: string;
|
||||||
|
raw: Record<string, unknown>;
|
||||||
|
values: {
|
||||||
|
stationNo: string | null; stationName: string | null; terminalName: string | null;
|
||||||
|
region: string | null; city: string | null; district: string | null;
|
||||||
|
operatingCompany: string | null; stationType: string | null;
|
||||||
|
orderStatus: string | null; chargeForm: string | null;
|
||||||
|
startTime: string | null; endTime: string | null;
|
||||||
|
duration: number | null; kwh: number | null;
|
||||||
|
eFee: number | null; serviceFee: number | null; fee: number | null;
|
||||||
|
plate: string | null; judgedPlate: string | null; vin: string | null;
|
||||||
|
customerName: string | null; customerPhone: string | null; enterpriseName: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSheet(buf: ArrayBuffer): ParsedRow[] {
|
||||||
|
const wb = XLSX.read(buf, { type: 'array' });
|
||||||
|
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||||
|
if (!ws) return [];
|
||||||
|
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { defval: null, raw: false, header: 1 });
|
||||||
|
const found = findHeaderRow(rows as unknown[][]);
|
||||||
|
if (!found) return [];
|
||||||
|
const { headerIdx, header } = found;
|
||||||
|
const idx = (label: string) => header.indexOf(label);
|
||||||
|
const result: ParsedRow[] = [];
|
||||||
|
for (let r = headerIdx + 1; r < rows.length; r++) {
|
||||||
|
const row = rows[r];
|
||||||
|
if (!Array.isArray(row)) continue;
|
||||||
|
const orderNo = safeStr(row[idx(COL.orderNo)]);
|
||||||
|
if (!orderNo) continue;
|
||||||
|
const raw: Record<string, unknown> = {};
|
||||||
|
header.forEach((h, i) => { raw[h] = row[i] ?? null; });
|
||||||
|
result.push({
|
||||||
|
orderNo,
|
||||||
|
raw,
|
||||||
|
values: {
|
||||||
|
stationNo: safeStr(row[idx(COL.stationNo)]),
|
||||||
|
stationName: safeStr(row[idx(COL.stationName)]),
|
||||||
|
terminalName: safeStr(row[idx(COL.terminalName)]),
|
||||||
|
region: safeStr(row[idx(COL.region)]),
|
||||||
|
city: safeStr(row[idx(COL.city)]),
|
||||||
|
district: safeStr(row[idx(COL.district)]),
|
||||||
|
operatingCompany: safeStr(row[idx(COL.operatingCompany)]),
|
||||||
|
stationType: safeStr(row[idx(COL.stationType)]),
|
||||||
|
orderStatus: safeStr(row[idx(COL.orderStatus)]),
|
||||||
|
chargeForm: safeStr(row[idx(COL.chargeForm)]),
|
||||||
|
startTime: safeDt(row[idx(COL.startTime)]),
|
||||||
|
endTime: safeDt(row[idx(COL.endTime)]),
|
||||||
|
duration: safeNum(row[idx(COL.duration)]),
|
||||||
|
kwh: safeNum(row[idx(COL.kwh)]),
|
||||||
|
eFee: safeNum(row[idx(COL.eFee)]),
|
||||||
|
serviceFee: safeNum(row[idx(COL.serviceFee)]),
|
||||||
|
fee: safeNum(row[idx(COL.fee)]),
|
||||||
|
plate: normalizePlate(row[idx(COL.plate)]),
|
||||||
|
judgedPlate: normalizePlate(row[idx(COL.judgedPlate)]),
|
||||||
|
vin: safeStr(row[idx(COL.vin)]),
|
||||||
|
customerName: safeStr(row[idx(COL.customerName)]),
|
||||||
|
customerPhone: safeStr(row[idx(COL.customerPhone)]),
|
||||||
|
enterpriseName: safeStr(row[idx(COL.enterpriseName)]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildPlateLookup(plates: Set<string>): Promise<Map<string, string>> {
|
||||||
|
if (plates.size === 0) return new Map();
|
||||||
|
const arr = Array.from(plates);
|
||||||
|
const placeholders = arr.map(() => '?').join(',');
|
||||||
|
const [rows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT plate_number, CAST(id AS CHAR) AS truck_id
|
||||||
|
FROM tab_truck
|
||||||
|
WHERE is_deleted = 0 AND plate_number IN (${placeholders})`,
|
||||||
|
arr,
|
||||||
|
);
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.plate_number && r.truck_id) map.set(String(r.plate_number).toUpperCase(), String(r.truck_id));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// POST /api/ele/import — 上传 xlsx 文件
|
||||||
|
// =========================================================
|
||||||
|
app.post('/import', async (c) => {
|
||||||
|
await ensureChargeRecordTable();
|
||||||
|
const form = await c.req.formData();
|
||||||
|
const file = form.get('file');
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return c.json({ ok: false, message: '未上传文件' }, 400);
|
||||||
|
}
|
||||||
|
const filename = file.name || 'unnamed.xlsx';
|
||||||
|
const buf = await file.arrayBuffer();
|
||||||
|
let parsed: ParsedRow[];
|
||||||
|
try {
|
||||||
|
parsed = parseSheet(buf);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('parseSheet error:', e);
|
||||||
|
return c.json({ ok: false, message: '解析失败:文件格式不正确' }, 400);
|
||||||
|
}
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
return c.json({ ok: false, message: '未识别到任何记录(请确认表头含「订单编号」与「车牌号」)' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件内去重
|
||||||
|
const dedupMap = new Map<string, ParsedRow>();
|
||||||
|
for (const p of parsed) dedupMap.set(p.orderNo, p);
|
||||||
|
const records = Array.from(dedupMap.values());
|
||||||
|
const fileDuplicates = parsed.length - records.length;
|
||||||
|
|
||||||
|
// 系统车辆匹配
|
||||||
|
const allPlates = new Set<string>();
|
||||||
|
for (const r of records) {
|
||||||
|
if (r.values.plate) allPlates.add(r.values.plate);
|
||||||
|
if (r.values.judgedPlate) allPlates.add(r.values.judgedPlate);
|
||||||
|
}
|
||||||
|
const plateMap = await buildPlateLookup(allPlates);
|
||||||
|
|
||||||
|
const batchId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const importedAt = new Date();
|
||||||
|
|
||||||
|
// 批量 INSERT IGNORE 实现订单编号 UNIQUE 去重
|
||||||
|
const sql = `INSERT IGNORE INTO bi_ele_charge_record
|
||||||
|
(order_no, station_no, station_name, terminal_name, region, city, district,
|
||||||
|
operating_company, station_type, order_status, charge_form,
|
||||||
|
start_time, end_time, duration_min, kwh, e_fee, service_fee, fee,
|
||||||
|
plate, judged_plate, vin, customer_name, customer_phone, enterprise_name,
|
||||||
|
matched_truck_id, matched_plate, vehicle_kind, raw_json,
|
||||||
|
batch_id, imported_at)
|
||||||
|
VALUES ?`;
|
||||||
|
|
||||||
|
const values = records.map(r => {
|
||||||
|
const plate = r.values.plate || r.values.judgedPlate;
|
||||||
|
const matchedId = plate ? plateMap.get(plate) || null : null;
|
||||||
|
// 命中系统车辆=internal;其余(含车牌为空)一律 external
|
||||||
|
const kind = matchedId ? 'internal' : 'external';
|
||||||
|
return [
|
||||||
|
r.orderNo,
|
||||||
|
r.values.stationNo, r.values.stationName, r.values.terminalName,
|
||||||
|
r.values.region, r.values.city, r.values.district,
|
||||||
|
r.values.operatingCompany, r.values.stationType,
|
||||||
|
r.values.orderStatus, r.values.chargeForm,
|
||||||
|
r.values.startTime, r.values.endTime, r.values.duration,
|
||||||
|
r.values.kwh, r.values.eFee, r.values.serviceFee, r.values.fee,
|
||||||
|
r.values.plate, r.values.judgedPlate, r.values.vin,
|
||||||
|
r.values.customerName, r.values.customerPhone, r.values.enterpriseName,
|
||||||
|
matchedId, matchedId ? plate : null, kind,
|
||||||
|
JSON.stringify(r.raw),
|
||||||
|
batchId, importedAt,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const [result] = await pool.query<ResultSetHeader>(sql, [values]);
|
||||||
|
const inserted = result.affectedRows;
|
||||||
|
const dbDuplicates = records.length - inserted;
|
||||||
|
|
||||||
|
// 统计内/外(无车牌也算外部)
|
||||||
|
let internal = 0, external = 0;
|
||||||
|
for (const r of records) {
|
||||||
|
const plate = r.values.plate || r.values.judgedPlate;
|
||||||
|
if (plate && plateMap.has(plate)) internal++;
|
||||||
|
else external++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
ok: true,
|
||||||
|
filename,
|
||||||
|
batchId,
|
||||||
|
parsed: parsed.length,
|
||||||
|
fileDuplicates,
|
||||||
|
inserted,
|
||||||
|
dbDuplicates,
|
||||||
|
breakdown: { internal, external },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// GET /api/ele/list — 分页列表(最新优先)
|
||||||
|
// =========================================================
|
||||||
|
app.get('/list', async (c) => {
|
||||||
|
await ensureChargeRecordTable();
|
||||||
|
const page = Math.max(1, Number(c.req.query('page')) || 1);
|
||||||
|
const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 50));
|
||||||
|
const kind = c.req.query('kind') || '';
|
||||||
|
const batchId = c.req.query('batchId') || '';
|
||||||
|
const search = c.req.query('search') || '';
|
||||||
|
|
||||||
|
const where: string[] = ['1=1'];
|
||||||
|
const params: (string | number)[] = [];
|
||||||
|
if (kind === 'internal' || kind === 'external') {
|
||||||
|
where.push('vehicle_kind = ?');
|
||||||
|
params.push(kind);
|
||||||
|
}
|
||||||
|
if (batchId) {
|
||||||
|
where.push('batch_id = ?');
|
||||||
|
params.push(batchId);
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
where.push('(order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)');
|
||||||
|
const q = `%${search}%`;
|
||||||
|
params.push(q, q, q);
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const [rows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT id, order_no, station_name, terminal_name, region, city,
|
||||||
|
start_time, end_time, duration_min, kwh, fee, e_fee, service_fee,
|
||||||
|
plate, judged_plate, customer_name, vehicle_kind,
|
||||||
|
batch_id, imported_at
|
||||||
|
FROM bi_ele_charge_record
|
||||||
|
WHERE ${where.join(' AND ')}
|
||||||
|
ORDER BY start_time DESC, id DESC
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
[...params, limit, offset],
|
||||||
|
);
|
||||||
|
const [countRows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT COUNT(*) AS total FROM bi_ele_charge_record WHERE ${where.join(' AND ')}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
const total = Number(countRows[0]?.total || 0);
|
||||||
|
return c.json({ items: rows, total, page, limit, totalPages: Math.ceil(total / limit) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// GET /api/ele/batches — 批次列表
|
||||||
|
// =========================================================
|
||||||
|
app.get('/batches', async (c) => {
|
||||||
|
await ensureChargeRecordTable();
|
||||||
|
const [rows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT batch_id,
|
||||||
|
MIN(imported_at) AS imported_at,
|
||||||
|
COUNT(*) AS records,
|
||||||
|
SUM(CASE WHEN vehicle_kind='internal' THEN 1 ELSE 0 END) AS internal_count,
|
||||||
|
SUM(CASE WHEN vehicle_kind='external' THEN 1 ELSE 0 END) AS external_count,
|
||||||
|
ROUND(SUM(kwh), 2) AS total_kwh,
|
||||||
|
ROUND(SUM(fee), 2) AS total_fee
|
||||||
|
FROM bi_ele_charge_record
|
||||||
|
GROUP BY batch_id
|
||||||
|
ORDER BY imported_at DESC
|
||||||
|
LIMIT 50`,
|
||||||
|
);
|
||||||
|
return c.json({ items: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// GET /api/ele/aggregate — 聚合统计
|
||||||
|
// =========================================================
|
||||||
|
app.get('/aggregate', async (c) => {
|
||||||
|
await ensureChargeRecordTable();
|
||||||
|
// 全量分类汇总
|
||||||
|
const [overallRows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT vehicle_kind,
|
||||||
|
COUNT(*) AS records,
|
||||||
|
ROUND(SUM(kwh), 2) AS total_kwh,
|
||||||
|
ROUND(SUM(fee), 2) AS total_fee
|
||||||
|
FROM bi_ele_charge_record
|
||||||
|
GROUP BY vehicle_kind`,
|
||||||
|
);
|
||||||
|
// 近 30 日按日
|
||||||
|
const [dailyRows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||||||
|
vehicle_kind,
|
||||||
|
COUNT(*) AS records,
|
||||||
|
ROUND(SUM(kwh), 2) AS total_kwh,
|
||||||
|
ROUND(SUM(fee), 2) AS total_fee
|
||||||
|
FROM bi_ele_charge_record
|
||||||
|
WHERE start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
|
GROUP BY DATE_FORMAT(start_time, '%Y-%m-%d'), vehicle_kind
|
||||||
|
ORDER BY date DESC`,
|
||||||
|
);
|
||||||
|
return c.json({ overall: overallRows, daily: dailyRows });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
49
src/server/routes/ele/migration.ts
Normal file
49
src/server/routes/ele/migration.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import pool from '../../db.js';
|
||||||
|
|
||||||
|
const CREATE_TABLE_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS bi_ele_charge_record (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
order_no VARCHAR(64) NOT NULL,
|
||||||
|
station_no VARCHAR(64) NULL,
|
||||||
|
station_name VARCHAR(128) NULL,
|
||||||
|
terminal_name VARCHAR(64) NULL,
|
||||||
|
region VARCHAR(64) NULL,
|
||||||
|
city VARCHAR(64) NULL,
|
||||||
|
district VARCHAR(64) NULL,
|
||||||
|
operating_company VARCHAR(128) NULL,
|
||||||
|
station_type VARCHAR(32) NULL,
|
||||||
|
order_status VARCHAR(32) NULL,
|
||||||
|
charge_form VARCHAR(32) NULL,
|
||||||
|
start_time DATETIME NULL,
|
||||||
|
end_time DATETIME NULL,
|
||||||
|
duration_min INT NULL,
|
||||||
|
kwh DECIMAL(10,3) NULL,
|
||||||
|
e_fee DECIMAL(10,2) NULL,
|
||||||
|
service_fee DECIMAL(10,2) NULL,
|
||||||
|
fee DECIMAL(10,2) NULL,
|
||||||
|
plate VARCHAR(32) NULL,
|
||||||
|
judged_plate VARCHAR(32) NULL,
|
||||||
|
vin VARCHAR(64) NULL,
|
||||||
|
customer_name VARCHAR(128) NULL,
|
||||||
|
customer_phone VARCHAR(32) NULL,
|
||||||
|
enterprise_name VARCHAR(128) NULL,
|
||||||
|
matched_truck_id VARCHAR(32) NULL,
|
||||||
|
matched_plate VARCHAR(32) NULL,
|
||||||
|
vehicle_kind ENUM('internal','external','unknown') NOT NULL DEFAULT 'unknown',
|
||||||
|
raw_json JSON NULL,
|
||||||
|
batch_id VARCHAR(64) NOT NULL,
|
||||||
|
imported_at DATETIME NOT NULL,
|
||||||
|
UNIQUE KEY uk_order_no (order_no),
|
||||||
|
KEY idx_start_time (start_time),
|
||||||
|
KEY idx_batch (batch_id),
|
||||||
|
KEY idx_kind (vehicle_kind),
|
||||||
|
KEY idx_plate (plate)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`;
|
||||||
|
|
||||||
|
let ensured = false;
|
||||||
|
export async function ensureChargeRecordTable(): Promise<void> {
|
||||||
|
if (ensured) return;
|
||||||
|
await pool.query(CREATE_TABLE_SQL);
|
||||||
|
ensured = true;
|
||||||
|
}
|
||||||
@@ -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,101 +2,190 @@ 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';
|
||||||
|
|
||||||
// 把 DATETIME (UTC 字面值) 转换为 CST 用户日期
|
// hydrogen_time 已是 CST 字面值,直接使用即可(不再 +8 小时)
|
||||||
const HYDROGEN_LOCAL = `DATE_ADD(hydrogen_time, INTERVAL 8 HOUR)`;
|
const HYDROGEN_LOCAL = `hydrogen_time`;
|
||||||
const ELECTRIC_LOCAL = `DATE_ADD(charging_start_time, INTERVAL 8 HOUR)`;
|
const ELECTRIC_LOCAL = `charging_start_time`;
|
||||||
|
|
||||||
type CustomerKind = 'external' | 'lingniu' | 'all';
|
type CustomerKind = 'external' | 'lingniu' | 'all';
|
||||||
|
|
||||||
|
// 外部/我司判定:truck_id 为空 = 外部;truck_id 非空 = 我司(羚牛车辆)
|
||||||
function customerClause(field: string, customer: CustomerKind): string {
|
function customerClause(field: string, customer: CustomerKind): string {
|
||||||
if (customer === 'lingniu') return `${field} IS NULL`;
|
if (customer === 'external') return `${field} IS NULL`;
|
||||||
if (customer === 'external') return `${field} IS NOT NULL`;
|
if (customer === 'lingniu') return `${field} IS NOT NULL`;
|
||||||
return '1=1';
|
return '1=1';
|
||||||
}
|
}
|
||||||
|
|
||||||
type Range = 'today' | 'thisWeek' | 'thisMonth' | 'thisQuarter' | 'last7' | 'last30';
|
type Range = 'thisWeek' | 'thisMonth' | 'last15';
|
||||||
|
|
||||||
function rangeClause(localExpr: string, range: Range): string {
|
function rangeClause(localExpr: string, range: Range): string {
|
||||||
switch (range) {
|
switch (range) {
|
||||||
case 'today': return `DATE(${localExpr}) = CURDATE()`;
|
|
||||||
case 'thisWeek': return `YEARWEEK(${localExpr}, 1) = YEARWEEK(CURDATE(), 1)`;
|
case 'thisWeek': return `YEARWEEK(${localExpr}, 1) = YEARWEEK(CURDATE(), 1)`;
|
||||||
case 'thisMonth': return `DATE_FORMAT(${localExpr}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')`;
|
case 'thisMonth': return `DATE_FORMAT(${localExpr}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')`;
|
||||||
case 'thisQuarter': return `YEAR(${localExpr}) = YEAR(CURDATE()) AND QUARTER(${localExpr}) = QUARTER(CURDATE())`;
|
case 'last15': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 14 DAY) AND CURDATE()`;
|
||||||
case 'last7': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 6 DAY) AND CURDATE()`;
|
|
||||||
case 'last30': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 29 DAY) AND CURDATE()`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 列出某 range 在当前时点下的全部日期(YYYY-MM-DD),用于补零 */
|
||||||
|
function enumerateDates(range: Range): string[] {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
let start: Date;
|
||||||
|
if (range === 'thisWeek') {
|
||||||
|
// 周一为一周开始(与 YEARWEEK(?, 1) 一致)
|
||||||
|
const day = today.getDay() || 7; // 周日 7
|
||||||
|
start = new Date(today);
|
||||||
|
start.setDate(today.getDate() - (day - 1));
|
||||||
|
} else if (range === 'thisMonth') {
|
||||||
|
start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
} else {
|
||||||
|
start = new Date(today);
|
||||||
|
start.setDate(today.getDate() - 14);
|
||||||
|
}
|
||||||
|
const result: string[] = [];
|
||||||
|
const cur = new Date(start);
|
||||||
|
while (cur <= today) {
|
||||||
|
result.push(fmt(cur));
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
// 氢能 总览: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 [kpiRows] = await pool.query<RowDataPacket[]>(
|
const today = new Date();
|
||||||
`SELECT
|
const todayYear = today.getFullYear();
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE())
|
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS yearKg,
|
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE())
|
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
|
||||||
THEN cost_expense ELSE 0 END) AS yearFee,
|
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND customer_id IS NULL
|
const [yearListRows] = await pool.query<RowDataPacket[]>(
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS ourYearKg,
|
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND customer_id IS NULL
|
|
||||||
THEN cost_expense ELSE 0 END) AS ourYearFee,
|
|
||||||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND customer_id IS NOT NULL
|
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS customerYearKg,
|
|
||||||
SUM(CASE WHEN DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS monthKg,
|
|
||||||
SUM(CASE WHEN DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
|
||||||
THEN cost_expense ELSE 0 END) AS monthFee,
|
|
||||||
SUM(CASE WHEN DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS todayKg,
|
|
||||||
SUM(CASE WHEN DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
|
||||||
THEN cost_expense ELSE 0 END) AS todayFee,
|
|
||||||
SUM(CASE WHEN customer_id IS NULL
|
|
||||||
THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg,
|
|
||||||
SUM(CASE WHEN customer_id IS NULL
|
|
||||||
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} >= ?
|
||||||
|
ORDER BY y DESC`,
|
||||||
[HYDROGEN_MIN_DATE],
|
[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[]>(
|
||||||
|
`SELECT
|
||||||
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
|
THEN hydrogen_quantity ELSE 0 END) AS yearKg,
|
||||||
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||||||
|
THEN cost_expense ELSE 0 END) AS yearFee,
|
||||||
|
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,
|
||||||
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
||||||
|
THEN cost_expense ELSE 0 END) AS ourYearFee,
|
||||||
|
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
||||||
|
THEN hydrogen_quantity ELSE 0 END) AS customerYearKg,
|
||||||
|
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
|
THEN hydrogen_quantity ELSE 0 END) AS monthKg,
|
||||||
|
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
|
THEN cost_expense ELSE 0 END) AS monthFee,
|
||||||
|
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,
|
||||||
|
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||||||
|
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
|
||||||
|
THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg,
|
||||||
|
SUM(CASE WHEN truck_id IS NOT NULL
|
||||||
|
THEN cost_expense ELSE 0 END) AS lingniuBornFee
|
||||||
|
FROM tab_energy_hydrogen_bill
|
||||||
|
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?`,
|
||||||
|
[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(s.short_name, s.name, os.fixed_station_name, os.station_name, '未知站点') AS name,
|
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.hydrogen_quantity) AS kg,
|
||||||
SUM(b.cost_expense) AS fee
|
SUM(b.cost_expense) AS fee
|
||||||
FROM tab_energy_hydrogen_bill b
|
FROM tab_energy_hydrogen_bill b
|
||||||
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
|
||||||
|
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(${HYDROGEN_LOCAL}) = 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) => ({
|
||||||
@@ -107,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,
|
||||||
@@ -116,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(${HYDROGEN_LOCAL}) = 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;
|
||||||
@@ -136,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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,28 +324,37 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
// 氢能 每日:日期范围 + 客户类型 + 站点级下钻
|
// 氢能 每日:日期范围 + 客户类型 + 站点级下钻
|
||||||
// =========================================================
|
// =========================================================
|
||||||
app.get('/hydrogen/daily', async (c) => {
|
app.get('/hydrogen/daily', async (c) => {
|
||||||
const range = (c.req.query('range') || 'last30') 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 () => {
|
||||||
|
|
||||||
const where = [
|
const where = [
|
||||||
'b.is_deleted = 0',
|
'b.is_deleted = 0',
|
||||||
`b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`,
|
`b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`,
|
||||||
rangeClause(`b.hydrogen_time + INTERVAL 8 HOUR`, range),
|
rangeClause(`b.hydrogen_time`, range),
|
||||||
customerClause('b.customer_id', customer),
|
customerClause('b.truck_id', customer),
|
||||||
].join(' AND ');
|
].join(' AND ');
|
||||||
|
|
||||||
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||||||
|
// 站点名 fallback:内部站表 → 外部站表 → 导入订单表(tab_import_hydrogen_order,按 bill_code 关联)
|
||||||
|
// 单价不重算:同价组显示原价,混合价组返回 NULL,前端显示「—」
|
||||||
const [stationRows] = await pool.query<RowDataPacket[]>(
|
const [stationRows] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
|
`SELECT DATE_FORMAT(b.hydrogen_time, '%Y-%m-%d') AS d,
|
||||||
b.hydrogen_station_id AS stationId,
|
b.hydrogen_station_id AS stationId,
|
||||||
COALESCE(s.short_name, s.name, os.fixed_station_name, os.station_name, '未知站点') AS stationName,
|
COALESCE(MAX(s.short_name), MAX(s.name),
|
||||||
SUM(b.hydrogen_quantity) AS kg,
|
MAX(os.fixed_station_name), MAX(os.station_name),
|
||||||
AVG(b.cost_price) AS pricePerKg
|
MAX(i.hydrogen_station_name),
|
||||||
|
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||||||
|
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS stationName,
|
||||||
|
ROUND(SUM(b.hydrogen_quantity), 2) AS kg,
|
||||||
|
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
|
||||||
|
MAX(b.cost_price) AS pricePerKg
|
||||||
FROM tab_energy_hydrogen_bill b
|
FROM tab_energy_hydrogen_bill b
|
||||||
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
|
||||||
|
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
||||||
WHERE ${where}
|
WHERE ${where}
|
||||||
GROUP BY d, b.hydrogen_station_id
|
GROUP BY d, b.hydrogen_station_id
|
||||||
ORDER BY d DESC, kg DESC`,
|
ORDER BY d DESC, kg DESC`,
|
||||||
@@ -218,49 +406,58 @@ app.get('/hydrogen/daily', async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组装为 HydrogenDailyRow[],按日期降序
|
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
|
||||||
const result = Array.from(dayMap.entries())
|
const allDates = enumerateDates(range);
|
||||||
.map(([date, info]) => ({
|
const fullDays = allDates.map(date => {
|
||||||
|
const info = dayMap.get(date);
|
||||||
|
return {
|
||||||
date,
|
date,
|
||||||
totalKg: Math.round(info.totalKg * 100) / 100,
|
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
|
||||||
chainPct: dayChainPct.get(date) ?? 0,
|
chainPct: dayChainPct.get(date) ?? 0,
|
||||||
customerType: customer === 'lingniu' ? 'lingniu' : 'external',
|
customerType: customer === 'lingniu' ? 'lingniu' : 'external',
|
||||||
stations: info.stations
|
stations: info
|
||||||
.slice()
|
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
||||||
.sort((a, b) => b.kg - a.kg)
|
name: s.name,
|
||||||
.map(s => ({
|
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
|
||||||
name: s.name,
|
kg: Math.round(s.kg * 100) / 100,
|
||||||
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
|
chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0,
|
||||||
kg: Math.round(s.kg * 100) / 100,
|
}))
|
||||||
chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0,
|
: [],
|
||||||
})),
|
};
|
||||||
}))
|
|
||||||
.sort((a, b) => b.date.localeCompare(a.date));
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 全量日期重算环比(含补零日,0→上一日有值时显示 -100%)
|
||||||
|
const ascDays = [...fullDays].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
let prevKg = 0;
|
||||||
|
for (const d of ascDays) {
|
||||||
|
d.chainPct = prevKg > 0 ? (d.totalKg - prevKg) / prevKg : 0;
|
||||||
|
prevKg = d.totalKg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按日期降序返回
|
||||||
|
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
return result;
|
||||||
|
}, { force });
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
// 电能 总览:KPI + 本月每日柱图数据
|
// 电能 总览: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
|
||||||
SUM(charging_degree) AS totalKwh,
|
SUM(kwh) AS totalKwh,
|
||||||
SUM(cost_expense) AS totalFee,
|
SUM(fee) AS totalFee,
|
||||||
SUM(CASE WHEN DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
THEN charging_degree ELSE 0 END) AS monthKwh,
|
THEN kwh ELSE 0 END) AS monthKwh,
|
||||||
SUM(CASE WHEN DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
THEN cost_expense ELSE 0 END) AS monthFee,
|
THEN fee ELSE 0 END) AS monthFee,
|
||||||
SUM(CASE WHEN DATE(${ELECTRIC_LOCAL}) = CURDATE()
|
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN kwh ELSE 0 END) AS todayKwh,
|
||||||
THEN charging_degree ELSE 0 END) AS todayKwh,
|
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee
|
||||||
SUM(CASE WHEN DATE(${ELECTRIC_LOCAL}) = CURDATE()
|
FROM bi_ele_charge_record`,
|
||||||
THEN cost_expense ELSE 0 END) AS todayFee
|
|
||||||
FROM tab_energy_electricity_bill
|
|
||||||
WHERE is_deleted = 0`,
|
|
||||||
);
|
);
|
||||||
const k = kpiRows[0] ?? {};
|
const k = kpiRows[0] ?? {};
|
||||||
const totalKwh = Number(k.totalKwh) || 0;
|
const totalKwh = Number(k.totalKwh) || 0;
|
||||||
@@ -272,29 +469,25 @@ app.get('/electric/overview', async (c) => {
|
|||||||
|
|
||||||
// 本月每日(用于柱图)
|
// 本月每日(用于柱图)
|
||||||
const [trendRows] = await pool.query<RowDataPacket[]>(
|
const [trendRows] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m-%d') AS date,
|
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||||||
SUM(charging_degree) AS kwh,
|
SUM(kwh) AS kwh,
|
||||||
SUM(cost_expense) AS fee
|
SUM(fee) AS fee
|
||||||
FROM tab_energy_electricity_bill
|
FROM bi_ele_charge_record
|
||||||
WHERE is_deleted = 0
|
WHERE DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||||
AND DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
|
||||||
GROUP BY date
|
GROUP BY date
|
||||||
ORDER BY date ASC`,
|
ORDER BY date ASC`,
|
||||||
);
|
);
|
||||||
// 若本月无数据(电能数据滞后),降级展示最近一个有数据的自然月
|
// 若本月无数据,降级展示最近一个有数据的自然月
|
||||||
let trend = trendRows;
|
let trend = trendRows;
|
||||||
if (trend.length === 0) {
|
if (trend.length === 0) {
|
||||||
const [fallback] = await pool.query<RowDataPacket[]>(
|
const [fallback] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m-%d') AS date,
|
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||||||
SUM(charging_degree) AS kwh,
|
SUM(kwh) AS kwh,
|
||||||
SUM(cost_expense) AS fee
|
SUM(fee) AS fee
|
||||||
FROM tab_energy_electricity_bill
|
FROM bi_ele_charge_record
|
||||||
WHERE is_deleted = 0
|
WHERE DATE_FORMAT(start_time, '%Y-%m') = (
|
||||||
AND DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') = (
|
SELECT DATE_FORMAT(MAX(start_time), '%Y-%m') FROM bi_ele_charge_record
|
||||||
SELECT DATE_FORMAT(MAX(${ELECTRIC_LOCAL}), '%Y-%m')
|
)
|
||||||
FROM tab_energy_electricity_bill
|
|
||||||
WHERE is_deleted = 0
|
|
||||||
)
|
|
||||||
GROUP BY date
|
GROUP BY date
|
||||||
ORDER BY date ASC`,
|
ORDER BY date ASC`,
|
||||||
);
|
);
|
||||||
@@ -306,20 +499,17 @@ app.get('/electric/overview', async (c) => {
|
|||||||
fee: Math.round((Number(r.fee) || 0) * 100) / 100,
|
fee: Math.round((Number(r.fee) || 0) * 100) / 100,
|
||||||
chainPct: 0,
|
chainPct: 0,
|
||||||
}));
|
}));
|
||||||
// 计算环比
|
|
||||||
for (let i = 1; i < trendArr.length; i++) {
|
for (let i = 1; i < trendArr.length; i++) {
|
||||||
const prev = trendArr[i - 1].kwh;
|
const prev = trendArr[i - 1].kwh;
|
||||||
trendArr[i].chainPct = prev > 0 ? (trendArr[i].kwh - prev) / prev : 0;
|
trendArr[i].chainPct = prev > 0 ? (trendArr[i].kwh - prev) / prev : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 今日环比 = 今日 kwh / 上一个有数据的自然日 kwh - 1
|
|
||||||
let todayChainPct = 0;
|
let todayChainPct = 0;
|
||||||
if (todayKwh > 0) {
|
if (todayKwh > 0) {
|
||||||
const [prevRow] = await pool.query<RowDataPacket[]>(
|
const [prevRow] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT SUM(charging_degree) AS kwh
|
`SELECT SUM(kwh) AS kwh
|
||||||
FROM tab_energy_electricity_bill
|
FROM bi_ele_charge_record
|
||||||
WHERE is_deleted = 0
|
WHERE DATE(start_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`,
|
||||||
AND DATE(${ELECTRIC_LOCAL}) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`,
|
|
||||||
);
|
);
|
||||||
const prevKwh = Number(prevRow[0]?.kwh) || 0;
|
const prevKwh = Number(prevRow[0]?.kwh) || 0;
|
||||||
todayChainPct = prevKwh > 0 ? (todayKwh - prevKwh) / prevKwh : 0;
|
todayChainPct = prevKwh > 0 ? (todayKwh - prevKwh) / prevKwh : 0;
|
||||||
@@ -329,66 +519,84 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
// 电能 每日:月份分组 + 日级行
|
// 电能 每日:月份分组 + 日级行 —— 数据源:bi_ele_charge_record
|
||||||
|
// 支持 range 参数(thisWeek / thisMonth / last15)
|
||||||
|
// 缺失日期补零
|
||||||
// =========================================================
|
// =========================================================
|
||||||
app.get('/electric/monthly', async (c) => {
|
app.get('/electric/monthly', async (c) => {
|
||||||
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
|
||||||
|
const range = (c.req.query('range') || 'last15') as Range;
|
||||||
|
const force = c.req.query('force') === '1';
|
||||||
|
|
||||||
const data = await cached(`electric/monthly?customer=${customer}`, async () => {
|
const data = await cached(`electric/monthly?customer=${customer}&range=${range}`, async () => {
|
||||||
|
|
||||||
const where = [
|
// bi_ele_charge_record 用 vehicle_kind 区分:internal=我司,external=外部
|
||||||
'is_deleted = 0',
|
let kindClause = '1=1';
|
||||||
customerClause('customer_id', customer),
|
if (customer === 'lingniu') kindClause = `vehicle_kind = 'internal'`;
|
||||||
].join(' AND ');
|
if (customer === 'external') kindClause = `vehicle_kind = 'external'`;
|
||||||
|
|
||||||
// 取最近 6 个月
|
|
||||||
const [rows] = await pool.query<RowDataPacket[]>(
|
const [rows] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') AS month,
|
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||||||
DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m-%d') AS date,
|
SUM(kwh) AS kwh,
|
||||||
SUM(charging_degree) AS kwh,
|
SUM(fee) AS fee
|
||||||
SUM(cost_expense) AS fee
|
FROM bi_ele_charge_record
|
||||||
FROM tab_energy_electricity_bill
|
WHERE ${kindClause}
|
||||||
WHERE ${where}
|
AND ${rangeClause('start_time', range)}
|
||||||
AND ${ELECTRIC_LOCAL} >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
GROUP BY date`,
|
||||||
GROUP BY month, date
|
|
||||||
ORDER BY date DESC`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 组装 month group with daily rows + chainPct
|
// 实际数据 map
|
||||||
const monthMap = new Map<string, Array<{ date: string; kwh: number; fee: number }>>();
|
const dataMap = new Map<string, { kwh: number; fee: number }>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const m = r.month as string;
|
dataMap.set(r.date as string, {
|
||||||
if (!monthMap.has(m)) monthMap.set(m, []);
|
|
||||||
monthMap.get(m)!.push({
|
|
||||||
date: r.date as string,
|
|
||||||
kwh: Number(r.kwh) || 0,
|
kwh: Number(r.kwh) || 0,
|
||||||
fee: Number(r.fee) || 0,
|
fee: Number(r.fee) || 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 补零:枚举 range 全部日期
|
||||||
|
const allDates = enumerateDates(range);
|
||||||
|
const fullDays = allDates.map(date => {
|
||||||
|
const d = dataMap.get(date);
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
kwh: d ? Math.round(d.kwh * 100) / 100 : 0,
|
||||||
|
fee: d ? Math.round(d.fee * 100) / 100 : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按月份分组(asc 内日期倒序,但月份分组按 desc)
|
||||||
|
const monthMap = new Map<string, typeof fullDays>();
|
||||||
|
for (const d of fullDays) {
|
||||||
|
const m = d.date.slice(0, 7);
|
||||||
|
if (!monthMap.has(m)) monthMap.set(m, []);
|
||||||
|
monthMap.get(m)!.push(d);
|
||||||
|
}
|
||||||
|
|
||||||
const months = Array.from(monthMap.entries())
|
const months = Array.from(monthMap.entries())
|
||||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||||
.map(([month, daysDesc]) => {
|
.map(([month, days]) => {
|
||||||
// 计算环比:daysDesc 是 DESC,需要按 ASC 算
|
const asc = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
const asc = [...daysDesc].sort((a, b) => a.date.localeCompare(b.date));
|
|
||||||
const chain = new Map<string, number>();
|
const chain = new Map<string, number>();
|
||||||
for (let i = 1; i < asc.length; i++) {
|
let prev = 0;
|
||||||
const prev = asc[i - 1].kwh;
|
for (const d of asc) {
|
||||||
chain.set(asc[i].date, prev > 0 ? (asc[i].kwh - prev) / prev : 0);
|
chain.set(d.date, prev > 0 ? (d.kwh - prev) / prev : 0);
|
||||||
|
prev = d.kwh;
|
||||||
}
|
}
|
||||||
const rowsWithChain = daysDesc.map(d => ({
|
const desc = [...days].sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
const rowsWithChain = desc.map(d => ({
|
||||||
date: d.date,
|
date: d.date,
|
||||||
kwh: Math.round(d.kwh * 100) / 100,
|
kwh: d.kwh,
|
||||||
fee: Math.round(d.fee * 100) / 100,
|
fee: d.fee,
|
||||||
chainPct: chain.get(d.date) ?? 0,
|
chainPct: chain.get(d.date) ?? 0,
|
||||||
}));
|
}));
|
||||||
const kwhSum = daysDesc.reduce((s, d) => s + d.kwh, 0);
|
const kwhSum = days.reduce((s, d) => s + d.kwh, 0);
|
||||||
const feeSum = daysDesc.reduce((s, d) => s + d.fee, 0);
|
const feeSum = days.reduce((s, d) => s + d.fee, 0);
|
||||||
return {
|
return {
|
||||||
month,
|
month,
|
||||||
kwh: Math.round(kwhSum * 100) / 100,
|
kwh: Math.round(kwhSum * 100) / 100,
|
||||||
@@ -398,7 +606,7 @@ app.get('/electric/monthly', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return months;
|
return months;
|
||||||
});
|
}, { force });
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
190
src/server/routes/feedback/index.ts
Normal file
190
src/server/routes/feedback/index.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||||
|
import pool from '../../db.js';
|
||||||
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
|
import { canManageFeedback } from '../../auth/types.js';
|
||||||
|
import { uploadFeedbackImage } from './oss.js';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||||
|
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
||||||
|
|
||||||
|
const CREATE_TABLE_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS bi_user_feedback (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
type ENUM('dimension','bug','ux','other') NOT NULL DEFAULT 'other',
|
||||||
|
module VARCHAR(64) NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
contact VARCHAR(200) NULL,
|
||||||
|
screenshots JSON NULL,
|
||||||
|
user_id VARCHAR(64) NULL,
|
||||||
|
user_name VARCHAR(128) NULL,
|
||||||
|
user_agent VARCHAR(512) NULL,
|
||||||
|
status ENUM('open','in_progress','done','rejected') NOT NULL DEFAULT 'open',
|
||||||
|
reply_content TEXT NULL,
|
||||||
|
reply_user VARCHAR(128) NULL,
|
||||||
|
reply_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
KEY idx_created_at (created_at),
|
||||||
|
KEY idx_type (type),
|
||||||
|
KEY idx_status (status),
|
||||||
|
KEY idx_user_id (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`;
|
||||||
|
|
||||||
|
let ensured = false;
|
||||||
|
async function ensureTable(): Promise<void> {
|
||||||
|
if (ensured) return;
|
||||||
|
await pool.query(CREATE_TABLE_SQL);
|
||||||
|
// 兼容旧表:补齐缺失列
|
||||||
|
for (const alter of [
|
||||||
|
`ALTER TABLE bi_user_feedback ADD COLUMN screenshots JSON NULL AFTER contact`,
|
||||||
|
`ALTER TABLE bi_user_feedback ADD COLUMN reply_content TEXT NULL AFTER status`,
|
||||||
|
`ALTER TABLE bi_user_feedback ADD COLUMN reply_user VARCHAR(128) NULL AFTER reply_content`,
|
||||||
|
`ALTER TABLE bi_user_feedback ADD COLUMN reply_at DATETIME NULL AFTER reply_user`,
|
||||||
|
`ALTER TABLE bi_user_feedback ADD INDEX idx_user_id (user_id)`,
|
||||||
|
]) {
|
||||||
|
try { await pool.query(alter); } catch { /* 已存在则忽略 */ }
|
||||||
|
}
|
||||||
|
ensured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']);
|
||||||
|
|
||||||
|
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
|
||||||
|
|
||||||
|
// 写入时间戳一律用东八区 CST,避免依赖 MySQL/容器时区设置
|
||||||
|
const CST_NOW = `DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR)`;
|
||||||
|
|
||||||
|
app.post('/submit', async (c) => {
|
||||||
|
await ensureTable();
|
||||||
|
const body = await c.req.json().catch(() => ({})) as {
|
||||||
|
type?: string; module?: string | null; content?: string;
|
||||||
|
contact?: string | null; userAgent?: string; screenshots?: string[];
|
||||||
|
};
|
||||||
|
const type = (body.type || '').trim();
|
||||||
|
const content = (body.content || '').trim();
|
||||||
|
if (!VALID_TYPES.has(type)) {
|
||||||
|
return c.json({ ok: false, message: '类型不合法' }, 400);
|
||||||
|
}
|
||||||
|
if (!content || content.length > 2000) {
|
||||||
|
return c.json({ ok: false, message: '内容长度需在 1-2000 字之间' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||||
|
const moduleVal = (body.module || '').slice(0, 64) || null;
|
||||||
|
const contact = (body.contact || '').slice(0, 200) || null;
|
||||||
|
const userAgent = (body.userAgent || '').slice(0, 512) || null;
|
||||||
|
const screenshots = Array.isArray(body.screenshots)
|
||||||
|
? body.screenshots.filter(s => typeof s === 'string' && /^https?:\/\//.test(s)).slice(0, 6)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const [r] = await pool.query<ResultSetHeader>(
|
||||||
|
`INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ${CST_NOW})`,
|
||||||
|
[type, moduleVal, content, contact, JSON.stringify(screenshots), user?.userId || null, user?.userName || null, userAgent],
|
||||||
|
);
|
||||||
|
return c.json({ ok: true, id: r.insertId });
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// POST /api/feedback/upload — 单张截图上传(multipart/form-data, field=file)
|
||||||
|
// =========================================================
|
||||||
|
app.post('/upload', async (c) => {
|
||||||
|
const form = await c.req.formData();
|
||||||
|
const file = form.get('file');
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return c.json({ ok: false, message: '未上传文件' }, 400);
|
||||||
|
}
|
||||||
|
const mime = file.type || 'image/png';
|
||||||
|
if (!ALLOWED_MIME.has(mime)) {
|
||||||
|
return c.json({ ok: false, message: `不支持的文件类型:${mime}` }, 400);
|
||||||
|
}
|
||||||
|
if (file.size > MAX_IMAGE_SIZE) {
|
||||||
|
return c.json({ ok: false, message: `图片过大(${(file.size / 1024 / 1024).toFixed(1)}MB)`}, 400);
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(await file.arrayBuffer());
|
||||||
|
try {
|
||||||
|
const url = await uploadFeedbackImage(file.name || 'screenshot.png', buf, mime);
|
||||||
|
return c.json({ ok: true, url });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('feedback upload error:', e);
|
||||||
|
return c.json({ ok: false, message: e instanceof Error ? e.message : '上传失败' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/feedback/mine — 当前用户的反馈历史
|
||||||
|
app.get('/mine', async (c) => {
|
||||||
|
await ensureTable();
|
||||||
|
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||||
|
if (!user?.userId) return c.json({ items: [] });
|
||||||
|
const [rows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT id, type, module, content, contact, screenshots, status,
|
||||||
|
reply_content, reply_user, reply_at, created_at
|
||||||
|
FROM bi_user_feedback
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100`,
|
||||||
|
[user.userId],
|
||||||
|
);
|
||||||
|
return c.json({ items: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||||||
|
app.get('/list', async (c) => {
|
||||||
|
await ensureTable();
|
||||||
|
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||||
|
if (!canManageFeedback(user?.roles)) {
|
||||||
|
return c.json({ ok: false, message: '无权限' }, 403);
|
||||||
|
}
|
||||||
|
const limit = Math.min(500, Math.max(1, Number(c.req.query('limit')) || 100));
|
||||||
|
const status = c.req.query('status') || '';
|
||||||
|
const where: string[] = ['1=1'];
|
||||||
|
const params: (string | number)[] = [];
|
||||||
|
if (VALID_STATUS.has(status)) {
|
||||||
|
where.push('status = ?');
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
const [rows] = await pool.query<RowDataPacket[]>(
|
||||||
|
`SELECT id, type, module, content, contact, screenshots, user_id, user_name, status,
|
||||||
|
reply_content, reply_user, reply_at, created_at
|
||||||
|
FROM bi_user_feedback
|
||||||
|
WHERE ${where.join(' AND ')}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[...params, limit],
|
||||||
|
);
|
||||||
|
return c.json({ items: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限)
|
||||||
|
app.patch('/:id', async (c) => {
|
||||||
|
await ensureTable();
|
||||||
|
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||||
|
if (!canManageFeedback(user?.roles)) {
|
||||||
|
return c.json({ ok: false, message: '无权限' }, 403);
|
||||||
|
}
|
||||||
|
const id = Number(c.req.param('id'));
|
||||||
|
if (!Number.isFinite(id) || id <= 0) return c.json({ ok: false, message: 'id 不合法' }, 400);
|
||||||
|
const body = await c.req.json().catch(() => ({})) as { status?: string; reply?: string };
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: (string | number | null)[] = [];
|
||||||
|
if (body.status) {
|
||||||
|
if (!VALID_STATUS.has(body.status)) return c.json({ ok: false, message: '状态不合法' }, 400);
|
||||||
|
fields.push('status = ?');
|
||||||
|
params.push(body.status);
|
||||||
|
}
|
||||||
|
if (typeof body.reply === 'string') {
|
||||||
|
const reply = body.reply.trim().slice(0, 2000);
|
||||||
|
fields.push('reply_content = ?', 'reply_user = ?', `reply_at = ${CST_NOW}`);
|
||||||
|
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||||
|
params.push(reply || null, user?.userName || user?.userId || null);
|
||||||
|
}
|
||||||
|
if (fields.length === 0) return c.json({ ok: false, message: '没有可更新的字段' }, 400);
|
||||||
|
params.push(id);
|
||||||
|
await pool.query(`UPDATE bi_user_feedback SET ${fields.join(', ')} WHERE id = ?`, params);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
38
src/server/routes/feedback/oss.ts
Normal file
38
src/server/routes/feedback/oss.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import OSS from 'ali-oss';
|
||||||
|
|
||||||
|
let client: OSS | null = null;
|
||||||
|
|
||||||
|
function getClient(): OSS {
|
||||||
|
if (client) return client;
|
||||||
|
const region = process.env.OSS_REGION || 'oss-cn-shanghai';
|
||||||
|
const accessKeyId = process.env.OSS_ACCESS_KEY_ID || '';
|
||||||
|
const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET || '';
|
||||||
|
const bucket = process.env.OSS_BUCKET || '';
|
||||||
|
if (!accessKeyId || !accessKeySecret || !bucket) {
|
||||||
|
throw new Error('OSS 未配置:OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET / OSS_BUCKET');
|
||||||
|
}
|
||||||
|
client = new OSS({ region, accessKeyId, accessKeySecret, bucket, secure: true });
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeExt(filename: string, fallback = 'png'): string {
|
||||||
|
const m = /\.([a-zA-Z0-9]{1,8})$/.exec(filename);
|
||||||
|
return m ? m[1].toLowerCase() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randId(len = 8): string {
|
||||||
|
return Math.random().toString(36).slice(2, 2 + len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传 buffer 到 OSS,返回公开访问的 URL */
|
||||||
|
export async function uploadFeedbackImage(filename: string, buf: Buffer, mimetype: string): Promise<string> {
|
||||||
|
const c = getClient();
|
||||||
|
const baseDir = (process.env.OSS_BASE_DIR || '/dos').replace(/^\/+|\/+$/g, '');
|
||||||
|
const ymd = new Date().toISOString().slice(0, 10);
|
||||||
|
const key = `${baseDir}/feedback/${ymd}/${Date.now().toString(36)}-${randId()}.${safeExt(filename, mimetype.split('/')[1] || 'png')}`;
|
||||||
|
await c.put(key, buf, {
|
||||||
|
headers: { 'Content-Type': mimetype, 'x-oss-object-acl': 'public-read' },
|
||||||
|
});
|
||||||
|
const host = (process.env.OSS_HOST || `https://${process.env.OSS_BUCKET}.${process.env.OSS_ENDPOINT}/`).replace(/\/+$/, '/');
|
||||||
|
return host + key;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { refreshMonitoringCache } from './cache.js';
|
|||||||
import monitoringRouter from './monitoring.js';
|
import monitoringRouter from './monitoring.js';
|
||||||
import targetsRouter from './targets.js';
|
import targetsRouter from './targets.js';
|
||||||
import trendRouter from './trend.js';
|
import trendRouter from './trend.js';
|
||||||
|
import vehicleRecentRouter from './vehicle-recent.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ app.route('/monitoring', monitoringRouter);
|
|||||||
app.route('/targets', targetsRouter);
|
app.route('/targets', targetsRouter);
|
||||||
app.route('/target', targetsRouter);
|
app.route('/target', targetsRouter);
|
||||||
app.route('/trend', trendRouter);
|
app.route('/trend', trendRouter);
|
||||||
|
app.route('/vehicle', vehicleRecentRouter);
|
||||||
|
|
||||||
// 启动时立即刷新缓存,之后每分钟刷新
|
// 启动时立即刷新缓存,之后每分钟刷新
|
||||||
refreshMonitoringCache();
|
refreshMonitoringCache();
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ app.get('/', async (c) => {
|
|||||||
filters = buildDateFilters(allVehicles); // 重算筛选选项以匹配权限范围
|
filters = buildDateFilters(allVehicles); // 重算筛选选项以匹配权限范围
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 区域级联:选中运营区域时,下游筛选选项(车牌等)只展示该区域车辆
|
||||||
|
if (filterParams.region) {
|
||||||
|
const regionScope = allVehicles.filter(v => v.region === filterParams.region);
|
||||||
|
filters = buildDateFilters(regionScope);
|
||||||
|
}
|
||||||
|
|
||||||
const filtered = applyFilters(allVehicles, filterParams);
|
const filtered = applyFilters(allVehicles, filterParams);
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
|
|||||||
@@ -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()
|
||||||
`;
|
`;
|
||||||
|
|||||||
101
src/server/routes/mileage/vehicle-recent.ts
Normal file
101
src/server/routes/mileage/vehicle-recent.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import mileagePool from '../../mileage-db.js';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
interface DayRow {
|
||||||
|
date: string;
|
||||||
|
daily_km: string | number | null;
|
||||||
|
source: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseYmd(s: string): Date | null {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
||||||
|
if (!m) return null;
|
||||||
|
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return Number.isFinite(d.getTime()) ? d : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DAYS = 366;
|
||||||
|
|
||||||
|
app.get('/:plate/recent', async (c) => {
|
||||||
|
const plate = c.req.param('plate');
|
||||||
|
if (!plate) return c.json({ plate: '', days: [] }, 400);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// 区间参数:优先 start/end;否则回退 days(兼容旧调用)
|
||||||
|
const startQ = c.req.query('start');
|
||||||
|
const endQ = c.req.query('end');
|
||||||
|
let start: Date;
|
||||||
|
let end: Date;
|
||||||
|
if (startQ) {
|
||||||
|
const ps = parseYmd(startQ);
|
||||||
|
if (!ps) return c.json({ plate, days: [] }, 400);
|
||||||
|
start = ps;
|
||||||
|
end = endQ ? (parseYmd(endQ) ?? today) : today;
|
||||||
|
} else {
|
||||||
|
const days = Math.min(Math.max(Number(c.req.query('days')) || 15, 1), MAX_DAYS);
|
||||||
|
end = today;
|
||||||
|
start = new Date(today);
|
||||||
|
start.setDate(today.getDate() - (days - 1));
|
||||||
|
}
|
||||||
|
if (start > end) [start, end] = [end, start];
|
||||||
|
// 限制区间长度
|
||||||
|
const span = Math.round((end.getTime() - start.getTime()) / 86400000) + 1;
|
||||||
|
if (span > MAX_DAYS) {
|
||||||
|
start = new Date(end);
|
||||||
|
start.setDate(end.getDate() - (MAX_DAYS - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rows] = await mileagePool.execute(
|
||||||
|
`SELECT DATE_FORMAT(stat_date, '%Y-%m-%d') AS date, daily_km, source
|
||||||
|
FROM v_vehicle_daily_stats
|
||||||
|
WHERE plate = ? AND stat_date >= ? AND stat_date <= ?
|
||||||
|
ORDER BY stat_date`,
|
||||||
|
[plate, fmt(start), fmt(end)]
|
||||||
|
) as [DayRow[], unknown];
|
||||||
|
|
||||||
|
// 同一 plate 同一天可能有多个数据源,取最大 daily_km
|
||||||
|
const map = new Map<string, { dailyKm: number; source: string }>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const km = Number(r.daily_km) || 0;
|
||||||
|
const src = r.source || 'NONE';
|
||||||
|
const existing = map.get(r.date);
|
||||||
|
if (!existing || km > existing.dailyKm) {
|
||||||
|
map.set(r.date, { dailyKm: km, source: src });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 补全:从 start 到 end 每天一条
|
||||||
|
const result: { date: string; dailyKm: number; isDataSynced: boolean }[] = [];
|
||||||
|
const cursor = new Date(start);
|
||||||
|
while (cursor <= end) {
|
||||||
|
const key = fmt(cursor);
|
||||||
|
const hit = map.get(key);
|
||||||
|
result.push({
|
||||||
|
date: key,
|
||||||
|
dailyKm: hit?.dailyKm ?? 0,
|
||||||
|
isDataSynced: !!hit && hit.source !== 'NONE',
|
||||||
|
});
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ plate, start: fmt(start), end: fmt(end), days: result });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error('vehicle recent error:', e);
|
||||||
|
return c.json({ plate, days: [] }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -793,11 +793,21 @@ app.get('/region-stats', async (c) => {
|
|||||||
cityMap.get(city)!.push(v);
|
cityMap.get(city)!.push(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTypeBreakdown = (vList: Vehicle[]) =>
|
const getTypeBreakdown = (vList: Vehicle[]) => {
|
||||||
['4.5T', '18T', '49T'].map((type) => {
|
const KNOWN = ['4.5T', '18T', '49T'] as const;
|
||||||
const tv = vList.filter((v) => v.type === type);
|
const make = (label: string, tv: Vehicle[]) => ({
|
||||||
return { type, total: tv.length, operating: tv.filter((v) => v.status === 'Operating').length, inventory: tv.filter((v) => v.status === 'Inventory').length, customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[] };
|
type: label,
|
||||||
}).filter((t) => t.total > 0);
|
total: tv.length,
|
||||||
|
operating: tv.filter((v) => v.status === 'Operating').length,
|
||||||
|
inventory: tv.filter((v) => v.status === 'Inventory').length,
|
||||||
|
pending: tv.filter((v) => v.status === 'Pending').length,
|
||||||
|
customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[],
|
||||||
|
});
|
||||||
|
const known = KNOWN.map((type) => make(type, vList.filter((v) => v.type === type)));
|
||||||
|
const other = vList.filter((v) => !KNOWN.includes(v.type as typeof KNOWN[number]));
|
||||||
|
if (other.length > 0) known.push(make('其他', other));
|
||||||
|
return known.filter((t) => t.total > 0);
|
||||||
|
};
|
||||||
|
|
||||||
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
|
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
|
||||||
const result = regionOrder
|
const result = regionOrder
|
||||||
|
|||||||
@@ -10,8 +10,27 @@ export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep'];
|
|||||||
/** 智能调度模块访问角色 */
|
/** 智能调度模块访问角色 */
|
||||||
export const SCHEDULING_ACCESS_ROLES = ['BI-SCHEDULE-OPT'];
|
export const SCHEDULING_ACCESS_ROLES = ['BI-SCHEDULE-OPT'];
|
||||||
|
|
||||||
|
/** 反馈管理(管理员)访问角色 */
|
||||||
|
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;
|
||||||
return roles.some(r => SCHEDULING_ACCESS_ROLES.includes(r));
|
return roles.some(r => SCHEDULING_ACCESS_ROLES.includes(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 用户是否可管理反馈。仅 BI-ADMIN-FEEDBACK 或全量权限角色可访问。 */
|
||||||
|
export function canManageFeedback(roles: readonly string[] | null | undefined): boolean {
|
||||||
|
if (!roles || roles.length === 0) return false;
|
||||||
|
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